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 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.
| Level | Mechanism | What runs again | New machine | What it can hide |
|---|---|---|---|---|
| Step | nick-fields/retry, or a second step after continue-on-error | One command | No | An assertion failure, if the command is the test suite |
| Job | gh run rerun --failed, or Re-run failed jobs in the UI | Failed jobs and their dependents | Yes on GitHub-hosted | Any intermittent bug, unless the attempt count is recorded |
| Test | Playwright retries, jest.retryTimes, pytest --reruns, Surefire rerunFailingTestsCount | The failed test | No | Races and shared state, unless flaky results are reported |
| None | continue-on-error: true | Nothing; the failure is ignored | No | Everything 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.
- 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 failedJob 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.
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"
fiTest 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.
# 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 testcontinue-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.
- 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:16Key takeaways
- GitHub Actions has no native step retry; every option is a pattern with a different scope.
- nick-fields/retry
retry_on: errorretries 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?
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?
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?
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.