# uv failed to fetch ci, or the retry line that replaces it

> A uv failed to fetch ci error only appears when uv did not retry. When it did, a different top line replaces it. Read which one you have first.

Source: https://latchkey.dev/learn/failures/uv-fetch-failed-in-ci  
Updated: 2026-09-21

A uv failed to fetch ci error appears as the top line only when uv made no retries at all, because uv swaps the top line for a retry summary the moment it retries even once and pushes the fetch error down into the cause chain. That swap is free diagnostic information most readers skip past, since the two lines mean genuinely different things about what your network did.

## What this error means

A `uv pip install`, `uv sync` or `uv lock` step fails and prints a top line followed by an indented chain of causes. There are two possible top lines. `Failed to fetch: `<url>`` means uv made one attempt and stopped, which happens when the retry budget is zero or when the failure was not classified as transient. `Request failed after 3 retries in 46.9s` means uv used its budget, and the fetch error has moved to the first `Caused by:` line underneath. Nothing about the failure itself differs between the two; the chain below them is the same. What differs is whether uv thought the failure was worth another attempt, which is the fact you want before you decide what to change.

```Captured locally on uv 0.11.7, 2026-09-21, index pointed at an unroutable address. Not runner output
error: Request failed after 3 retries in 46.9s
  Caused by: Failed to fetch: `https://192.0.2.1/simple/flask/`
  Caused by: error sending request for url (https://192.0.2.1/simple/flask/)
  Caused by: client error (Connect)
  Caused by: tcp connect error
  Caused by: deadline has elapsed
```

## Common causes

### The index host is unreachable from this network, not down

A private index on a network the runner cannot reach, or a public one behind a proxy that is not configured for this step, produces connection failures on every attempt. The chain ends in a connect error and the duration is long because each attempt waited out its own timeout. Nothing about the index is wrong and no retry budget is large enough.

### The index is answering 5xx under load

A self-hosted index or a caching proxy that falls over when a matrix starts returns 503 to some requests and succeeds on others. uv retries those, so the failures you see are the ones that failed four times, which means the load problem is sustained rather than momentary. The status in the chain is the giveaway, since it proves a server answered.

### Certificates are being rejected, and uv stops early

A TLS failure surfaces in the cause chain with a certificate message and may come with a suggestion to enable the system certificate store. In our experience this is the one most often misread as flakiness, because the fix people reach for is a bigger retry budget and the actual fix is a trust store or a corporate root certificate.

### Nothing is cached, so every job re-downloads everything

With `--no-cache`, or with a cache directory that is not preserved between runs, every job fetches every distribution again. That multiplies the number of requests by the width of the matrix and turns an index that is fine for a developer into one that is marginal for a pipeline. The request that eventually fails is a symptom of the volume rather than of any one download.

## How to fix it

### Read the top line before changing anything

1. Check whether it says `Failed to fetch:` or `Request failed after`. The first means uv did not retry, so raising the budget will not change the outcome.
2. Read the last `Caused by:` line. That is the actual failure, and everything above it is framing added on the way up.
3. Note the duration in the retry line. A long duration means attempts timed out; a short one means they were refused or rejected outright.

```.github/workflows/ci.yml
- run: uv sync --frozen -v 2>&1 | tee uv.log
- if: failure()
  run: grep -E "^error:|Caused by:|Transient request failure" uv.log
```

### Cache the uv cache directory rather than passing --no-cache

The uv cache is what turns a repeat install into a local operation. Caching it keyed on the lock file means most jobs make no index requests at all, which is a stronger fix than any retry budget because it removes the request. Use `uv cache prune --ci` before saving so the cache holds what a future run can use rather than everything this run touched.

```.github/workflows/ci.yml
- uses: actions/cache@v4
  with:
    path: ~/.cache/uv
    key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
    restore-keys: uv-${{ runner.os }}-

- run: uv sync --frozen
- run: uv cache prune --ci
```

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

Set `UV_HTTP_RETRIES` in the job environment so every uv call agrees. Remember what it costs: with backoff bounded between two and thirty seconds, each extra retry can add most of a minute to a failing step, so a budget of eight on an unreachable index turns a one minute failure into several.

```.github/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    env:
      UV_HTTP_RETRIES: "5"
    steps:
      - uses: actions/checkout@v5
      - uses: astral-sh/setup-uv@v6
      - run: uv sync --frozen
```

### Turn on verbosity so the retries are not invisible

uv logs transient failures at debug level, so at default verbosity four attempts look like one slow request. Running with `-v` in CI produces a line per retry naming the URL and the error, which turns the duration in the final message from a mystery into a count you can read.

```.github/workflows/ci.yml
- run: uv pip install -r requirements.txt -v
```

## How to prevent it

- Cache ~/.cache/uv keyed on the lock file, and prune with `uv cache prune --ci` before saving.
- Keep UV_HTTP_RETRIES in the job environment so a single value covers every uv call.
- Run uv with -v in CI so a retried failure shows its attempts instead of hiding them.
- Alert on the two top lines separately, because only one of them means uv thought the failure was transient.

## Why the top line moves

The swap is one `Display` implementation. In `crates/uv-client/src/error.rs`, uv's `Error` type holds a kind, a retry count and a duration, and its formatter writes `Request failed after {retries} {tries} in {duration}s` whenever the count is above zero, falling back to the kind's own message when it is zero. Its `source` implementation moves in step: with retries, the source becomes the kind, which is how `Failed to fetch:` ends up one level down.

`Failed to fetch: `<url>`` is the `WrappedReqwestError` variant of the kind enum, so it is the same error object in both cases. Reading the two lines as two different problems is the mistake this page exists to prevent, and it is an easy one to make because the words share almost nothing.

Setting the budget to zero makes the difference visible on demand. The two blocks below are the same command against the same unroutable index, run a few minutes apart, differing only in `UV_HTTP_RETRIES`. In the second the top line is the fetch error, the duration is gone, and the chain below is identical.

```Captured locally on uv 0.11.7, 2026-09-21. Not runner output
$ UV_HTTP_RETRIES=0 uv pip install --index-url https://192.0.2.1/simple --no-cache "flask==3.0.0"
error: Failed to fetch: `https://192.0.2.1/simple/flask/`
  Caused by: error sending request for url (https://192.0.2.1/simple/flask/)
  Caused by: client error (Connect)
  Caused by: tcp connect error
  Caused by: deadline has elapsed
```

| Top line | What uv did | What to conclude |
| --- | --- | --- |
| `Failed to fetch: `<url>`` | One attempt. No retry was made. | Either the budget is zero or uv judged the failure permanent. |
| `Request failed after 1 retry in ...s` | Two attempts, and the singular is deliberate. | Transient by uv's reckoning, and still failing. |
| `Request failed after 3 retries in ...s` | Four attempts, the default budget spent. | A sustained failure, not a blip. Look at the path, not the index. |
| A duration far longer than the work | Backoff between attempts, two to thirty seconds. | Each attempt timed out rather than being refused. |

> uv logs each transient failure at debug level only, so at default verbosity a run that retried three times looks like a single slow request. Adding `-v` turns each one into a line naming the URL and the error it is about to retry.

## What uv counts as transient

The classification lives in `crates/uv-client/src/retry.rs` and it walks the whole error source chain rather than looking at the top error, because a network failure in a streamed response can be buried several layers down in other crates. Along that chain it accepts any error `reqwest` itself classifies as transient, any retryable HTTP status, every `h2` error on the grounds that they all look retryable, and a specific list of IO error kinds: broken pipe, connection aborted, connection reset, invalid data, timed out and unexpected end of file.

The budget is `DEFAULT_RETRIES`, which is 3, overridable with `UV_HTTP_RETRIES`. The policy is exponential with bounded jitter between two and thirty seconds, so four attempts against an endpoint that times out can legitimately take the better part of a minute. The captured block above took 46.9 seconds for exactly that reason, and that duration is itself a signal: a refused connection fails immediately, so a long duration means each attempt waited.

A run that prints `Failed to fetch:` as the top line with a non-zero budget is therefore telling you something specific. uv looked at the chain, found nothing it recognised as transient, and stopped. That is a stronger statement than a timeout, and the fix is usually in the cause chain rather than in any retry setting.

```Captured locally on uv 0.11.7, 2026-09-21, against a local 503 responder. Not runner output
$ uv pip install --index-url http://127.0.0.1:8944/simple --no-cache "flask==3.0.0"
error: Request failed after 3 retries in 10.0s
  Caused by: Failed to fetch: `http://127.0.0.1:8944/simple/flask/`
  Caused by: HTTP status server error (503 Service Unavailable) for url (http://127.0.0.1:8944/simple/flask/)
```

## Why these blocks are captures and not a runner run

All three came from uv 0.11.7 on a laptop on 2026-09-21, against two servers under our control: a four-line Python responder that answers 503 to everything, and 192.0.2.1, the TEST-NET-1 address reserved by RFC 5737 that nothing on the internet routes. The invocations are shown in full above each block, so every value in them can be accounted for.

None of them is a Latchkey runner reproduction, and the failure does not need one. Making the real Python Package Index fail on demand is not something a runner can do honestly, and breaking a runner's egress produces a message about the runner. The behaviour this page is about, the top line swapping when the retry count is non-zero, is a property of uv rather than of any environment, and it reproduces in ten seconds anywhere. `content/heal-evidence.mjs` has no record for this slug, so nothing here claims Latchkey repairs it.

## FAQ

### What is the difference between "Failed to fetch" and "Request failed after N retries" in uv?

They are the same error object displayed two ways. uv's client error type holds a retry count, and its formatter prints the retry summary whenever that count is above zero, moving the fetch message down into the cause chain. A top line of `Failed to fetch:` therefore means no retry happened, either because the budget was zero or because uv did not classify the failure as transient.

### How many times does uv retry an HTTP request?

Three by default, for four attempts in total, from `DEFAULT_RETRIES` in the client crate. Override it with `UV_HTTP_RETRIES`. The backoff is exponential with bounded jitter and is clamped between two and thirty seconds, so a run against an endpoint that times out on every attempt can spend most of a minute before reporting.

### Why did uv not retry my failure?

Because nothing in the error source chain matched what it treats as transient. uv walks the whole chain looking for a reqwest error it considers transient, a retryable HTTP status, an h2 error, or one of six IO error kinds: broken pipe, connection aborted, connection reset, invalid data, timed out and unexpected end of file. Anything else, including many certificate problems, stops on the first attempt.

### Why is the duration in the error so much longer than the timeout?

Because it covers the whole ladder, not one request. The duration reported in the retry line is the total time across attempts including backoff and jitter, and the backoff alone is between two and thirty seconds per gap. That is why a number like 46.9 seconds for a four-attempt failure is normal rather than a sign of a hung request.

## References

- [astral-sh/uv: error.rs, the Display implementation that swaps the top line](https://github.com/astral-sh/uv/blob/0.11.7/crates/uv-client/src/error.rs)
- [astral-sh/uv: base_client.rs, DEFAULT_RETRIES and the backoff bounds](https://github.com/astral-sh/uv/blob/0.11.7/crates/uv-client/src/base_client.rs)
- [astral-sh/uv: retry.rs, which error kinds count as transient](https://github.com/astral-sh/uv/blob/0.12.17/crates/uv-client/src/retry.rs)
- [uv documentation: caching in continuous integration, including uv cache prune --ci](https://docs.astral.sh/uv/concepts/cache/#caching-in-continuous-integration)

---

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
