Skip to content
Latchkey LogoLatchkey home

Retry failed steps in GitHub Actions at the right level

GitHub Actions has no built-in step retry, so to have GitHub Actions retry failed steps you choose one of four patterns: a retry action such as nick-fields/retry, a job re-run with gh run rerun --failed, the test runner's own retries, or continue-on-error with a second step. Each repeats something different, so retry at the level that gets a fresh copy of whatever failed, and never retry an assertion silently.

A step failed for a reason that had nothing to do with your change, and you want the workflow to try again by itself. The workflow syntax has continue-on-error and timeout-minutes but no retry key, so every option is a pattern on top of something else.

The patterns are not interchangeable. A step retry reruns a command on the same machine. A job re-run gets a new machine. A test retry reruns one test inside the same process tree. Choosing the wrong one either fails to fix the failure or hides the bug that caused it.

Four retry levels compared: what each repeats, whether it gets a new machine, and what it can hide
Retry at the level that replaces what failed. Only a job re-run gets a new machine; only a test retry knows which test flipped.

Four levels, four different things repeated

Pick by what failed. A network fetch inside a step wants the step retried. A machine that ran out of memory or lost its connection wants a new machine, which only a job re-run provides. A single test that flips wants the test runner, because the runner is the only level that knows which test it was.

LevelMechanismWhat runs againNew machineWhat it can hide
Stepnick-fields/retry, or a second step after continue-on-errorOne commandNoAn assertion failure, if the command is the test suite
Jobgh run rerun --failed, or Re-run failed jobs in the UIFailed jobs and their dependentsYes on GitHub-hostedAny intermittent bug, unless the attempt count is recorded
TestPlaywright retries, jest.retryTimes, pytest --reruns, Surefire rerunFailingTestsCountThe failed testNoRaces and shared state, unless flaky results are reported
Nonecontinue-on-error: trueNothing; the failure is ignoredNoEverything that step or job checks

Step level: retry the fetch, not the suite

nick-fields/retry v4.0.0 is the common step-level option. It needs command, max_attempts, and exactly one of timeout_minutes or timeout_seconds. Version 4 moved the action to the Node 24 runtime; the inputs did not change.

Read retry_on carefully. In the action's source, error means any nonzero exit, so wrapping npm test retries assertion failures exactly like network errors. Wrap the step that talks to the network, such as the install, and leave the test step alone. retry_on_exit_code narrows it to one exit code.

The action keeps its retries visible by default: warning_on_retry is true, so each failed attempt becomes a warning annotation reading Attempt 1 failed. Reason: followed by the error, and the total_attempts output can go into the step summary.

.github/workflows/ci.yml
- id: install
  uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
  with:
    command: npm ci
    max_attempts: 3
    timeout_minutes: 10
    retry_on: error
- if: always()
  run: echo "npm ci attempts=${{ steps.install.outputs.total_attempts }}" >> "$GITHUB_STEP_SUMMARY"
- run: npm test   # not wrapped: an assertion fails once and stays failed

Job level: a new machine, the same commit

A job re-run runs the same GITHUB_SHA on a new runner, which is the only retry that helps when the machine itself was the problem. From the UI it is Re-run jobs, then Re-run failed jobs; from the terminal it is gh run rerun <run-id> --failed, which also re-runs the jobs that depend on them. A run can be re-run at most 50 times, within 30 days.

To make it automatic without making it blind, trigger on workflow_run, re-run once, and only when the failed log carries a machine signal. The run_attempt == 1 guard caps it at one automatic re-run. Note that workflow_run triggers only if the workflow file exists on the default branch.

.github/workflows/rerun.yml
name: Re-run machine failures once
on:
  workflow_run:
    workflows: [CI]
    types: [completed]
permissions:
  actions: write
jobs:
  rerun:
    if: github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.run_attempt == 1
    runs-on: ubuntu-latest
    env:
      GH_REPO: ${{ github.repository }}
      GH_TOKEN: ${{ github.token }}
      RUN_ID: ${{ github.event.workflow_run.id }}
    steps:
      - run: |
          if gh run view "$RUN_ID" --log-failed |
             grep -qE 'exit code 137|lost communication with the server|No space left on device|ECONNRESET|EAI_AGAIN'; then
            echo "Re-ran run $RUN_ID once: machine signal in the failed log" >> "$GITHUB_STEP_SUMMARY"
            gh run rerun "$RUN_ID" --failed
          else
            echo "Left run $RUN_ID red: no machine signal" >> "$GITHUB_STEP_SUMMARY"
          fi

Test level: the runner's own retries

Test runners retry one test, in the same job, and the good ones record that they did. Playwright marks a test that failed then passed as flaky. Surefire counts it under Flakes in its summary. pytest-rerunfailures prints rerun counts, and its --only-rerun takes a regular expression, so you can rerun a connection error and never an AssertionError. Jest's retryTimes works only with the default jest-circus runner and must be called at the top level of a file or in a describe block.

This is the right level for evidence, the wrong level for machine faults: a retried test still runs on the machine that just ran out of memory.

Test runner retry settings
# Playwright, playwright.config.ts
retries: process.env.CI ? 2 : 0,

# Jest (jest-circus), top of a test file
jest.retryTimes(2, { logErrorsBeforeRetry: true });

# pytest-rerunfailures: rerun connection errors, never assertions
pytest --reruns 2 --only-rerun ConnectionError

# Maven Surefire
mvn -Dsurefire.rerunFailingTestsCount=2 test

continue-on-error is not a retry

continue-on-error: true on a step lets the job pass when the step fails; on a job it lets the workflow run pass when the job fails. Nothing runs again. Its legitimate uses are experimental matrix legs and the native retry below, where steps.<id>.outcome still reads failure while conclusion reads success, so a second step can try once more and say so with a warning annotation.

.github/workflows/ci.yml
- id: first
  continue-on-error: true
  run: docker pull ghcr.io/acme/test-db:16
- if: steps.first.outcome == 'failure'
  run: |
    echo "::warning title=Retried::docker pull failed once and was retried"
    docker pull ghcr.io/acme/test-db:16

Key takeaways

  • GitHub Actions has no native step retry; every option is a pattern with a different scope.
  • nick-fields/retry retry_on: error retries any nonzero exit, assertions included. Wrap the install, not the tests.
  • Only a job re-run gets a new machine. Automate it once, and only on a machine signal.
  • Test-level retries are where flakiness gets recorded; keep the flaky count visible.

Frequently asked questions

Does GitHub Actions have a built-in retry for steps?
No. The workflow syntax has continue-on-error and timeout-minutes but no retry key. Use a retry action such as nick-fields/retry for a step, gh run rerun --failed for jobs, or your test runner's retries for individual tests.
How do I re-run only the failed jobs in GitHub Actions?
In the run summary, open the Re-run jobs dropdown and choose Re-run failed jobs, or run gh run rerun <run-id> --failed. Jobs that depend on the failed ones run again too. The re-run uses the same commit SHA and the privileges of the actor who triggered the original run.
How do I retry a GitHub Actions step on failure?
Wrap the command in nick-fields/retry with max_attempts, a timeout and command, or run the step with continue-on-error: true and add a second step that runs when steps.<id>.outcome is failure. Retry the network call, not the test suite.
Do retries hide real bugs?
They can. A retry that turns a race into a green build removes the evidence while the bug stays. Retries are safe when they are scoped to machine and network failures and when every retry leaves a record: a warning annotation, a flaky count or an attempt number in the summary.

References

A retry nobody can see hides bugs. Latchkey logs every repair and leaves failing tests red. Start free → 30-day trial · No credit card