ECONNRESET and connection reset by peer in CI
ECONNRESET in GitHub Actions means a connection that was already open was torn down by a reset packet, which is a different failure from a timeout: something answered you, and then something ended the conversation. Every tool in the job has its own sentence for that one event, so the useful question is not which tool printed it but where in the path the reset came from.


What this error means
A step that downloads, installs, clones or calls an API dies part way through, and the same job passes when you re-run it. The wording depends on who was holding the socket. curl reports exit 56 and a receive failure. npm prints its own code line. Node reports either a read error or a socket hang up, and attaches code: 'ECONNRESET' to both. Go programs, the Docker client and the kubelet among them, print the raw syscall with both endpoints in it. None of them says which hop sent the reset, and that is what decides whether a retry is the fix or a waste of two minutes. The run below produces the event on purpose. A TLS server on loopback closes with a zero linger timeout, so the close sends a reset rather than a graceful shutdown, and two clients hit it at different moments: curl part way through an eight megabyte body, and Node before any response byte was written. Everything above the wire is real, including both clients' TLS stacks and the messages they print. The server cuts only the first attempt, because a peer that resets forever models a dead host rather than the blip this page is about.
curl: (56) Recv failure: Connection reset by peer
Error: socket hang up
at TLSSocket.socketOnEnd (node:_http_client:528:25)
code: 'ECONNRESET'Reproduced on a Latchkey runner
attempt 1, curl 8.5.0 (x86_64-pc-linux-gnu) libcurl/8.5.0, node v20.20.2, server cuts the stream: yes
curl: (56) Recv failure: Connection reset by peer
Error: socket hang up
at TLSSocket.socketOnEnd (node:_http_client:528:25)
code: 'ECONNRESET'
server: reset the download after 1048576 of 8388608 bytes
server: reset GET /api/session HTTP/1.1 before writing a response byte
curl exited 56, node exited 1
[latchkey-bash-wrapper] BEGIN sidecar POST (boot_wait=30s max_time=320s url=http://localhost/diagnose socket=/run/latchkey-self-heal/sock)
[latchkey-bash-wrapper] END sidecar POST ok (attempts=1 http=200)
attempt 2, curl 8.5.0 (x86_64-pc-linux-gnu) libcurl/8.5.0, node v20.20.2, server cuts the stream: no
curl: got 8388608 bytes, download complete
node: read 4096 bytes, request completeThe same event, in seven different logs
Every line below is one TCP reset. If you are searching for the exact string in your log, start here, then read this page once rather than seven pages once each.
| What the log says | Who printed it | When the reset landed |
|---|---|---|
curl: (56) Recv failure: Connection reset by peer | curl, and anything shelling out to it | While reading the response body |
npm error code ECONNRESET, or npm ERR! code ECONNRESET before npm 10 | npm, mid-install | While fetching a package or a manifest |
error: RPC failed; curl 56 | git over HTTPS | While receiving the pack |
read tcp 10.0.0.5:51234->44.205.64.79:443: read: connection reset by peer | Go tools: the Docker client, the kubelet, k6 | On a socket read, with both endpoints and their ports |
Error: socket hang up with code: ECONNRESET | Node, via http or https | Before the response headers arrived |
Error: read ECONNRESET at TLSWrap.onStreamRead | Node, same code | After headers, during the body |
curl: (56) OpenSSL SSL_read: Connection reset by peer, errno 104 | curl built against OpenSSL | The same moment as the first row |
Common causes
Something in the middle tore the connection down
A proxy with an idle timeout, a NAT table dropping a long-lived entry, a load balancer recycling the backend, or an appliance that decided your transfer had gone on long enough. None of these are visible from the runner, and all produce a reset rather than a clean close. This is the ordinary case, and the one a retry fixes.
The peer was overloaded, restarting, or finished with you
A registry node under load, a service rolling out a new version, or a server whose keep-alive window closed a moment before your next request went down the same socket. That race is why this shows up as a flaky test far more often than as a flaky download.
Your job opened more connections than the path allows
Parallel installs, a matrix of jobs behind one egress address, and a load test all push the same lever. Hosted runners share their outbound address, so the number of connections a remote sees from what looks like one client is larger than anything your workflow declared. In our experience resets that cluster in a highly parallel job are the easiest to fix, because turning the concurrency down is one line.
The test shut down the server it was talking to
The flaky-test version, and the one where retries are the wrong answer. A test that tears down its server while a client is still connecting, or reuses a keep-alive socket across a teardown, produces a reset whenever the timing goes the wrong way. The error is real; the cause is in the test.
How to fix it
Retry with the client that owns the transfer
- Use the tool's own retry, not a retry around the step, so the backoff and the resume happen where the state is.
- For curl,
--retrywith--retry-all-errors, because a reset mid-body is not in curl's default retryable set. - For package managers, raise their fetch retry counts rather than re-running the whole install.
curl --retry 5 --retry-delay 2 --retry-all-errors --fail --show-error \
--location --output artifact.tgz "$URL"Open fewer sockets at once
Lower the number of connections your job makes against one host. Every package manager has a knob for it, and a matrix has max-parallel. This is the fix that turns a reproducible cluster of resets into nothing at all, for a little wall clock.
npm config set maxsockets 8
pip install --no-input -r requirements.txt # pip is serial by default
export COMPOSER_MAX_PARALLEL_HTTP=6Take the transfer off the critical path
A reset can only break a download that happens. Cache the dependency store, pull images through a mirror, and keep large artifacts inside your own network. A package restored from a cache is not a connection anything can reset.
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npmFor tests, stop the reset instead of retrying it
Keep the server alive for the whole test, handle errors on the server side rather than letting an upgrade handler throw, and do not reuse keep-alive sockets across a teardown. If a single transient reset during setup is genuinely expected, retry the connect a bounded number of times and only for that code.
for (let i = 0; i < 3; i++) {
try {
await connect(url);
break;
} catch (e) {
if (e.code !== 'ECONNRESET') throw e;
}
}Refused, timed out and reset are three different answers
Node documents ECONNRESET as "A connection was forcibly closed by a peer. This normally results from a loss of the connection on the remote socket due to a timeout or reboot." The important word is forcibly: a reset is an action taken by something, not the absence of an answer.
The words after curl's number are its TLS library talking, not a second diagnosis. The curl on the Ubuntu runner image calls a mid-body reset a receive failure; an OpenSSL build calls the identical event "OpenSSL SSL_read: Connection reset by peer, errno 104". Search on the 56.
ECONNREFUSED means nothing was listening, and no retry inside the same minute will change that. ETIMEDOUT means packets went out and nothing came back, which usually points at a firewall that drops rather than refuses, or at a host that is gone. ECONNRESET means the connection was established, work was underway, and something ended it: the peer, or a box in the middle. That is the only one of the three where retrying the identical request has a good chance of working, which is why sorting your failure into the right bucket before you touch anything is worth the minute it takes.
# curl tells you which one it had, in its exit code
# 7 refused, 28 timed out, 56 reset while receiving
curl --silent --show-error --fail https://registry.example.com/health
echo "curl exit $?"What happened on the recorded run
The first attempt lost both transfers to one reset and the second completed both, with no change to the command. The runner is a Latchkey managed runner, and what sits between those attempts is its own retry of the failed step. No pattern is named here: naming one asserts a specific production detector fired, and that belongs to a page whose evidence record carries it.
The shape is worth keeping whatever you run on. A reset that clears on the next attempt was a blip; one that repeats with the same host and request is policy: a proxy rule, a size limit, a TLS inspection box. The first is worth three retries, the second a packet capture. See how self-healing works for what a managed runner does with a step that failed this way.
How to prevent it
- Give every network step in CI a bounded retry that belongs to the tool doing the work.
- Cap install and download concurrency so one job is not fifty connections to one host.
- Cache or mirror the transfers that repeat on every run.
- Keep real-network tests separate from unit tests, so one reset fails one suite.
Frequently asked questions
What causes ECONNRESET in GitHub Actions?
Is socket hang up the same as ECONNRESET?
code: ECONNRESET. A read ECONNRESET is the same event landing later, after headers, while the body was still being read. Both point at the same wire event and take the same fixes.