"socket hang up" (ECONNRESET): who closed the socket?
Error: socket hang up with code=ECONNRESET is Node telling you that the connection it was using died before a complete response arrived — and the string never says who killed it. Your process did not hang, nothing crashed, and the client did exactly what it promises: it wrote a request onto a socket, asked for a response, and the other end went away. Because the message names no peer, no address and no phase of the exchange, the same one line covers a dying container, a proxy that closed an idle upstream connection, a certificate-layer teardown and a load balancer rotating a backend. Everything quoted below was executed on this host — arm64 Linux 6.17, Node v26.5.1, Python 3.13.5, curl 8.14.1 — against the small origins built for this page, and every block printed byte-identical output on two consecutive runs.
TL;DR — three branches, three different fixes
- A reset before a single response byte. The peer's process went away: crash,
docker restart, health-check kill, OOM. Node reportsError: socket hang upwithcode=ECONNRESET, curl exits 52 withEmpty reply from server, Python raiseshttp.client.RemoteDisconnected. Fix the lifetime of the peer, not the socket. - The response had already started. Then it is not a hang-up at all but a truncated body: Node emits
abortedon the response, curl exits 18, Python raisesIncompleteRead. Different layer, different fix — buffering, a time limit, or a shutdown that does not drain. - A graceful close on an idle kept-alive connection — the branch that never errors. A proxy, a tunnel or a restarted origin drops the idle connection; your client writes the next request onto a socket whose peer is already gone; nothing is thrown. Measured here: the first request returns
status=200, the second is written into a dead socket and produces no error until the client's own timeout fires. - The fix has two mandatory halves: a response timeout on every request, because a dead pooled socket is otherwise a black hole, and one retry on a fresh connection for methods that are safe to repeat. Both are measured below, and the client itself is the fourth block.
What the string means — and what it is not
socket hang up is the message of the error Node's HTTP client raises when a socket ends before the response is complete: the response already ended, was aborted, or the socket closed while a request was in flight. It is a client-side verdict, so it is not evidence about the origin — it is evidence about the connection. That is exactly why it feels useless: two services on one network, one reporting a hang-up and the other reporting nothing, are usually describing a single event from opposite ends.
Two neighbours get mistaken for it. ECONNREFUSED is a different failure: nothing was listening, the kernel answered the SYN with a reset, and the client knows before it writes a single byte; the distinction between a refused connection and a dropped one, measured per address family, is in the ECONNREFUSED post. EPIPE is the write side of the same story — you wrote into a socket whose peer had closed its read half. On TLS the equivalent line arrives as error:0A000126 unexpected eof while reading, the twin on the certificate layer: it fires when the record layer ends without a close_notify, and it is the better string to debug with, because it keeps the TLS layer's verdict separate from the application's.
One dropped connection, three clients
Start with the deterministic case: an origin that dies mid-request. The block below runs one small HTTP origin with three routes — /health answers, /reset destroys the socket before writing a status line, /cut announces 100 bytes, sends four, then closes for good — and three clients make the same two requests. No Docker, no proxy, no second machine.
#!/usr/bin/env bash
# One dropped connection, three clients: who says what.
set -u
D=${TMPDIR:-/tmp}/sock-hang-lab1
mkdir -p "$D"
PORT=19961
cat > "$D/origin.mjs" <<'JS'
import http from "node:http";
const srv = http.createServer((req, res) => {
if (req.url === "/health") { res.end("ok"); return; }
if (req.url === "/reset") {
// the process is going away: no status line, no headers, no body
req.socket.destroy();
return;
}
if (req.url === "/cut") {
// the response has started and then the connection is closed for good
res.writeHead(200, { "content-length": "100" });
res.write("half");
res.socket.end();
return;
}
res.end("ok");
});
srv.listen(19961, "127.0.0.1", () => console.log("origin up"));
JS
cat > "$D/nclient.mjs" <<'JS'
import http from "node:http";
const req = http.request(process.argv[2], { agent: false }, (res) => {
let n = 0;
res.on("data", (c) => (n += c.length));
res.on("end", () => console.log(`node: status=${res.statusCode} bytes=${n}`));
res.on("aborted", () => console.log(`node: response aborted after ${n} bytes`));
});
req.setTimeout(3000, () => req.destroy(new Error("client timeout after 3000 ms")));
req.on("error", (e) => console.log(`node: ${e.name}: ${e.message} (code=${e.code})`));
req.end();
JS
cat > "$D/pyclient.py" <<'PY'
import http.client, sys
c = http.client.HTTPConnection("127.0.0.1", 19961, timeout=5)
try:
c.request("GET", sys.argv[1])
r = c.getresponse()
body = r.read()
print("python: status=%d bytes=%d" % (r.status, len(body)))
except Exception as e:
print("python: %s: %s" % (type(e).__name__, e))
PY
node "$D/origin.mjs" > "$D/origin.log" 2>&1 &
SRV=$!
for i in $(seq 1 50); do
if curl -s -o /dev/null --max-time 1 "http://127.0.0.1:$PORT/health"; then break; fi
sleep 0.1
done
for route in reset cut; do
echo "--- GET /$route ---"
curl -sS -o "$D/curl.body" "http://127.0.0.1:$PORT/$route" 2> "$D/curl.err"
rc=$?
echo "curl: exit=$rc"
sed -n '1,2p' "$D/curl.err"
node "$D/nclient.mjs" "http://127.0.0.1:$PORT/$route"
python3 "$D/pyclient.py" "/$route"
done
kill "$SRV" 2>/dev/null
wait "$SRV" 2>/dev/null
exit 0
The output, verbatim:
/reset, curl: exit52andcurl: (52) Empty reply from server. The same cause printed exit 56 andConnection reset by peerin the TLS lab, and the difference is the layer, not the failure; the curl-side recipe is here./reset, Node:Error: socket hang up (code=ECONNRESET). This is the string the whole page is about, and notice what it omits: bytes received, status, peer./reset, Python:http.client.RemoteDisconnected: Remote end closed connection without response. The most precise of the three — it says without response, which is exactly the branch you are in./cut, curl: exit18andcurl: (18) end of response with 96 bytes missing. That number is the remainingcontent-length, not the byte count you received./cut, Node: the response emitsabortedafter4bytes. A different code path from the hang-up: headers were parsed, the response object existed, only the body failed./cut, Python:http.client.IncompleteRead: IncompleteRead(4 bytes read, 96 more expected)— the same arithmetic as curl, from the opposite direction.
Three clients, three vocabularies, one event. If your logs hold only the Node half, the cheapest next step is not to search for the string but to repeat the request with curl from the same host: a 52 means nothing was ever returned, an 18 means the response began and stopped, and those two answers send you to different sections of your stack.
The branch that never errors: a close on an idle kept-alive socket
Now the case that costs the most production time, because it leaves no log line at all. A connection is kept alive, a peer with a shorter idle timeout closes it, and the client writes the next request onto that dead socket. The block below reproduces it with a hand-written pooler — the shape of every proxy, every shared agent and every home-made connection pool — against an origin that drops the idle connection 200 ms after answering.
#!/usr/bin/env bash
# The silent branch: the peer FINs an idle kept-alive connection, the request never answers.
set -u
D=${TMPDIR:-/tmp}/sock-hang-lab2
mkdir -p "$D"
cat > "$D/idle.mjs" <<'JS'
import http from "node:http";
import net from "node:net";
const PORT = 19962;
const IDLE_DROP_MS = 200; // the peer's idle timeout for a kept-alive connection
const srv = http.createServer((req, res) => {
res.end("ok", () => {
if (req.url === "/second") return;
setTimeout(() => req.socket.destroy(), IDLE_DROP_MS);
});
});
await new Promise((r) => srv.listen(PORT, "127.0.0.1", r));
const sock = net.connect(PORT, "127.0.0.1");
await new Promise((r) => sock.once("connect", r));
function request(path, timeoutMs) {
return new Promise((resolve) => {
let buf = "";
const done = (v) => {
clearTimeout(t);
sock.off("data", onData); sock.off("error", onError); sock.off("close", onClose);
resolve(v);
};
const onData = (c) => {
buf += c;
const i = buf.indexOf("\r\n\r\n");
if (i < 0) return;
const head = buf.slice(0, i);
const len = Number((head.match(/content-length: (\d+)/i) || [, "0"])[1]);
if (buf.length - (i + 4) >= len) done("status=" + head.split(" ")[1] + " body=" + buf.slice(i + 4));
};
const onError = (e) => done("error " + e.code + ": " + e.message);
const onClose = () => done("socket closed with no response");
const t = setTimeout(() => done("no response; aborted after " + timeoutMs + " ms (client timeout)"), timeoutMs);
sock.on("data", onData); sock.on("error", onError); sock.on("close", onClose);
sock.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1:${PORT}\r\nConnection: keep-alive\r\n\r\n`);
});
}
console.log("req1 -> " + (await request("/first", 1000)));
await new Promise((r) => setTimeout(r, 400)); // idle longer than the peer's idle timeout
console.log("req2 on the same socket -> " + (await request("/second", 1000)));
sock.destroy();
srv.close();
process.exit(0);
JS
node "$D/idle.mjs"
exit 0
The measured output, two lines: req1 -> status=200 body=ok, then req2 on the same socket -> no response; aborted after 1000 ms (client timeout). Read the second one carefully. There is no ECONNRESET, no socket hang up, no EPIPE. The write succeeded locally, the bytes went into a socket whose peer was already gone, and the only thing that ended the wait was the client's own timeout. For a service that pools connections and carries no response timeout, that is not an error at all: it is a request occupying a worker until something upstream gives up. The socket hang up string belongs to this family only when the peer's teardown was a reset — a killed process, a rotation, a firewall rule — rather than a graceful close.
One measured detail explains why the bug hides in development: on this host http.globalAgent.keepAlive is true, while a request made with agent: false opens a fresh connection and lets the origin close it right after the response. Only a shared agent has an idle socket to write onto, which is why a service that builds a per-request agent never shows the symptom until it is pooled in production.
Why self-hosted services hit this constantly
Every hop of the path owns an idle timeout, and they are almost never aligned:
- Node's own HTTP server keeps a connection alive for
server.keepAliveTimeout, which reads5000ms on this host, withheadersTimeoutat60000ms andrequestTimeoutat300000ms. A reverse proxy whose idle window is larger than five seconds will find the socket closed underneath it; one whose window is smaller never will. - nginx closes an idle kept-alive client connection after
keepalive_timeout, default75s, and stops reading a response from the proxied server afterproxy_read_timeout, default60s— both read from nginx's own module documentation on the day this page was written, not from memory. A 60-second read timeout in front of a 300-second Node request timeout is a configuration that reportssocket hang upto the client and nothing at all to the application. - Container restarts are resets. A failing health check, an OOM kill, a
docker restartor a deploy that stops the old process without draining connections all produce the reset branch: the in-flight request is answered with a hang-up, and the origin's log may end mid-line with no error recorded. - Load balancers and tunnels add a fourth and a fifth window on the same path. The rule is identical everywhere: the client's idle window must be strictly smaller than the smallest idle timeout of every peer it can reach, or the client will hand a request to a corpse.
Node's built-in agent offers no knob for this, and that is worth stating precisely because a lot of advice assumes otherwise. On this host, new http.Agent({ keepAlive: true }) reports keepAliveMsecs=1000 (the TCP keep-alive probe interval, not an idle lifetime), maxFreeSockets=256, and freeSocketTimeout is undefined. There is no idle-socket lifetime to shorten, which is why the next two sections do not tune the pool and instead make the failure visible and retryable.
Fix one: an idle window the pooler enforces itself
If you own the pooler you can close the gap without touching anything else: keep the timestamp of the last use, and open a fresh connection whenever the idle time has reached the peer's window. The block below is the previous reproduction with that policy added — a measured idle drop at 200 ms, a client that refuses to reuse a socket older than that, and a single retry on a fresh connection for a request that produced no response byte.
#!/usr/bin/env bash
# The fix, measured: never reuse a socket older than the peer's idle window.
set -u
D=${TMPDIR:-/tmp}/sock-hang-lab3
mkdir -p "$D"
cat > "$D/pool.mjs" <<'JS'
import http from "node:http";
import net from "node:net";
const PORT = 19963;
const IDLE_DROP_MS = 200; // the peer's idle timeout, measured once on the wire
const srv = http.createServer((req, res) => {
res.end("ok", () => {
if (req.url === "/second") return;
setTimeout(() => req.socket.destroy(), IDLE_DROP_MS);
});
});
await new Promise((r) => srv.listen(PORT, "127.0.0.1", r));
let sock = net.connect(PORT, "127.0.0.1"); // must be connected before the first write
let lastUsed = 0;
function request(path, socket, timeoutMs) {
return new Promise((resolve) => {
let buf = "";
const done = (v) => {
clearTimeout(t);
socket.off("data", onData); socket.off("error", onError); socket.off("close", onClose);
resolve(v);
};
const onData = (c) => {
buf += c;
const i = buf.indexOf("\r\n\r\n");
if (i < 0) return;
const head = buf.slice(0, i);
const len = Number((head.match(/content-length: (\d+)/i) || [, "0"])[1]);
if (buf.length - (i + 4) >= len) done("status=" + head.split(" ")[1] + " body=" + buf.slice(i + 4));
};
const onError = (e) => done("error " + e.code + ": " + e.message);
const onClose = () => done("socket closed with no response");
const t = setTimeout(() => done("no response; aborted after " + timeoutMs + " ms (client timeout)"), timeoutMs);
socket.on("data", onData); socket.on("error", onError); socket.on("close", onClose);
socket.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1:${PORT}\r\nConnection: keep-alive\r\n\r\n`);
});
}
// GET is idempotent, so a connection-level failure may be retried once on a fresh socket
async function get(path) {
if (lastUsed && Date.now() - lastUsed >= IDLE_DROP_MS) {
console.log("pool: idle window exceeded, opening a fresh connection");
sock.destroy();
sock = net.connect(PORT, "127.0.0.1");
}
lastUsed = Date.now();
const first = await request(path, sock, 1000);
if (!first.startsWith("error") && !first.startsWith("no response") && !first.startsWith("socket closed")) return first;
lastUsed = 0;
sock.destroy();
sock = net.connect(PORT, "127.0.0.1");
const retried = await request(path, sock, 1000);
lastUsed = Date.now(); // the fresh socket is in use now, so the idle window restarts
return "retry on a fresh connection -> " + retried;
}
let visibleFailures = 0;
const a = await get("/first");
if (!a.startsWith("status=200")) visibleFailures += 1;
console.log("req1 -> " + a);
await new Promise((r) => setTimeout(r, 400)); // idle longer than the peer's idle timeout
const b = await get("/second");
if (!b.startsWith("status=200")) visibleFailures += 1;
console.log("req2 -> " + b);
console.log("caller-visible failures: " + visibleFailures);
sock.destroy();
srv.close();
process.exit(0);
JS
node "$D/pool.mjs"
exit 0
Measured: req1 -> status=200 body=ok, then pool: idle window exceeded, opening a fresh connection, then req2 -> status=200 body=ok, then caller-visible failures: 0. The peer's window is not a guess; it is observable on the wire, and it is the same number you would put in a proxy's idle configuration. The ordering is the point — the pooler decides before writing, because after the write there is nothing left to decide.
Fix two: the client rules that keep the string out of your logs
Most services do not own the pooler, so the fix has to live in the client. Three rules, all measured below: never wait forever for a status line; treat a connection failure that produced no response byte as retryable; and only retry methods that are safe to repeat. The module uses node:http and nothing else — no extra dependency to audit.
// A reusable HTTP client for a service that sits behind a proxy or a load balancer.
// node:http only: one keep-alive agent, a response timeout, and one safe retry.
import http from "node:http";
const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
const agent = new http.Agent({ keepAlive: true, maxSockets: 8 });
function once(url, { method, timeoutMs, useAgent }) {
return new Promise((resolve, reject) => {
const req = http.request(url, { method, agent: useAgent ? agent : false }, (res) => {
let body = "";
res.setEncoding("utf8");
res.on("data", (c) => (body += c));
res.on("end", () => resolve({ status: res.statusCode, body, reusedSocket: req.reusedSocket }));
// 'aborted' is deprecated on IncomingMessage: 'close' plus res.complete is the supported signal,
// and the error carries the code the retry rule below keys on
res.on("close", () => {
if (!res.complete) reject(Object.assign(new Error("response truncated"), { code: "ECONNRESET" }));
});
res.on("error", reject);
});
req.setTimeout(timeoutMs, () => req.destroy(new Error(`request timeout after ${timeoutMs} ms`)));
req.on("error", reject);
req.end();
});
}
export async function call(url, { method = "GET", timeoutMs = 1000 } = {}) {
try {
// the pooled socket may already be dead; a dead socket does not always error, so the
// response timeout is what turns "no answer" into a failure we can act on
return { attempt: 1, ...(await once(url, { method, timeoutMs, useAgent: true })) };
} catch (err) {
const retryable = err.code === "ECONNRESET" || err.code === "EPIPE" || /^request timeout/.test(err.message);
if (!IDEMPOTENT.has(method) || !retryable) throw err;
console.log(`attempt 1 on the pooled socket: ${err.message} (no response byte)`);
// a fresh connection, never the socket that just failed
return { attempt: 2, ...(await once(url, { method, timeoutMs, useAgent: false })) };
}
}
The harness that follows exercises all three of its paths: an ordinary call, a call after the origin dropped the idle connection, and a call the origin resets once before answering. It writes the module above to disk unchanged, so the code that runs is the code you just read.
#!/usr/bin/env bash
# The client from the listing, exercised against an origin that drops idle connections
# and against an origin that resets the connection once before answering.
set -u
D=${TMPDIR:-/tmp}/sock-hang-lab4
mkdir -p "$D"
cat > "$D/client.mjs" <<'CLIENT_JS'
// A reusable HTTP client for a service that sits behind a proxy or a load balancer.
// node:http only: one keep-alive agent, a response timeout, and one safe retry.
import http from "node:http";
const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
const agent = new http.Agent({ keepAlive: true, maxSockets: 8 });
function once(url, { method, timeoutMs, useAgent }) {
return new Promise((resolve, reject) => {
const req = http.request(url, { method, agent: useAgent ? agent : false }, (res) => {
let body = "";
res.setEncoding("utf8");
res.on("data", (c) => (body += c));
res.on("end", () => resolve({ status: res.statusCode, body, reusedSocket: req.reusedSocket }));
// 'aborted' is deprecated on IncomingMessage: 'close' plus res.complete is the supported signal,
// and the error carries the code the retry rule below keys on
res.on("close", () => {
if (!res.complete) reject(Object.assign(new Error("response truncated"), { code: "ECONNRESET" }));
});
res.on("error", reject);
});
req.setTimeout(timeoutMs, () => req.destroy(new Error(`request timeout after ${timeoutMs} ms`)));
req.on("error", reject);
req.end();
});
}
export async function call(url, { method = "GET", timeoutMs = 1000 } = {}) {
try {
// the pooled socket may already be dead; a dead socket does not always error, so the
// response timeout is what turns "no answer" into a failure we can act on
return { attempt: 1, ...(await once(url, { method, timeoutMs, useAgent: true })) };
} catch (err) {
const retryable = err.code === "ECONNRESET" || err.code === "EPIPE" || /^request timeout/.test(err.message);
if (!IDEMPOTENT.has(method) || !retryable) throw err;
console.log(`attempt 1 on the pooled socket: ${err.message} (no response byte)`);
// a fresh connection, never the socket that just failed
return { attempt: 2, ...(await once(url, { method, timeoutMs, useAgent: false })) };
}
}
CLIENT_JS
cat > "$D/origin.mjs" <<'ORIGIN_JS'
import http from "node:http";
const IDLE_DROP_MS = 200;
let dieOnce = true;
const srv = http.createServer((req, res) => {
if (req.url === "/flaky" && dieOnce) {
dieOnce = false; // reset before any response byte, exactly once
req.socket.destroy();
return;
}
res.end("ok", () => {
if (req.url === "/first") setTimeout(() => req.socket.destroy(), IDLE_DROP_MS);
});
});
srv.listen(19964, "127.0.0.1", () => console.log("origin up"));
ORIGIN_JS
cat > "$D/selftest.mjs" <<'TEST_JS'
import { call } from "./client.mjs";
const show = (label, r) =>
console.log(`${label} -> status=${r.status} body=${r.body} attempt=${r.attempt} reusedSocket=${r.reusedSocket}`);
const first = await call("http://127.0.0.1:19964/first");
show("first ", first);
await new Promise((r) => setTimeout(r, 400)); // idle past the origin's 200 ms window
const second = await call("http://127.0.0.1:19964/second");
show("second", second);
const flaky = await call("http://127.0.0.1:19964/flaky");
show("flaky ", flaky);
const ok = [first, second, flaky].every((r) => r.status === 200 && r.body === "ok");
console.log(`caller-visible failures: ${ok ? 0 : 1}`);
process.exit(0);
TEST_JS
node "$D/origin.mjs" > "$D/origin.log" 2>&1 &
SRV=$!
for i in $(seq 1 50); do
if curl -s -o /dev/null --max-time 1 "http://127.0.0.1:19964/health"; then break; fi
sleep 0.1
done
node "$D/selftest.mjs"
kill "$SRV" 2>/dev/null
wait "$SRV" 2>/dev/null
exit 0
Measured output, five lines, in order: first -> status=200 body=ok attempt=1 reusedSocket=false; second -> status=200 body=ok attempt=1 reusedSocket=false; attempt 1 on the pooled socket: socket hang up (no response byte); flaky -> status=200 body=ok attempt=2 reusedSocket=false; caller-visible failures: 0.
The second line is the honest part of the story. After the origin dropped the idle connection, the pool's next request went out on a fresh socket by itself, because Node's agent removes a free socket once it notices the close. That is also why a reproduction of this bug in a lab often refuses to fail — the notice usually wins the race. It is not a guarantee, and the third and fourth lines show what happens when it loses: the peer's teardown arrives as a reset, the request fails with socket hang up, the wrapper sees a connection error that produced no response byte, and the retry — on a new connection, never the one that just failed — returns status=200. The caller sees attempt=2 and a good response, or a real error, and never a silent wait.
Two consequences worth writing into a code review. req.reusedSocket is false on both successful calls above, so a retry policy keyed only on that flag would have handed the third line's error to the caller. And a POST deliberately gets no retry in this module: a request that may already have been executed at the origin is not safe to repeat. If you need retries on writes, the retry belongs at the API layer behind an idempotency key.
Triage: which branch are you in?
Four questions, in this order, take about five minutes and cover the whole page:
- Did any byte of the response arrive? No status line at all, then the connection died early — this page. A truncated body, then the response began and stopped (exit 18,
IncompleteRead,aborted), and on HTTPS you should read the TLS-layer twin instead. - Repeat the request with curl from the same host. Exit 52 means nothing was returned; exit 7 means nothing was listening (ECONNREFUSED); exit 18 means the body was short. The curl exit code is the cheapest triage instrument on the host, because it does not depend on your client's pooling.
- Was the connection idle first? If the failure follows a quiet period — a nightly job, a low-traffic endpoint, a cron-triggered request — you are in the graceful-close branch, and nothing will be logged at the socket level. Set the client's response timeout below the smallest idle window on the path, then confirm that a retry on that route is safe.
- What changed at the last deploy? A reset-shaped failure that starts exactly at a deploy, a restart or a scaling event is the reset branch: the origin was replaced while requests were in flight. That is a drain problem — stop accepting, finish what is in flight, then exit — not a socket problem.
One more instrument for the branch where nothing is logged anywhere: an idle connection you can see is a connection you can reason about, and the host exposes it without extra software. A CLOSE_WAIT entry that lives for minutes means your process received the close and never closed its own end. A connection count that jumps and never comes back down points at a pool that is not being pruned. And a listener whose accept queue is full answers with resets that look exactly like the branch you have been chasing.
Sources and related work
- Every command in the five blocks above was executed on arm64 Linux 6.17 with Node v26.5.1, Python 3.13.5 and curl 8.14.1 on 2026-10-12, and each block was run twice with byte-identical stdout before this page was written.
- nginx directive defaults (
keepalive_timeout 75s,proxy_read_timeout 60s) were read from nginx.org's module documentation on 2026-10-12. - Node's timeout defaults and agent properties quoted above (
keepAliveTimeout=5000,headersTimeout=60000,requestTimeout=300000,keepAliveMsecs=1000,freeSocketTimeout=undefined,maxFreeSockets=256) were read back from the live objects on this host, not from documentation. - curl: (52) Empty reply from server — the curl-side diagnosis of an origin that closes before answering.
- ECONNREFUSED from another host: the 127.0.0.1 bind trap — refused versus dropped, measured per address family.
- error:0A000126 unexpected eof while reading — the same teardown seen from the TLS record layer.
- "The script will never generate a response" in Workers — the request that never answers, seen from the runtime's side rather than the client's.