# go proxy.golang.org timeout ci and what the message proves

> A go proxy.golang.org timeout ci failure names one request. Tell a dial timeout from a 5xx, then fix the separator that made it fatal.

Source: https://latchkey.dev/learn/failures/go-module-proxy-timeout-in-ci  
Updated: 2026-09-20

A go proxy.golang.org timeout ci failure is the go command telling you that one HTTP request to a module proxy did not complete, and the wording of that line says whether the connection was ever made. That distinction is the whole page, because the go command treats the two outcomes differently when your GOPROXY list has more than one entry, and the separator you used decides whether it even tries the next one.

## What this error means

The job dies inside `go mod download`, `go build` or `go test`, on one module, with a single line that starts `go: ` and names a module and version. What follows that is either a `Get "..."` clause, which means the go command never got an HTTP response, or a `reading ...` clause, which means it did get one and refused it. Everything useful is in which of those two you have. The suffix on the URL says which file was being fetched, and that in turn says which command asked: `go mod download` and `go get` ask for `.mod` first, `go build` and `go test` ask for `.zip`, and a `.info` request comes from a version query such as `go list -m -u all`. The block below is the first kind, produced on purpose: a container running go1.27.1 with `GOPROXY` pointed at 192.0.2.1, the TEST-NET-1 address reserved by RFC 5737 for documentation, which nothing on the internet routes. This page carries no reproduction on a Latchkey runner, so the address in the log is ours rather than a public proxy's, and the shape of the line is the part to compare against yours.

```go1.27.1, `go mod download`, GOPROXY at an unroutable address
go: github.com/google/uuid@v1.6.0: Get "https://192.0.2.1/github.com/google/uuid/@v/v1.6.0.mod": dial tcp 192.0.2.1:443: i/o timeout
```

## Common causes

### The proxy or the path to it dropped, and your separator made that terminal

The common case, and the one that looks random. A momentary loss of egress, a congested link or a proxy under load produces a `dial tcp` timeout or a reset on one module out of hundreds. With the default comma-separated `GOPROXY` the go command stops there rather than trying `direct`, so a transient network event becomes a red build with no retry of any kind behind it.

### Every job downloads every module, because nothing is cached

A workflow without a module cache re-fetches the full dependency graph on every run. That is hundreds of requests to one host per job, multiplied by the matrix, which is why a failure rate that is invisible on a laptop is a daily event in CI. `actions/setup-go` caches Go modules and build outputs by default, and its README is explicit that the `cache` input is optional and caching is enabled by default, so this is usually a setting somebody turned off rather than one nobody turned on.

### A private module is being asked of a public proxy

When `GOPRIVATE` or `GONOPROXY` does not match the module path, the request for a private module goes to the public mirror, which cannot serve it. That does not usually time out, it answers, so the line says `reading` rather than `Get`. The reason it belongs on this page is that teams reach for a longer timeout when the actual problem is that the request should never have left.

### A corporate proxy is in the path and is not finishing the transfer

An `HTTPS_PROXY` in the environment, or a transparent middlebox, can accept the connection and then stall on a large module zip. In our experience this is the version that survives a retry, because the same box is in the path every time, and the tell is that small `.mod` and `.info` requests succeed while a `.zip` for a heavy dependency does not.

## How to fix it

### Choose the separator that matches what you meant

1. If you want the public mirror and a fallback to source control when the mirror is unhappy, use a pipe. A comma only falls back on 404 and 410.
2. If you want the mirror to be a gatekeeper, so that anything it refuses ends the build, keep the comma. That is what it is for.
3. Set it once, in the workflow environment, so every step in the job agrees rather than each step inheriting a different default.

```.github/workflows/ci.yml
jobs:
  test:
    runs-on: ubuntu-latest
    env:
      GOPROXY: "https://proxy.golang.org|direct"
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-go@v6
        with:
          go-version-file: go.mod
```

### Leave the module cache on, and key it to go.sum

The cheapest fix for a flaky download is not downloading. `actions/setup-go` caches by default and hashes `go.mod` for the key; point `cache-dependency-path` at `go.sum` when you want the key to move only when a resolved version moves, and when several modules live in one repository.

```.github/workflows/ci.yml
- uses: actions/setup-go@v6
  with:
    go-version-file: go.mod
    cache: true
    cache-dependency-path: |
      **/go.sum
```

### Retry the download step, not the whole job

A bounded retry around `go mod download` costs seconds and catches the transient case that the comma separator turned into a build failure. Keep it small. Three attempts covers a blip, and a longer ladder only delays the answer you want when the proxy is genuinely down.

```Terminal
n=0
until go mod download; do
  n=$((n + 1))
  [ "$n" -ge 3 ] && exit 1
  sleep $((n * 5))
done
```

### Keep private modules off the public mirror

Set `GOPRIVATE` to the prefixes you own. It seeds both `GONOPROXY` and the checksum database exclusion, so private paths are fetched from version control and never announced to a public service. This removes a class of failure rather than making it slower to fail.

```.github/workflows/ci.yml
- run: go env -w GOPRIVATE=github.com/your-org/*
- run: git config --global url."https://x-access-token:${{ secrets.GH_PAT }}@github.com/".insteadOf "https://github.com/"
```

## How to prevent it

- Pick the GOPROXY separator deliberately and put the choice in the workflow environment, not in a developer machine profile.
- Keep `actions/setup-go` caching on, and confirm the cache is being hit by reading the step log rather than assuming.
- Set `GOPRIVATE` for every prefix you own, on the first day the repository exists.
- Wrap `go mod download` in a short retry so the network gets three chances and your reviewers get none of the noise.

## Two shapes, and what each one rules out

The go command wraps transport failures and HTTP failures differently, and it does so consistently enough that you can read the cause off the line without reproducing anything. A `Get` clause carries a network error from the Go standard library, so the request never returned a status. A `reading` clause carries a status, so the proxy was reachable and answered.

Against a server that answered 503 instead of refusing the connection, the same command in the same container printed this instead. Note that the URL moves out of quotes, and a second, indented line appears carrying whatever the proxy sent as a body.

```go1.27.1, proxy answering 503
go: github.com/google/uuid@v1.6.0: reading http://mirror503:8899/github.com/google/uuid/@v/v1.6.0.mod: 503 Service Unavailable
	server response: maintenance
```

| What the line says | What reached the proxy | Where to look next |
| --- | --- | --- |
| `Get "..."` then a `dial tcp` error | Nothing. No TCP connection, so no status. | Egress, DNS, a proxy in the path, or an address that does not route. |
| `Get "..."` then a TLS error | A connection, but the handshake failed. | A middlebox terminating TLS, or a trust store the runner does not have. |
| `reading ...` then a status and reason | The full request. The proxy chose that answer. | The proxy itself, and the `server response:` line under the error. |
| `reading ...` then 404 or 410 | The full request. The proxy does not have the module. | The next entry in your GOPROXY list, which the go command will now try. |

> The `server response:` line carries whatever the proxy sent as a body, and the go command truncates it. In `cmd/go/internal/web/api.go` the cap is `maxErrorDetailLines`, set to 8, with a byte cap of 81 times that, and the text stops at the first blank line. A proxy that returns an HTML error page gives you the top of it and nothing more.

## A comma makes a timeout fatal, a pipe does not

This is the part most CI configurations get wrong, and it is documented rather than folklore. The Go modules reference is explicit: "If a proxy URL is followed by a comma, the go command falls back to the next URL after a 404 or 410 error; all other errors are considered terminal. If the proxy URL is followed by a pipe, the go command falls back to the next source after any error, including non-HTTP errors like timeouts."

The default value of `GOPROXY` is `https://proxy.golang.org,direct`, with a comma. So the fallback to `direct` that everyone assumes is there for outages is not there for outages. It is there for modules the mirror does not carry. A timeout, a reset, a 502 and a 503 all end the build on the first entry.

The source agrees with the documentation, which is worth knowing because the behavior is easy to misremember. In `TryProxies`, in `cmd/go/internal/modfetch/proxy.go`, the loop over the proxy list breaks as soon as it sees an error that is not equivalent to a not-found error on an entry that was not marked to fall back on error. That single condition is the whole rule.

It is also observable in a few seconds. In a container with one proxy answering 503 to everything, `go mod download` under `GOPROXY=http://mirror503:8899,direct` printed exactly the message above and exited 1, and the module was never requested from source control. Changing nothing but the comma to a pipe, `GOPROXY=http://mirror503:8899|direct`, the same command in the same container exited 0 and printed nothing at all.

```Terminal
GOPROXY="https://proxy.golang.org|direct" go mod download
```

## Why this page has no runner reproduction

A Latchkey runner job cannot cause this failure honestly. To make proxy.golang.org time out you would have to either wait for a real outage, which is not a schedule, or break the runner's own egress, which produces a message about the runner rather than about the module proxy. Pointing `GOPROXY` at an address that does not route is the same experiment with none of that ambiguity, and it runs anywhere, which is why the block at the top was produced in a container instead of billed to a runner.

What a runner would have added is the one thing this failure does not need: proof that a retry sometimes works. Every line on this page is either the go command's own output, quoted from a run whose inputs are stated, or a sentence from the Go modules reference. Nothing here claims Latchkey repairs this failure, because `content/heal-evidence.mjs` has no record for this slug and a repair claim without one is marketing.

## FAQ

### Does GOPROXY fall back to direct when proxy.golang.org times out?

No, not with the default comma. The Go modules reference says a comma makes the go command fall back "after a 404 or 410 error; all other errors are considered terminal", and a timeout is neither. Replace the comma with a pipe if you want a fallback on any error, and understand that you are also giving up the gatekeeping the comma provides.

### What is the difference between a Get error and a reading error from the go command?

A `Get "..."` clause means the go command never received an HTTP response, so the error under it is a dial, TLS or transport error. A `reading ...` clause means it received one and rejected the status, and the status and reason phrase are printed right there. The first is about the path to the proxy, the second is about the proxy.

### Should I raise a timeout to fix a go mod download i/o timeout?

There is no single timeout to raise. Proxy requests go through the standard library `http.DefaultClient`, and the 30 second figure people quote is the dial timeout on its default transport, not a module setting you can put in `go env`. Cache modules, retry the step, and fix egress. Those are the three levers that exist.

### Does a 404 from proxy.golang.org also end the build?

No, and that asymmetry is the whole design. A 404 or a 410 is the proxy saying it does not carry the module, so the go command moves to the next entry in your GOPROXY list even across a comma. Every other status, and every transport error, is terminal on a comma separated entry. That is why a mirror can be used as a gatekeeper: it answers 403 for anything off the approved list and the build stops there.

## References

- [Go Modules Reference: GOPROXY, and what each separator falls back on](https://go.dev/ref/mod#environment-variables)
- [Go Modules Reference: the GOPROXY protocol and its error fallback](https://go.dev/ref/mod#goproxy-protocol)
- [golang/go: cmd/go/internal/web/api.go, where the reading and server response message is built](https://github.com/golang/go/blob/go1.27.1/src/cmd/go/internal/web/api.go)
- [actions/setup-go: caching is enabled by default](https://github.com/actions/setup-go#caching-dependency-files-and-build-outputs)

---

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
