# pip ReadTimeoutError and 429 from PyPI in CI

> Fix pip ReadTimeoutError and 429 responses from PyPI in GitHub Actions: raise the timeout the failing pip reads, and cache the wheels it downloads.

Source: https://latchkey.dev/learn/failures/pip-read-timeout-and-429-in-ci  
Updated: 2026-09-20

A pip ReadTimeoutError against PyPI in CI means the connection opened and then went quiet: the host accepted the request and sent nothing back inside the socket timeout. On GitHub Actions the fix is to raise the timeout the failing pip actually reads and to cache the wheels, because the default is 15 seconds and a cold job downloads everything again.

## What this error means

The install step runs for a while and then ends in a Python traceback, and the line that matters is near the bottom, in the shape "ReadTimeoutError: HTTPSConnectionPool(host='pypi.org', port=443): Read timed out." The host in it tells you which half of the job failed, and the recorded run below names the other one. pypi.org is the index, which pip queries to resolve a version; files.pythonhosted.org is where the wheel itself lives, and a stall there happens with the resolution already done. A second shape is the throttle: pip abandons the download and reports a 429 status for a files.pythonhosted.org URL, which is the same interruption with a different reason. A third is a run of "Retrying (Retry(total=N ...)) after connection broken by" warnings, which is pip narrating its own attempts before it decides. In the run below the download host is mapped to a TLS server on loopback that completes the handshake and then never answers, because a stalled download from the real host cannot be produced on demand; the index lookup, the certificate check and the traceback are pip's own. It also sets the timeout to 5 seconds rather than pip's default of 15, so the stall turns terminal in seconds instead of a minute, which is why the evidence below reads read timeout=5.0.

```Actions log, install step
pip._vendor.requests.exceptions.ConnectionError: HTTPSConnectionPool(host='files.pythonhosted.org', port=443): Max retries exceeded with url: /packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl.metadata (Caused by ReadTimeoutError("HTTPSConnectionPool(host='files.pythonhosted.org', port=443): Read timed out. (read timeout=5.0)"))
```

## Common causes

### The download host went quiet part way through

The ordinary case. A CDN edge accepts the connection, serves some of the wheel and then stops, and pip waits 15 seconds before it calls that a failure. Nothing about the package or the pin is involved, which is why the same requirements file passes on the next run.

### You raised the timeout and the failing pip never read it

The wasted fix on this failure, and an easy one to make. `pip install --timeout 60` sets the timeout for that one invocation, and a build has more than one: tox, Poetry, a Dockerfile layer and a build backend each start their own pip. An environment variable or a `pip.conf` reaches all of them, a flag on one line reaches one of them, and a flag beats the environment wherever both apply.

### The index throttled the run

A 429 from a PyPI host means the request rate from this address was too high. On hosted runners the address is shared, so a wide matrix, a monorepo installing the same requirements in every leg, or simply a busy hour is enough. It is transient and it is also a signal that the job is asking for more than it needs.

### The index is not PyPI

A private index, a corporate proxy or a regional mirror sits between pip and the packages, with its own capacity and its own timeouts. In our experience a job that started timing out the week a proxy appeared is not seeing a PyPI problem, and the host in the traceback says so.

## How to fix it

### Raise the timeout through the environment

1. Set `PIP_DEFAULT_TIMEOUT` and `PIP_RETRIES` in the job's `env:` block so every pip in the job inherits them.
2. Start at 60 seconds rather than 15; the failure is a host going quiet, so the fix is patience.
3. Leave the command lines alone, because a flag on one command overrides the environment for that command only.

```.github/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    env:
      PIP_DEFAULT_TIMEOUT: '60'
      PIP_RETRIES: '5'
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-python@v7
        with:
          python-version: '3.12'
          cache: pip
      - run: pip install -r requirements.txt
```

### Put the setting where the nested pip will find it

A Dockerfile layer, a tox environment and a Poetry install all start a pip that reads its own configuration. Write a `pip.conf` into the image, or set the same environment variables with `ENV`, so the setting survives the layer boundary.

```Dockerfile
# Dockerfile
ENV PIP_DEFAULT_TIMEOUT=60 PIP_RETRIES=5
RUN pip install --no-cache-dir -r requirements.txt
```

### Wait longer on a throttle, and ask for less

A rate limit needs a minute, not a second. Back off before retrying, and then reduce the request count: a restored pip cache, a wheelhouse committed to an artifact, or a private index you control all cut the number of times the job asks a public host for anything.

```.github/workflows/ci.yml
- name: Build a wheelhouse once
  run: pip wheel --wheel-dir wheelhouse -r requirements.txt
- name: Install from it
  run: pip install --no-index --find-links wheelhouse -r requirements.txt
```

### Read the host before you change anything

The host in the traceback is the fastest diagnosis on the page. A private index host means the problem is yours, a PyPI host means it is transient, and a mirror that is neither means somebody added a proxy. Print the effective index once and the guessing stops.

```Terminal
pip config list
python -m pip config debug | head -20
```

## How to prevent it

- Set `PIP_DEFAULT_TIMEOUT` and `PIP_RETRIES` in the workflow environment, not per command.
- Cache the pip store with `setup-python`, so most installs never reach the network.
- Pin requirements so the download set is stable and the cache key is meaningful.
- Keep one index configured per job, and print it when a run fails.

## pip has one timeout, and it is a socket timeout

`--timeout` is documented as "set the socket timeout", and it defaults to 15 seconds. It is not a budget for the whole download: it is how long pip will wait for the next piece of data to arrive. A large wheel on a slow link does not trip it, and a host that goes quiet for sixteen seconds does.

`--retries` defaults to 5 and is documented as the maximum attempts to establish a new HTTP connection, though the run below ends in "Max retries exceeded" with a read timeout as its cause, so the budget covers more than the name suggests. Raise both, and raise the timeout first.

```.github/workflows/ci.yml
env:
  PIP_DEFAULT_TIMEOUT: '60'
  PIP_RETRIES: '5'
```

## The throttle is a different failure with the same cure

A 429 from a package index is not a stall; it is an answer, and one that arrives quickly. It means this client asked for too much too fast, and on a hosted runner the client is an address shared with every other job on the same egress. Waiting is the only correct first move, and for longer than you would wait on a 5xx, because a rate limit window recharges on a clock.

What actually removes it is asking for less. A cached wheel is not a request, and a wheelhouse built once and restored on every run turns a hundred requests into zero.

## Cache the wheels, not the virtual environment

Caching `~/.cache/pip` keeps the wheels pip already downloaded, so a repeat install resolves from disk. `setup-python` manages it for you from the requirements file.

Caching the virtual environment instead ages badly: it captures a resolved tree rather than the inputs, so one changed pin leaves a cache that is wrong rather than stale. Keep the store, rebuild the environment.

```.github/workflows/ci.yml
- uses: actions/setup-python@v7
  with:
    python-version: "3.12"
    cache: pip
    cache-dependency-path: requirements.txt
```

## What the runner does about it

Latchkey detects the stall as `PIP_INDEX_TIMEOUT` at confidence 0.96, as high as anything in the network set, because the frame it matches is unambiguous: the vendored urllib3 timeout against a PyPI host, inside a traceback pip only prints as it exits. Its plan is not a bare retry. It sets `PIP_DEFAULT_TIMEOUT` to 100 and `PIP_RETRIES` to 5 and then retries the step, which is why the recorded run above shows those two values on the second attempt and a saved wheel under them.

Two neighbours cover the rest of the family. `PYPI_RATE_LIMIT` fires on a PyPI-host 429 at 0.92 and waits 60 seconds before the first retry rather than 2. `PIP_NETWORK_RETRY` fires at 0.90 on the urllib3 retry warnings pip prints while it is still trying. All three are live, and all three end in a retried step.

## FAQ

### What is a good pip timeout for CI?

Sixty seconds is a reasonable floor, against a default of 15. It is a socket timeout rather than a whole-download budget, so a higher value costs nothing on a healthy run and only matters when a host goes quiet. Latchkey's own repair sets it to 100.

### Why does pip print Retrying after connection broken?

That is urllib3 narrating its own attempts: one line per broken connection as the counter runs down. It is not the final verdict, and a run that prints two of them and then installs cleanly has not failed. Repetition across a whole job, or a total of zero followed by an error, is the version worth acting on.

### Does caching the pip cache stop PyPI timeouts?

It stops most of them, because a wheel already in `~/.cache/pip` is never requested again. It does not stop the index lookup, so a cold resolution still talks to the index. Pinning requirements and caching the store turn a hundred requests into a handful.

### Is there a rate limit on PyPI?

PyPI throttles clients that request too much too quickly, and a 429 from a PyPI host is that throttle. It is applied to the requesting address, so a hosted runner shares it with every other job behind the same egress. Backing off is the immediate answer and caching is the durable one.

## References

- [pip: general options, including --timeout and --retries](https://pip.pypa.io/en/stable/cli/pip/)
- [pip: configuration files and environment variables](https://pip.pypa.io/en/stable/topics/configuration/)
- [pypa/pip#7424: timeouts against PyPI in CI](https://github.com/pypa/pip/issues/7424)
- [GitHub Actions: dependency caching reference](https://docs.github.com/en/actions/reference/dependency-caching-reference)

---

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
