# cargo spurious network error is a warning, not the failure

> A cargo spurious network error line is a warning that cargo is about to retry. Read the countdown, then read the error that actually ended the build.

Source: https://latchkey.dev/learn/failures/cargo-spurious-network-error-in-ci  
Updated: 2026-09-21

A cargo spurious network error line is not the reason your build failed, it is cargo telling you it classified a failure as transient and is going to try again, with the number of attempts it has left in brackets. The line that ends the build is further down and says something different, and confusing the two is why teams raise a retry count that was never the constraint.

## What this error means

One or more `warning:` lines appear during `cargo fetch`, `cargo build` or `cargo update`, each reading `spurious network error (N tries remaining)` followed by a colon and a description of what went wrong. The count goes down, and the last one is singular: `1 try remaining`, because the pluralisation is chosen per line. If the build then succeeds, those warnings are the whole story and nothing is wrong beyond a blip. If it fails, the failure is reported separately as an `error:` with a `Caused by:` chain, and the deepest entry in that chain is the same description that was in the brackets. A build that prints no warning at all and fails immediately means cargo did not consider the failure transient, which is a much stronger signal than any of the warnings.

```Captured locally on cargo 1.94.1, 2026-09-21, against a local 503 responder. Not runner output
warning: spurious network error (3 tries remaining): failed to get successful HTTP response from `http://127.0.0.1:8944/index/config.json` (127.0.0.1), got 503
body:

warning: spurious network error (2 tries remaining): failed to get successful HTTP response from `http://127.0.0.1:8944/index/config.json` (127.0.0.1), got 503
body:

warning: spurious network error (1 try remaining): failed to get successful HTTP response from `http://127.0.0.1:8944/index/config.json` (127.0.0.1), got 503
body:
```

## Common causes

### The index or a crate download hit a 5xx, and four attempts were not enough

The sparse index is many small HTTP requests rather than one git fetch, so a proxy or CDN having a bad minute produces several independent failures in one build. Each gets its own ladder, which is why a single bad minute can print a dozen warnings. This is the version the default budget usually survives and the version that a slightly larger budget survives more often.

### The registry answers 403 when it means slow down

Some corporate proxies and mirrors return 403 rather than 429 for throttling. Cargo does not treat 403 as spurious, so the build fails on the first attempt with no warning at all. The absence of a countdown is the diagnostic here: a throttle that cargo recognised would have printed one, so a silent instant failure points at a status code cargo has classified as permanent.

### Every job downloads the whole index and every crate, because nothing is cached

A workflow with no cache for the registry index and the downloaded crates re-fetches the full dependency set on every run, multiplied by the matrix. That is a large number of requests against one host per push, so a failure rate too small to notice on a laptop becomes a daily red build. In our experience this is the real reason a team starts seeing these warnings, rather than anything changing at the registry.

### A middlebox is cutting large crate downloads short

A proxy with an idle timeout or a response size limit tends to break the big `.crate` transfers while leaving the small index requests alone. Libcurl reports that as a partial file or a receive error, both of which cargo counts as spurious, so you get the countdown. The tell is that the same few heavy dependencies appear in the warnings run after run while the rest of the graph is quiet.

## How to fix it

### Cache the registry index and the downloaded crates

1. Cache `~/.cargo/registry/index`, `~/.cargo/registry/cache` and `~/.cargo/git/db`, keyed on the lockfile so the key moves only when a resolved version moves.
2. Cache `target` separately if you want build reuse. Mixing the two makes one large cache that is slower to restore and is invalidated by things that have nothing to do with the network.
3. Confirm the cache is being hit by reading the restore step output rather than assuming, because a key that never matches looks exactly like having no cache at all.

```.github/workflows/ci.yml
- uses: actions/cache@v4
  with:
    path: |
      ~/.cargo/registry/index
      ~/.cargo/registry/cache
      ~/.cargo/git/db
    key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
    restore-keys: cargo-${{ runner.os }}-
```

### Raise the retry budget where the failures are genuinely transient

Set it in the job environment so every cargo invocation in the job agrees, rather than on the one command that failed. Five is a reasonable ceiling in CI: with a base of 500 milliseconds and a ten second cap, the extra attempts add seconds rather than minutes, and a budget larger than that mostly delays the answer you want when the registry is truly down.

```.github/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    env:
      CARGO_NET_RETRY: "5"
      CARGO_HTTP_MULTIPLEXING: "false"
    steps:
      - uses: actions/checkout@v5
      - run: cargo test --locked
```

### Fetch once, early, so the network failure is not buried in a long build

Running `cargo fetch --locked` as its own step puts every network request in one place. The step either succeeds or fails in seconds, the warnings are attributable to it, and a compile failure later in the job can no longer be mistaken for a network problem.

```.github/workflows/ci.yml
- run: cargo fetch --locked
- run: cargo build --offline --all-targets
- run: cargo test --offline
```

### Check what your mirror returns when it throttles

If you use a replacement source, ask it directly. A registry that answers 403 for a rate limit will never be retried by cargo, and the fix is to configure the mirror to answer 429 rather than to change anything in your workflow. This is a five minute check that ends a category of intermittent failure permanently.

```Terminal
curl -s -o /dev/null -w "%{http_code}\n" https://your-mirror.example.com/index/config.json
```

## How to prevent it

- Cache the registry index and crate cache on every Rust job, keyed on Cargo.lock.
- Put CARGO_NET_RETRY in the job environment so a single value covers every cargo call in the job.
- Isolate the network in a `cargo fetch --locked` step and build offline afterwards.
- Confirm that any mirror you depend on answers 429 rather than 403 when it throttles.

## What cargo means by spurious

The word is not a judgement about your network, it is the name of a predicate. `maybe_spurious` in `src/cargo/util/network/retry.rs` returns true for a short, explicit list and false for everything else. From libcurl it accepts could not connect, could not resolve host, could not resolve proxy, operation timed out, receive error, send error, an HTTP/2 error, an HTTP/2 stream error, an SSL connect error and a partial file. From git2 it accepts the network, operating system, zlib and HTTP error classes, with one carve-out: a certificate error is never spurious. From HTTP it accepts any 5xx and a 429.

Everything outside that list fails on the first attempt with no warning, and that includes the two responses people most often want retried. A 404 is not spurious, so a missing crate fails instantly. A 403 is not spurious either, which matters if your registry uses 403 as its rate-limit response, because cargo will treat a throttle as a permanent refusal.

The retry budget is `net.retry`, documented as the number of times to retry, default 3, for four attempts in total. Sleeps start from a base of 500 milliseconds with up to a second of jitter and are capped at ten seconds. When the failure is an HTTP response carrying a `Retry-After` header, cargo parses it and uses it instead, still capped at the same ten seconds, so a server asking for a two minute pause gets ten seconds.

| What happened | Spurious? | What you see |
| --- | --- | --- |
| Connection refused or timed out | Yes | A warning per attempt, then the error if all fail. |
| HTTP 500, 502, 503 or 504 | Yes | The same countdown, with the status in the warning text. |
| HTTP 429 | Yes | Retried, and `Retry-After` is honoured up to ten seconds. |
| HTTP 403 | No | Fails on the first attempt, with no warning line at all. |
| HTTP 404 | No | Fails at once. The crate or version does not exist there. |
| TLS certificate rejected | No | Fails at once. Explicitly excluded from the git2 branch. |

> The text after the colon is built by `HttpNotSuccessful::display_short`, which writes `failed to get successful HTTP response from `<url>``, then the peer address in brackets when libcurl reported one, then `, got <code>`, then a `body:` line with up to 512 bytes of the response. An empty `body:` line means the server sent no body, which is what a bare 503 from a proxy looks like.

## The line that actually ended the build

When the budget runs out, the warnings stop and cargo reports the failure through its normal error chain. The underlying description is the same string that was inside the warnings, which is the detail that makes the two easy to conflate, but its position in the output is what tells you the run is over.

The block below is from the same run as the warnings above, in the same container, and the only change made to it is that the absolute path of the scratch package has been shortened with an ellipsis. Read it from the bottom: the deepest cause is the HTTP failure, above that is what cargo was doing when it happened, and the top line names the dependency that could not be resolved.

```Captured locally on cargo 1.94.1, 2026-09-21, same run as the warnings above, package path elided
error: failed to get `serde` as a dependency of package `w2-08-probe v0.1.0 (...)`

Caused by:
  failed to query replaced source registry `crates-io`

Caused by:
  download of config.json failed

Caused by:
  failed to get successful HTTP response from `http://127.0.0.1:8944/index/config.json` (127.0.0.1), got 503
  body:
```

## Why this page has no runner reproduction

Making crates.io fail on demand is not something a runner can do honestly, and breaking a runner's egress produces a message about the runner rather than about the registry. Pointing a replacement source at a server that answers 503 to everything produces the exact line this page is about, costs nothing, and runs anywhere, which is why the blocks here came from a container rather than from a billed job.

The addresses in them are therefore ours. `127.0.0.1:8944` is a four-line Python responder, and the countdown, the wording and the empty `body:` line are cargo's. What a runner would have added is proof that a retry sometimes works, which this failure does not need, because the retry ladder is visible in the output and its size is a documented setting. Nothing here claims Latchkey repairs this failure, since `content/heal-evidence.mjs` carries no record for this slug.

## FAQ

### Does a cargo spurious network error warning mean my build failed?

No. The warning is printed at the moment cargo decides to retry, so a build that prints three of them and then goes green recovered on the fourth attempt and nothing is wrong. The failure, if there is one, is reported afterwards as an `error:` with a `Caused by:` chain. Treat the warnings as a health signal about your registry path, not as a build outcome.

### How many times does cargo retry a network request?

Three by default, for four attempts in total, controlled by the `net.retry` config option or the `CARGO_NET_RETRY` environment variable. The sleep starts at half a second with up to a second of jitter and is capped at ten seconds. When the response carries a `Retry-After` header cargo uses that value instead, still capped at ten seconds.

### Why did cargo not retry my 403 or 404?

Because neither is in the list `maybe_spurious` accepts. Only 5xx and 429 count from HTTP, alongside a specific set of libcurl and git2 transport errors, and a certificate error is excluded outright. A 404 means the crate or version is not at that source, and a 403 means the source refused you, so cargo treats both as answers rather than accidents.

### What does the empty "body:" line under the warning mean?

It means the server returned no response body. Cargo always writes a `body:` line and fills it with up to 512 bytes of whatever came back, so an empty one is a bare status with nothing attached, which is typical of a proxy or load balancer answering 503 on its own rather than passing a real registry response through.

## References

- [rust-lang/cargo: retry.rs, maybe_spurious and the warning text](https://github.com/rust-lang/cargo/blob/0.91.0/src/cargo/util/network/retry.rs)
- [rust-lang/cargo: HttpNotSuccessful::render, which builds the text after the colon](https://github.com/rust-lang/cargo/blob/0.91.0/src/cargo/util/errors.rs)
- [The Cargo Book: net.retry and the rest of the net configuration table](https://doc.rust-lang.org/cargo/reference/config.html#netretry)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
