How Do I Retry a Failed Step in GitHub Actions?
GitHub Actions has no per-step retry. Wrap the command in a shell loop with backoff or use a retry action, and only for transient network and registry errors.
Answer
Retry a failed step in GitHub Actions by wrapping the command in a shell retry loop with backoff inside the run block, or by putting a retry action around the command, and scope that retry to the classes of failure that are genuinely transient. GitHub Actions has no per-step retry setting: the re-run controls replay a whole run or a whole failed job, and continue-on-error only stops a failed step from failing the job.
Scope is the part that decides whether the retry helps. A network call to a registry you do not own can succeed on the second attempt. An assertion in a test suite cannot, so retrying it bills the same minutes again and pushes the red result later.
| Failure signature | Retry it | Reason |
|---|---|---|
| Connection reset, DNS failure, or TLS timeout against a package registry | Yes, with backoff | The remote side changed between attempts |
| HTTP 429 or 503 from npm, PyPI, Maven Central, or a container registry | Yes, with backoff | Rate limits and upstream restarts clear on their own |
toomanyrequests or i/o timeout on a Docker image pull | Yes, with backoff | Pull limits reset; a pull-through cache removes the retry entirely |
| Cloud API throttling during a deploy step | Yes, with backoff | The API returns a retryable status by design |
| Assertion failure in a test | No | The same input produces the same result |
| Compile, type, or lint error | No | Deterministic in the checked-out tree |
Exit code 137 or Killed | No | The machine ran out of memory; size the runner up instead |
no space left on device | No | Disk state carries into the next attempt |
| HTTP 401 or 403 from any endpoint | No | A credential problem repeats until the credential changes |
The one rule that holds across all of them: retry the step that talks to a machine you do not control, and fix the step that fails on your own code.
Detail
The retry loop written in the step
A loop in the run block needs no third-party action and gives exact control over the attempt count, the backoff, and the exit code.
name: publish
on:
push:
tags: ["v*"]
jobs:
publish:
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm test
- name: Publish to registry
shell: bash
timeout-minutes: 5
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
attempt=1
max_attempts=3
delay=5
until npm publish --access public; do
if [ "$attempt" -ge "$max_attempts" ]; then
echo "npm publish failed after $attempt attempts"
exit 1
fi
echo "attempt $attempt failed, retrying in ${delay}s"
sleep "$delay"
attempt=$((attempt + 1))
delay=$((delay * 2))
doneThree details make this safe to leave in a workflow. The attempt cap exits non-zero after the last try, so a permanent failure still turns the job red. The delay doubles from 5 to 10 to 20 seconds, which spreads attempts across a rate-limit window instead of stacking three requests inside 15 seconds. timeout-minutes on the step bounds the whole loop, which matters because the job-level default is 360 minutes and every one of those minutes bills.
The action alternative
A retry action is shorter and gives a per-attempt timeout, which a plain loop does not. nick-fields/retry is the common public choice:
- name: Publish to registry
uses: nick-fields/retry@v3
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
with:
timeout_minutes: 5
max_attempts: 3
retry_wait_seconds: 15
retry_on: error
command: npm publish --access publictimeout_minutes applies to each attempt rather than to the step as a whole, so a hung connection is killed and retried instead of holding the runner. retry_on: error retries on a non-zero exit and leaves timeouts alone; retry_on: timeout does the reverse. Pin the action to a tag or a commit SHA the same way you pin actions/checkout, because the retry wrapper sees the same secrets the command does.
What blind retries cost
Wrapping every step in three attempts is where the minutes go. Take a repository with 300 red pull-request runs per month on warp-ubuntu-latest-x64-4x at $0.008 per minute, from the WarpBuild pricing page, checked on 2026-08-13.
| Setup | Added minutes per red run | Monthly minutes | Monthly cost |
|---|---|---|---|
| 3 attempts around a 6 minute test step, 15 second waits | 2 x (6 + 0.25) = 12.5 | 3,750 | $30.00 |
| 3 attempts around a 40 second publish step, on the 20 runs per month that hit a transient error | 2 x (0.67 + 0.25) = 1.83 | 37 | $0.29 |
| GitHub's re-run failed jobs on a 12 minute job | 12 | 3,600 | $28.80 |
The dollar figure is the smaller half of the bill. The blind setup also adds 12.5 minutes to every failure notification, which is 3,750 minutes of waiting per month, about 62 hours of engineer time spent watching a run that was already going to fail. The scoped setup pays 37 minutes for the same protection because it runs only when a registry actually misbehaves.
Runner rates differ by platform, and the arithmetic above moves with them. Priced per minute as follows on the same pricing page, checked on 2026-08-13.
| Label | vCPU | RAM | Per minute |
|---|---|---|---|
warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 |
warp-ubuntu-latest-arm64-4x | 4 | 16 GB | $0.006 |
warp-windows-latest-x64-4x | 4 | 16 GB | $0.016 |
warp-macos-26-arm64-6x | 6 | 22 GB | $0.08 |
Two wasted 6 minute attempts bill $0.096 on warp-ubuntu-latest-x64-4x and $0.96 on warp-macos-26-arm64-6x at these rates, so scope retries tightest on the expensive platforms. The loop syntax changes across platforms too: the default shell on Windows runners is PowerShell, so either set shell: bash on the step or write the loop with $LASTEXITCODE and Start-Sleep.
When the retry is the wrong tool
Some failures look transient and are not. A job that never picks up a runner, a Dependabot workflow that skips on a public repository, or an Android emulator step that falls back to software emulation all fail in ways a retry loop repeats, and the fixes are in the WarpBuild common issues documentation. Runner-side timeouts that repeat on the same hostname belong in the guide to GitHub Actions network timeouts rather than behind more attempts.
For the ordering question of where retries sit against caching, runner sizing, and job splitting, work through the guide to speeding up GitHub Actions. Retries buy stability, and every attempt they add is a minute the pipeline was already trying to remove.
Related Questions
Does GitHub Actions have a built-in retry for a single step?
No. GitHub's re-run controls work at the run and job level, so re-running a failed job replays every step in that job from the top. Per-step retry comes from a loop written in the run block or from a retry action wrapped around the command, and both are scoped to the one step you name. The ordering of retries against the other levers is covered in the guide to speeding up GitHub Actions.
Which failures should I never retry?
Assertion failures, compile and type errors, exit code 137 from memory pressure, no space left on device, and 401 or 403 credential errors. Each of those repeats on the next attempt, so the extra attempts bill runner minutes and delay the red result without changing it. Timeouts against an external host are the borderline case, and the guide to GitHub Actions network timeouts separates the ones worth retrying from the ones that need a mirror or a longer client timeout.
Do retries hide a flaky test?
Yes, when the retry wraps the test step. A suite that fails on attempt one and passes on attempt two reports a green run, which removes the failure from the success-rate ranking that finds unstable jobs. A flaky test produces different results on the same input, and the ranking is how you find it. Retry the network calls a test makes, then follow the guide to flaky GitHub Actions jobs for the suite itself.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.