go proxy.golang.org timeout ci and what the message proves
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.
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 timeoutTwo 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.
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. |
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
- 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.
- 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.
- Set it once, in the workflow environment, so every step in the job agrees rather than each step inheriting a different default.
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.modLeave 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.
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
cache-dependency-path: |
**/go.sumRetry 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.
n=0
until go mod download; do
n=$((n + 1))
[ "$n" -ge 3 ] && exit 1
sleep $((n * 5))
doneKeep 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.
- 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/"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.
GOPROXY="https://proxy.golang.org|direct" go mod downloadWhy 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.
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-gocaching on, and confirm the cache is being hit by reading the step log rather than assuming. - Set
GOPRIVATEfor every prefix you own, on the first day the repository exists. - Wrap
go mod downloadin a short retry so the network gets three chances and your reviewers get none of the noise.
Frequently asked questions
Does GOPROXY fall back to direct when proxy.golang.org times out?
What is the difference between a Get error and a reading error from the go command?
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?
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?
Related guides
References
- Go Modules Reference: GOPROXY, and what each separator falls back on
- Go Modules Reference: the GOPROXY protocol and its error fallback
- golang/go: cmd/go/internal/web/api.go, where the reading and server response message is built
- actions/setup-go: caching is enabled by default
- Go documentation
- GitHub Actions documentation