Network Timeouts in GitHub Actions Jobs

A GitHub Actions job that times out downloading dependencies is hitting a registry rate limit, a DNS failure, or a slow mirror. Read the log signature first.

A GitHub Actions job that stalls or fails while downloading dependencies is hitting one of three things: a registry rate limit, a transient DNS failure, or a slow package mirror, and each one leaves a different string in the log. Read the signature first, then apply the matching fix, which is a bounded retry around the fetch step for the transient cases and a warm cache or a prepared machine image so the fetch stops happening at all.

This guide covers the three log signatures, the retry that is worth keeping in a workflow, the client timeout settings per package manager, and a model that prices the wasted minutes.

Diagnosis

Separate the three sources before changing anything, because a retry helps two of them and hides the third.

SourceLog signatureWhat confirms it
Registry rate limit429 Too Many Requests from npm or PyPI, toomanyrequests: You have reached your pull rate limit from a container registry, 503 from a Maven mirrorThe response arrives fast and carries a status code. Failures cluster by registry across unrelated repositories and clear after a wait
Transient DNS failuregetaddrinfo EAI_AGAIN registry.npmjs.org, Temporary failure in name resolution, dial tcp: lookup ...: no such host, Could not resolve hostSeveral unrelated jobs go red inside the same few minutes, then everything recovers. The next attempt seconds later succeeds
Slow package mirrorETIMEDOUT or ESOCKETTIMEDOUT from npm, Read timed out. (read timeout=15) from pip, Connection timed out [IP: ...] from aptNo status code appears. The step runs for the length of the client timeout and the byte count in the log stops growing before it ends

Registry rate limits are the only one of the three that answers you. A status code in the log means the remote host is reachable, resolved, and refusing the request on purpose, so the fix is spacing the requests out, authenticating the pull, or pulling from a copy you control. The guide to GitHub Actions rate limits covers which limits apply per registry and how to read the headers they return.

Transient DNS failures cluster in time rather than by registry. The tell is that a job fails on one hostname while an unrelated job fails on a different hostname in the same window. These are worth a retry with a short wait, because the resolution path changes between attempts and the second attempt usually succeeds.

Slow package mirrors are the case people misread. Nothing errors, so the step holds the runner while throughput drops toward zero, and the job ends only when a client timeout fires or when the job hits its own limit. A job with no timeout-minutes runs to GitHub's default of 360 minutes (workflow syntax reference), so an unbounded hang bills six hours of runner time for a download that was never going to finish.

Two checks separate a genuine network problem from a runner problem. Turn on debug logging and look for whether any bytes moved before the stall. Then confirm the job actually started on a runner: a job that never picks up a runner because of repository access or runner group restrictions looks like a hang from the outside, and those cases are in the WarpBuild common issues documentation. CI observability, the Action Debugger, and the WarpBuild MCP server all read the same job history, so the clustering question can be answered from a report rather than by opening runs one at a time.

Fix

Apply these in order. The first two bound the damage, and the third removes the failure mode.

1. Bound every step that talks to a registry. Put timeout-minutes on the job and a tighter value on each fetch step. This converts a six-hour hang into a four-minute failure and costs nothing when the network behaves.

2. Keep the retry on the fetch step. A registry pull, a package install, and an artifact download are worth two or three attempts with a doubling wait. Retrying at that level costs the attempt plus the backoff; a whole-run re-run replays checkout, install, build, and test. The step-level and job-level options are compared in how to retry a failed step in GitHub Actions. Keep the retry off the build and test steps, because an assertion failure repeats on every attempt.

3. Remove the fetch. Removing the download removes the timeout with it. Two mechanisms do this, and they cover different state.

  • A cache action moves named paths. Replacing actions/cache@v4 with WarpBuilds/cache@v1 is a one-line change, and the WarpBuild setup actions for Node.js, Python, Go, Java, .NET, Ruby, Zig, Rust, Gradle, and mise wire the same storage in with no key management at all. A warm entry turns a registry round trip into a local restore, so the registry stops being in the critical path on most runs.
  • A snapshot runner captures the whole runner VM mid-workflow, and later jobs boot from that image with dependency trees, pulled container images, and prior build outputs already on disk. Nothing is fetched at job start, which is the case a cache action handles worst. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners; the same idea for named paths is covered in persistent caches for GitHub Actions runs.

Neither mechanism removes the first fetch. The cache still misses when a lockfile changes, and the snapshot is still built from a run that downloaded everything once, so keep the bounded retry from step 2 in place for those runs.

Configuration

Client defaults decide how long a stalled connection holds the runner. Set them explicitly on the steps that fetch.

ToolTimeout settingRetry setting
npmnpm config set fetch-timeout 120000 (milliseconds)fetch-retries, default 2
pippip install --timeout 60 (default read timeout is 15 seconds)--retries, default 5
aptAcquire::http::Timeout "30"; in an apt.conf.d fileAcquire::Retries "3";
curl--connect-timeout 10 --max-time 300--retry 3 --retry-all-errors
githttp.lowSpeedLimit 1000 with http.lowSpeedTime 30none; wrap the clone instead

Turn the client retries off when an outer loop is doing the retrying, so the attempt count stays predictable and the backoff is the one you wrote.

The workflow below wraps only the two steps that talk to a remote host. Build and test carry a job timeout and no retry.

name: pr-build
on:
  pull_request:

jobs:
  build:
    runs-on: warp-ubuntu-latest-x64-8x
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v4

      - uses: WarpBuilds/setup-node@v6
        with:
          node-version: 22
          cache: npm

      - name: Install dependencies
        shell: bash
        timeout-minutes: 8
        run: |
          set -euo pipefail
          npm config set fetch-timeout 120000
          npm config set fetch-retries 0
          attempt=1
          max_attempts=3
          delay=10
          until npm ci; do
            if [ "$attempt" -ge "$max_attempts" ]; then
              echo "npm ci failed after $attempt attempts"
              exit 1
            fi
            echo "attempt $attempt failed, retrying in ${delay}s"
            sleep "$delay"
            attempt=$((attempt + 1))
            delay=$((delay * 2))
          done

      - name: Pull test fixtures image
        shell: bash
        timeout-minutes: 4
        run: |
          set -euo pipefail
          for attempt in 1 2 3; do
            docker pull ghcr.io/acme/test-fixtures:2026-08 && exit 0
            sleep $((attempt * 10))
          done
          echo "image pull failed after 3 attempts"
          exit 1

      - name: Build
        run: npm run build

      - name: Test
        run: npm test

Four properties make this shape safe to leave in place. The attempt cap exits non-zero after the last try, so a permanent failure still turns the job red. The wait doubles from 10 to 20 seconds, which spreads the attempts across a rate-limit window instead of stacking three requests inside 30 seconds. timeout-minutes on each fetch step bounds the loop below the job limit. And the cache: npm line on the setup action means most runs restore the dependency tree and never enter the loop at all.

Platform placement decides what is available. WarpBuild caching is not supported on Windows runners, so a Windows job in the same workflow keeps its existing setup steps and relies on the bounded retry alone. Cache entries expire 7 days after last use, which is why a repository with a quiet week pays the cold fetch again on Monday.

Cost or Time Model

Take one pull request gate: 800 runs a month, one build job billing 12 minutes, on warp-ubuntu-latest-x64-8x at $0.016 per minute from the WarpBuild pricing page, checked on 2026-08-13. Baseline is 9,600 billed minutes, or $153.60. Assume 5 percent of runs, so 40 a month, hit a network failure about 4 minutes into the job.

PostureExtra minutes per incidentIncidents per monthExtra minutesExtra cost
No retry, developer re-runs the whole workflow4 wasted plus a 12 minute re-run, so 1640640$10.24
Bounded retry on the fetch steps only2 attempts of 40 seconds plus 30 seconds of waiting, so 1.84072$1.15
Blind retry around an 8 minute test step2 x (8 + 0.25), so 16.560 red runs990$15.84

The third row is the trap. Wrapping every step in three attempts costs more than the failures it absorbs, and it converts a flaky test into a green run, which removes it from the success-rate ranking that would have found it.

Now price the structural fix. A cold npm ci against the registry takes 100 seconds on this repository; a warm cache restore takes 15 seconds, so each run drops 1.42 minutes.

LineMonthly effect
Fetch minutes removed, 1,136 at $0.016$18.18
Re-run minutes removed, 640 at $0.016$10.24
Cache storage added, 2 GB at $0.20 per GB-month-$0.40
Cache operations added, 1,600 at $0.0001-$0.16
Net$27.86

The dollar column is the smaller half. The same change removes 1,776 minutes of wall clock a month from runs a developer is watching, and it removes the incident class rather than absorbing it, because the registry is no longer in the critical path on a cache hit.

Where the state is the machine rather than a set of paths, price a snapshot instead. A snapshot restore is $0.04 per job, which is 2.5 minutes at $0.016 per minute, so a snapshot pays when it removes more than 2.5 minutes of setup per run on this size. Snapshot storage is $0.025 per snapshot-hour, or $18.00 a month per alias held continuously.

For the order in which this sits against runner sizing, job splitting, and caching work, see the guide to speeding up GitHub Actions.

FAQ

Why does my GitHub Actions job time out while downloading dependencies?

Three sources cover almost every case. A registry rate limit returns HTTP 429 or a toomanyrequests message and clears on its own. A transient DNS failure prints EAI_AGAIN or a name resolution error and clears in seconds. A slow package mirror never returns an error at all; the client sits on an open connection until its own read timeout fires, so the step ends at whatever timeout the tool defaults to.

Should I retry the whole job or only the fetch step?

Only the fetch step. A whole-job re-run replays checkout, install, build, and test, so one failed download bills the full job a second time. A bounded retry around the install or pull step costs the attempt plus the backoff wait, which is under two minutes in most workflows against 12 or more minutes for a re-run.

How long can a hung download bill before GitHub stops it?

A job with no timeout-minutes set runs to GitHub's default of 360 minutes, and every one of those minutes bills. Set timeout-minutes on the job and a tighter one on each step that talks to a registry, so a hung connection is killed in minutes rather than hours.

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.