Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

By updated

Improve this page

GitLab logo

Does your pipeline fail one time out of five for no apparent reason? Network timeout, unstable test, unavailable Docker registry, transient errors are the daily life of CI/CD pipelines. This guide gives you the mechanisms to absorb passing errors without hiding the real problems.

A reliable pipeline does not mean a pipeline that never fails. It is a pipeline that fails for the right reasons: a real bug, a real code problem, not a network glitch at 3 a.m.

What you will learn

  • Configure retries targeted by error type (retry:when)
  • Set timeouts adapted to each job
  • Detect and isolate flaky tests
  • Make a job idempotent (rerunnable without side effects)
  • Distinguish a transient error, a code error and an infrastructure error

Prerequisites

This lesson assumes you can read a failing pipeline: open a job's log, find the line where the script fails, and tell a code error from an infrastructure failure, without which no retry setting makes sense. It also assumes you know the execution rules (rules:), because the final example combines a workflow: with a manually started deployment, and that you have already written a multi-job .gitlab-ci.yml.

On the rights and tooling side, no paid edition is needed: the keywords presented here are written in the repository file, so you need to be able to edit it, which a Developer role allows. The two project settings mentioned (the project timeout and the automatic cancellation of redundant pipelines) live under Settings > CI/CD, which requires the Maintainer role on the project: with a Developer role you write the pipeline but you cannot open those settings. No local tool is required. Upstream lessons:

In which context?

Your pipeline runs 50 times a day. Out of these 50 runs, 3 to 5 fail randomly:

  • An npm install that times out because the registry is slow
  • An integration test that fails because the PostgreSQL service was not ready
  • A docker pull that returns a 503 from Docker Hub
  • A unit test that passes locally but fails 1 time out of 10 in CI

Each transient failure triggers a manual investigation, a re-run, and frustration. This guide turns these failures into automatic recoveries.

Targeted retry

Rerunning a failed job is the most immediate reflex, and the most misused. A blind retry turns one clean failure into three slow failures, and sometimes hides a real defect behind a run that eventually passes. GitLab lets you restrict the rerun to certain categories of errors, which completely changes the cost/benefit ratio. The value of max: is capped at 2 retries, that is three executions in total.

Simple retry

The most basic mechanism: rerun a job on failure.

test:
script: npm test
retry: 2 # Up to 2 retries (3 executions max in total)

Problem: this retry catches all errors. A real bug in the tests will be rerun 2 times for nothing, wasting time.

Retry by error type (retry:when)

The best practice: target transient errors only.

build:docker:
script:
- docker build -t myapp .
- docker push registry.example.com/myapp:$CI_COMMIT_SHA
retry:
max: 2
when:
- runner_system_failure
- stuck_pending_with_matching_runners
- scheduler_failure

Available error types

These values correspond to the failure reason recorded by GitLab, visible on the job page. The row to remember is script_failure: it is the non-zero exit code of your own script, so in almost all cases a real defect that no rerun will fix.

Two values in this table are deprecated. The GitLab YAML reference declares stuck_or_timeout_failure and job_execution_timeout deprecated since GitLab 19.1. Each one was split into finer-grained reasons, listed in the table. For stuck_or_timeout_failure, the reference adds that since GitLab 19.0 this reason is kept for historical data only: new jobs carry one of the finer reasons, so configuring it alone no longer retries anything. You will come across these values in existing pipelines, so do not write them in your own.

when valueMeaningRetry recommended?
alwaysAny error (default)No, too broad
unknown_failureUnclassified errorYes
script_failureThe script returned a non-zero codeNo, it is often a real bug
api_failureGitLab API errorYes
stuck_pending_with_matching_runnersJob pending for too long although matching runners existYes
stuck_pending_no_matching_runnersJob pending because no runner matches its tagsNo, configuration error
no_updates_running and no_updates_cancelingJob that no longer shows any sign of life, while running or while being canceledDepends on the context
stuck_or_timeout_failureDeprecated since GitLab 19.1: grouped the four previous reasonsNo, use the values above
runner_system_failureThe runner crashedYes
runner_unsupportedIncompatible runnerNo, not transient
stale_scheduleStale scheduleNo
server_timeout_running and server_timeout_cancelingThe server cut the job for exceeding its time limit, while running or while being canceledDepends on the context
job_execution_timeoutDeprecated since GitLab 19.1: grouped the two previous reasonsNo, use the values above
archived_failureArchived projectNo
unmet_prerequisitesUnmet prerequisitesNo
scheduler_failureScheduling errorYes
data_integrity_failureData integrityYes

The stuck_pending_no_matching_runners value is deliberately absent from the examples on this page: a job that waits for lack of a matching runner reveals a configuration error (tags, protected branch) that a rerun will not fix. A targeted retry stays reserved for infrastructure failures.

The number of retries is tuned according to the cost of one execution and the likelihood that the error is passing. A Docker build depends on remote resources and tolerates two retries; a deployment, for its part, must stay framed by a resource_group.

build:docker:
script:
- docker build -t myapp:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
retry:
max: 2
when:
- runner_system_failure
- stuck_pending_with_matching_runners
- unknown_failure

Docker errors are often linked to the network (pulling base images) or to the registry.

Timeouts

Without an explicit limit, a job inherits the project timeout, set to 60 minutes by default. An npm install that waits indefinitely for an unreachable registry therefore occupies a runner for an hour before giving up. A tight timeout turns that wait into a fast, readable failure, and frees the execution queue for other pipelines.

Per-job timeout

Each job can have its own timeout:

lint:
script: npm run lint
timeout: 5 minutes # Lint should not take more than 5 min
test:unit:
script: npm test
timeout: 15 minutes
test:e2e:
script: npm run test:e2e
timeout: 30 minutes # End-to-end tests are longer
build:docker:
script: docker build .
timeout: 20 minutes

Global project timeout

Configurable in Settings > CI/CD > General pipelines > Timeout (default: 60 minutes).

The job timeout takes precedence over the project one: the GitLab YAML reference states that it can be longer than the project timeout, but not longer than the runner timeout. If the project is at 30 minutes, the job at 45 and the runner at 60, the job is cut at 45.

Choosing the right timeout

These values are starting points to compare with your own execution times, visible in the job history. Only one rule is universal: a timeout far wider than the real duration protects against nothing.

Job typeRecommended timeoutReason
Lint3-5 minVery fast, a long timeout hides a problem
Unit tests10-15 minDepends on the number of tests
Integration tests15-30 minServices to start + tests
Docker build10-20 minDepends on the image and the cache
Deployment10-15 minIf it takes longer, there is a problem
E2E tests20-45 minThe longest, depending on the suite

Handling flaky tests

A flaky (unstable) test sometimes passes and sometimes fails, with no code change. Its real cost is not the time lost rerunning the pipeline: it is the loss of trust in the result. Past a certain instability threshold, the team reruns systematically without reading the error, and a real defect ends up going unnoticed.

Detecting flaky tests with GitLab

GitLab automatically detects flaky tests if you use JUnit reports:

test:
script: npm test -- --reporters=jest-junit
artifacts:
reports:
junit: junit.xml

GitLab flags as flaky a test that fails then passes (or the reverse) on the same MR without a code change. The results are visible in Analytics > CI/CD Analytics > Test cases.

Strategies for flaky tests

The three approaches below are not mutually exclusive: quarantine unblocks the pipeline immediately, test-level retry reduces noise, and only the fix solves the problem. Treat the first two as temporary measures with a deadline, otherwise the unstable test suite keeps growing.

Isolate flaky tests in a separate suite with allow_failure:

test:stable:
script: npm test -- --testPathIgnorePatterns='flaky'
# These must pass: a failure is a real bug
test:flaky:
script: npm test -- --testPathPattern='flaky'
allow_failure: true
# To be watched: does not block the pipeline

allow_failure for non-blocking jobs

A job with allow_failure: true runs and displays its result, but its failure does not stop the pipeline: the interface flags it with a warning rather than an error. The exit_codes: variant restricts this tolerance to specific exit codes, which lets you tell a warning from a blocking error within the same tool.

# The pipeline passes even if this job fails
security:scan:
script: trivy image "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
allow_failure: true # Informational, does not block
# Variant: allow_failure only for specific exit codes
lint:strict:
script: npm run lint:strict
allow_failure:
exit_codes:
- 2 # Warnings = ok, Errors = blocking

Job idempotence

An idempotent job can be rerun without creating side effects. It is the basis of reliability: if the retry creates duplicates or inconsistent states, the remedy is worse than the disease.

Principles

These four rules are easy to check: rerun the same job twice on the same commit and compare the state of the target system. If it differs, the job is not idempotent and the retry becomes dangerous.

PrincipleExample
No creation if it already existsCheck whether the Docker image exists before rebuilding
No global state modifiedUse unique names based on $CI_COMMIT_SHA
Clean up before runningrm -rf dist/ && npm run build
Atomic operationsDeploy to a new directory then switch the symbolic link

Concrete examples

In the two pairs below, the fixed version applies the same idea: name the target after the commit, then switch in a single move. The deployment becomes replayable, and a failure along the way does not leave the application half-updated.

# ❌ Not idempotent: creates duplicates
deploy:
script:
- echo "$CI_COMMIT_SHA" >> /srv/deploy-history.log
- cp -r dist/ /srv/app/
# ✅ Idempotent: the result is the same if rerun
deploy:
script:
- rm -rf /srv/app-$CI_COMMIT_SHA/
- cp -r dist/ /srv/app-$CI_COMMIT_SHA/
- ln -sfn /srv/app-$CI_COMMIT_SHA /srv/app
# ❌ Not idempotent: helm upgrade can fail if the release is in an intermediate state
deploy:k8s:
script:
- helm upgrade myapp ./chart --install
# ✅ Idempotent: forces the desired state
deploy:k8s:
script:
- helm upgrade myapp ./chart --install --atomic --timeout 5m
# --atomic: automatic rollback on failure

interruptible: cancel obsolete jobs

When you push a new commit, the previous commit's jobs are often useless. interruptible lets GitLab cancel them automatically:

default:
interruptible: true # All jobs are cancelable by default
lint:
script: npm run lint
# interruptible: true (inherited from default)
test:
script: npm test
# interruptible: true (inherited from default)
deploy:production:
script: ./deploy.sh
interruptible: false # NEVER interrupt a deployment in progress
resource_group: production

To enable automatic cancellation: Settings > CI/CD > General pipelines > Auto-cancel redundant pipelines.

Complete pattern: a resilient pipeline

Here is a pipeline that combines all the techniques seen so far. The default: block carries the common settings, minimal retry and automatic cancellation, which each job can then override. Note the deliberate asymmetry between the tests, widely tolerant, and the production deployment: manual trigger, non-interruptible and serialized by resource_group.

workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
default:
interruptible: true
retry:
max: 1
when:
- runner_system_failure
- stuck_pending_with_matching_runners
stages:
- lint
- test
- build
- deploy
lint:
stage: lint
timeout: 5 minutes
script: npm run lint
test:unit:
stage: test
timeout: 15 minutes
script: npm test -- --reporters=jest-junit --retries=1
artifacts:
reports:
junit: junit.xml
test:integration:
stage: test
timeout: 20 minutes
services:
- postgres:16-alpine@sha256:cf78e76683b9ca8c5733cbbdce6c9262b45b6767934dd0a95e671f9a0fc20685
script: npm run test:integration
# retry inherited from default: a single retry, infrastructure failures
build:
stage: build
timeout: 15 minutes
script:
- rm -rf dist/
- npm run build
artifacts:
paths: [dist/]
deploy:production:
stage: deploy
timeout: 10 minutes
interruptible: false
script: ./deploy.sh --atomic
retry:
max: 2
when:
- runner_system_failure
resource_group: production
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+/
when: manual

Troubleshooting

The question to ask in front of an unstable pipeline is always the same: does the failure come from the code, from the infrastructure, or from the reliability configuration itself? The first row is a pure configuration error, which prevents any job from starting. The next two fall into the last case, where the mechanism meant to absorb errors becomes itself the cause of the problem.

SymptomLikely causeSolution
Pipeline failed as soon as it is created, no job displayedA job is placed in a stage: that the stages: list does not declare: GitLab then rejects the whole configuration, with no message in the pipeline listValidate the configuration with the project's CI linter (pipeline editor, validation tab), which names the missing stage; declare the stage in stages:
Job rerun but same errorscript_failure included in retry (real bug)Use a targeted retry: when:, exclude script_failure
Endless retry (3 attempts, same timeout)Timeout too short for the jobIncrease the timeout or optimize the job
Flaky test not detected by GitLabNo JUnit reportAdd artifacts: reports: junit:
Pipeline not canceled after a new pushinterruptible not configuredAdd default: interruptible: true
Deployment interrupted halfwayinterruptible: true on the deploy jobExplicitly set interruptible: false
Job that creates duplicates on retryNon-idempotent jobUse unique names ($CI_COMMIT_SHA) and clean up beforehand

Hands-on lab

The lab "Reliable without hiding failures" puts this guide into practice on a GitLab CE forge of your own, set up on a virtual machine with its runner and its registry: no minute quota, no identity verification, and a project you can break as often as you need.

The pipeline is broken before it starts: a job sits in a stage that stages: does not declare, and no job runs at all. This symptom is not covered in the body of the lesson: the first row of the troubleshooting table above gives the approach to diagnose it. You repair it, then make it reliable: a retry limited to infrastructure failures, explicit timeouts, and a lock (resource_group) so two deployments never start together. The grade is based on the locks the forge really created, and it reads the default branch main: that is where you push.

The lab starts with dsoxlab run gl-18-fiabiliser-sans-masquer, after dsoxlab provision prepares the virtual machine, its runner and its registry. Installing DSOXLab and its prerequisites are described in the DSOXLab documentation.

Key takeaways

  • retry: when: targets transient errors, never retry script_failure except in specific cases
  • stuck_or_timeout_failure and job_execution_timeout are deprecated since GitLab 19.1: write the precise reasons (stuck_pending_with_matching_runners, server_timeout_running…), and never retry stuck_pending_no_matching_runners, which signals a configuration error
  • Timeouts must be adapted to each job type (lint 5 min, deploy 10 min)
  • Flaky tests are the first enemy of reliability, detect them with JUnit, fix them first
  • An idempotent job can be rerun without side effects, the key to reliable retry
  • interruptible: true cancels obsolete jobs, except deployments
  • Reliability does not come from a single mechanism but from the combination: targeted retry + adapted timeout + idempotence + interruptible
  • A reliable pipeline fails for the right reasons: a real bug, not a network glitch

Test your knowledge

These questions only cover the notions treated here: targeted retry, timeouts, unstable tests, idempotence and cancellation of obsolete jobs.

Knowledge check

Check what you have learned with this interactive quiz

10 questions
5 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn