# Docker net/http request canceled on a registry call

> Docker net/http request canceled on a pull or push is a Go client timeout, and the exact wording says the request never got a connection at all.

Source: https://latchkey.dev/learn/docker/docker-net-http-request-canceled-timeout  
Updated: 2026-09-20

A docker net/http request canceled failure on a pull or push is the Go HTTP client in the daemon giving up on a deadline, and the middle of the sentence tells you which phase it died in. "While waiting for connection" means it never obtained one, so the registry had not yet been asked anything and the problem is between the runner and the host.

## What this error means

A pull or a push fails after a noticeable pause, usually around fifteen seconds, and the URL in the message ends in the registry API root rather than at a blob or a manifest. It is intermittent: the same job passes on a re-run with nothing changed. The message is longer than it looks because three pieces of the Go standard library contribute to it, and the whole sentence appears in none of them. No run is recorded here, and the line below is set out as those pieces format it.

```Assembled from the strings the Go client formats, not a recorded run
--- the ping client giving up before it had a connection
Error response from daemon: Get "https://registry.example.net/v2/": net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)
```

## Common causes

### A transient network fault between the runner and the registry

The common case, and the reason the job passes on a re-run with nothing changed. A brief loss of connectivity, a congested link or a registry edge that stops accepting connections for a few seconds is enough to outlast a fifteen second deadline. Nothing about the image, the tag or the credentials is involved.

### Name resolution that stalls rather than fails

A resolver that answers slowly is worse than one that answers wrongly, because a refusal returns immediately and a stall consumes the whole budget. The message is identical either way, since the connection was never obtained. Timing a lookup separately is what separates this from the rest.

### A proxy or firewall that drops the connection silently

A blocked route that sends a refusal fails fast and says so. One that discards packets leaves the client waiting until its deadline, which produces exactly this wording. In our experience this is the version that repeats on every run rather than intermittently, which is the tell that it is policy rather than weather.

### Proxy variables the daemon never sees

Setting proxy variables in a workflow step configures that step. The daemon is a separate process started before the job, so it uses its own environment, and a registry reachable only through a proxy will time out for the daemon while curl in the same job succeeds. That difference is a diagnosis in itself.

## How to fix it

### Retry with backoff, because this one is usually weather

Unlike most registry failures, this is worth retrying. Keep it bounded and print the attempt, so a route problem shows up as three identical failures rather than hiding inside a loop that eventually gives up.

```Terminal
for i in 1 2 3; do
  docker pull registry.example.net/acme/api:1.4.2 && exit 0
  echo "attempt $i timed out, waiting $((i * 10))s"
  sleep $((i * 10))
done
echo "::error::three attempts timed out, this is not transient"
exit 1
```

### Configure the proxy for the daemon, not just for the step

1. Put the proxy settings where the daemon reads them rather than in the job environment.
2. Include the registry host in the no-proxy list if it is reachable directly.
3. Restart the daemon and wait for it to answer before the first pull.

```.github/workflows/ci.yml
- name: Give the daemon its own proxy configuration
  run: |
    sudo mkdir -p /etc/systemd/system/docker.service.d
    printf '[Service]\nEnvironment="HTTPS_PROXY=http://proxy.example.net:3128"\nEnvironment="NO_PROXY=localhost,127.0.0.1"\n' \
      | sudo tee /etc/systemd/system/docker.service.d/proxy.conf
    sudo systemctl daemon-reload && sudo systemctl restart docker
    timeout 60 bash -c 'until docker info >/dev/null 2>&1; do sleep 2; done'
```

### Pull base images through a mirror you control

A registry mirror moves the dependency from a public host to one inside your own network, which removes both the distance and the shared load. It also means a public registry having a bad minute stops being a reason your pipeline fails.

```/etc/docker/daemon.json
{ "registry-mirrors": ["https://mirror.example.net"] }
```

### Fail fast when it is not transient

Three identical timeouts are not weather. Have the job say so rather than retrying into a red run twenty minutes later, and print the connection timing at that point so the log carries the evidence for whoever reads it next.

```Terminal
curl -sS -o /dev/null -m 20 -w "connect=%{time_connect}s\n" https://registry.example.net/v2/ \
  || echo "::error::the runner cannot reach the registry at all"
```

## How to prevent it

- Retry registry calls with backoff, and cap the retries so a route problem is visible.
- Configure proxies for the daemon rather than for the shell step.
- Mirror the base images your pipeline pulls on every run.
- Log connection timing next to any registry failure so the next one has a baseline.

## The sentence is three strings, and each one narrows the diagnosis

The Go HTTP transport keeps two similar errors apart on purpose. One reads "net/http: request canceled" and is the general cancellation. The other reads "net/http: request canceled while waiting for connection", and the transport substitutes it specifically when the request was still inside the step that obtains a connection, whether from the idle pool or by dialing. So the longer wording is a statement about phase.

The suffix comes from somewhere else. The client appends "(Client.Timeout exceeded while awaiting headers)" when its own deadline expired during the send, which is what tells you a deadline rather than a cancellation is responsible. Finally the daemon wraps the URL, and the Docker API client prefixes the daemon error.

Pasting the whole line into a code search will find nothing, because it is assembled at runtime from parts that live in different files. That is not evidence of anything being invented; it is what a composite message looks like. Search a distinctive fragment of one part instead.

| Fragment | Where it is formatted | What it narrows down |
| --- | --- | --- |
| `net/http: request canceled while waiting for connection` | The Go HTTP transport, when the deadline lands during connection acquisition | The registry was never asked anything |
| `(Client.Timeout exceeded while awaiting headers)` | The Go HTTP client, appended when its own deadline expired | A deadline, not a context cancellation or a reset |
| `Get "https://.../v2/"` | The Go URL error wrapper, carrying method and URL | The registry API root, so this was the initial ping |
| `Error response from daemon: ` | The Docker API client | The daemon, not the CLI, made the request |

> Fragments read in the Go standard library and the Docker API client on 2026-09-20. The transport keeps two spellings of cancellation and swaps in the longer one only in the connection-acquisition path.

## Where the fifteen seconds comes from

The daemon builds a dedicated HTTP client for the registry ping and for the authentication exchange, and both are constructed with a timeout of fifteen seconds. That value is written into the source rather than read from a flag or a configuration file, so there is nothing to tune. It was fifteen seconds in the sources for Docker 27 and 28 and on the current main branch, each read on 2026-09-20.

That explains the shape of the failure. A URL ending at the registry API root, a pause of roughly fifteen seconds, and a complaint about never getting a connection together mean the ping could not reach the host in the time allowed. It is not a slow image, a big layer or a busy registry backend, because none of those had been consulted yet.

## Tell a slow path from a blocked one

The useful question is whether the runner can open a connection to that host at all, and how long it takes when it can. Measure it directly rather than inferring from the Docker failure, because Docker will keep telling you the same thing.

A connect that succeeds quickly when tested, while pulls keep timing out, points at load or at a proxy that handles the two differently. A connect that hangs for the full timeout is a route or firewall answer and the fix is outside Docker.

```.github/workflows/ci.yml
- name: Time the connection the daemon is failing to make
  run: |
    getent hosts registry.example.net || echo "resolution failed"
    curl -sS -o /dev/null -m 20 \
      -w "connect=%{time_connect}s tls=%{time_appconnect}s total=%{time_total}s\n" \
      https://registry.example.net/v2/ || echo "curl could not complete either"
```

## Why no recorded run backs this page

The condition is a network failure lasting longer than fifteen seconds between a runner and a registry, and we cannot schedule one. What we could do is manufacture it, by pointing the daemon at a black-holed address until the deadline expires, and that would produce a real log of a failure nobody actually has. It would look like evidence and would be a recording of our own firewall rule.

This page does not need it. The two Go strings, the substitution rule that chooses between them, and the fifteen second client are all readable in source, and together they say more about a reader log than a screenshot of ours would. A retry is also the right first move here regardless of what a recorded run showed, which is unusual on this cluster and worth saying plainly.

## FAQ

### What does while waiting for connection actually mean?

That the deadline expired before the transport had a connection to use, either from its idle pool or from a fresh dial. Go keeps a separate error for that phase and substitutes it in place of the general cancellation message. It means the registry was never sent a request, so nothing about the image, the tag or the credential can be the cause.

### Can I raise the timeout?

Not for this client. The daemon constructs the registry ping and authentication client with a fifteen second timeout written into the source, and exposes no flag or configuration key for it. That value is the same in the Docker 27 and 28 sources and on the current main branch, each read on 2026-09-20. Fixing the path is the only available move.

### Is this the same as a TLS handshake timeout?

No, and the wording separates them. A handshake timeout means a connection was established and the TLS negotiation did not finish in time. This message means no connection was obtained at all, so the handshake never started. They have different causes and the handshake case has its own page here.

### Why does curl work in the same job when Docker times out?

Most often because they have different environments. Proxy variables set in a workflow step apply to that step, while the daemon is a separate long-running process with its own configuration. Curl goes through the proxy and the daemon does not, or the reverse, and the registry is only reachable one of those ways.

## References

- [Go: the transport error used when a request is still waiting for a connection](https://github.com/golang/go/blob/master/src/net/http/transport.go)
- [Go: the client that appends the Client.Timeout suffix](https://github.com/golang/go/blob/master/src/net/http/client.go)
- [moby: the registry ping and authentication client, with its timeout](https://github.com/moby/moby/blob/master/daemon/pkg/registry/auth.go)
- [Docker docs: configuring the daemon and registry mirrors](https://docs.docker.com/reference/cli/dockerd/)

---

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
