Skip to content
Latchkey LogoLatchkey home

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.

Two go module proxy error shapes, and the comma and pipe fallback rules between them
The go command prints one shape when no response arrived and another when a response arrived and was refused. The separator in GOPROXY decides which of them ends the build.

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

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 saysWhat reached the proxyWhere to look next
Get "..." then a dial tcp errorNothing. No TCP connection, so no status.Egress, DNS, a proxy in the path, or an address that does not route.
Get "..." then a TLS errorA connection, but the handshake failed.A middlebox terminating TLS, or a trust store the runner does not have.
reading ... then a status and reasonThe full request. The proxy chose that answer.The proxy itself, and the server response: line under the error.
reading ... then 404 or 410The 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

  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/"

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.

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.

Frequently asked questions

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.

Related guides

References

A proxy timeout is only fatal because the job is cold. Latchkey keeps the module cache in the runner's region. Start free → 30-day trial · No credit card