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 signatureRetry itReason
Connection reset, DNS failure, or TLS timeout against a package registryYes, with backoffThe remote side changed between attempts
HTTP 429 or 503 from npm, PyPI, Maven Central, or a container registryYes, with backoffRate limits and upstream restarts clear on their own
toomanyrequests or i/o timeout on a Docker image pullYes, with backoffPull limits reset; a pull-through cache removes the retry entirely
Cloud API throttling during a deploy stepYes, with backoffThe API returns a retryable status by design
Assertion failure in a testNoThe same input produces the same result
Compile, type, or lint errorNoDeterministic in the checked-out tree
Exit code 137 or KilledNoThe machine ran out of memory; size the runner up instead
no space left on deviceNoDisk state carries into the next attempt
HTTP 401 or 403 from any endpointNoA 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))
          done

Three 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 public

timeout_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.

SetupAdded minutes per red runMonthly minutesMonthly cost
3 attempts around a 6 minute test step, 15 second waits2 x (6 + 0.25) = 12.53,750$30.00
3 attempts around a 40 second publish step, on the 20 runs per month that hit a transient error2 x (0.67 + 0.25) = 1.8337$0.29
GitHub's re-run failed jobs on a 12 minute job123,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.

LabelvCPURAMPer minute
warp-ubuntu-latest-x64-4x416 GB$0.008
warp-ubuntu-latest-arm64-4x416 GB$0.006
warp-windows-latest-x64-4x416 GB$0.016
warp-macos-26-arm64-6x622 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.

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.