ConnectionResetError 104: Fix Abrupt Peer Disconnects
Retry idempotent reads once with backoff and fix the timeout gap.
20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.
- ✓Basic TCP ideas: connections, clients, servers, and timeouts
- ✓Making HTTP requests from Python and reading status codes
- ✓Reading tracebacks to find the failing call and errno
- Fix it fast: retry idempotent reads (GET, HEAD) once after a short backoff; the reset often hits a stale pooled connection that is already gone.
- A RST means the peer aborted the connection — crashed process, culled idle conn, or a full queue — not a bug on the line that raised.
- Keep-alive mismatch is the top cause: pools holding conns past the server or LB idle timeout get RST on first reuse.
- Never blind-retry uploads or POSTs: re-validate with an idempotency key first, or one reset becomes two charges.
Imagine you're mid-sentence on a call and the other person doesn't say goodbye — the line just goes dead with a click. That click is a TCP reset. Maybe their phone died (a crashed server), maybe the network dropped stale calls (a load balancer timeout), or maybe you were talking too long without pausing (an idle connection). The fix isn't redialing blindly: you check whether it's safe to call back, wait a beat, and keep calls short enough that the network doesn't cut you off.
Your service was healthy for weeks. Then checkout latency spiked, the logs filled with ConnectionResetError: [Errno 104] Connection reset by peer, and every traceback pointed at a different line — a database driver here, an HTTP call there, an upload somewhere else. Nothing deployed. Nothing obviously broke. Just a wave of peers hanging up without saying goodbye.
That scatter is the signature of errno 104. Unlike a clean close, a TCP reset carries no explanation: the peer's process may have crashed, a load balancer may have culled an idle connection, or your own upload may have outrun the server's patience. Your code discovers it only when it next touches the socket, so the traceback marks the victim, not the cause.
This article closes that gap. You'll learn what a RST actually is, why keep-alive mismatches manufacture resets on a timer, how load-balancer idle timeouts became the most common source, what large uploads do to impatient servers, exactly which failures deserve a retry with backoff, and the client-hardening checklist that turns the rest into background noise.
What RST Means: the Peer Hung Up Without Goodbye
A TCP connection normally ends with manners: one side sends FIN, the other acknowledges, both agree the conversation is over. A reset skips the etiquette. The peer sends a RST packet that says this connection no longer exists — now — and both sides' kernels tear down state immediately. Any bytes in flight are discarded, and your next read or write on that socket raises ConnectionResetError with errno 104.
RSTs come from a short list of senders. A process that crashes or gets OOM-killed leaves the kernel holding sockets it never closed cleanly; the kernel RSTs them. A socket closed abortively with SO_LINGER set to zero discards unsent data and emits RST by design. Firewalls, NAT gateways, and load balancers send RSTs (or spoof them) when they reap idle or half-open connections. An overloaded server whose accept queue overflows may RST new arrivals rather than handshake.
The critical consequence is timing: you learn about the reset only when you touch the socket. The RST may have arrived seconds ago, but your code meets it at the next recv or sendall — which is why the traceback frames a random line instead of the true cause. Debugging therefore starts away from the traceback: which peer, what was its state, and what sits between you. The snippet below reproduces an abortive close locally so you can see both the read and write symptoms deterministically.
Server and Keep-Alive Mismatch: Idle Connections Get Killed
HTTP keep-alive exists to reuse TCP connections across requests, and pooled clients hold idle connections ready for the next call. Every holder sets a limit: your pool keeps a conn for 120 s, the server closes idle conns after 75 s, the load balancer reaps after 60 s. The moment your pool's limit exceeds anyone else's, it starts handing out corpses — connections the far side already closed — and the first request on each one eats a reset.
The failure has a telltale rhythm. Resets arrive in a sawtooth that tracks idle gaps: quiet minutes breed stale connections, the next request after each lull fails, then traffic flows until the next pause. Peak hours look clean because connections never sit idle long enough to die. Teams routinely misread this as provider flakiness that strikes when load is low, which sends them interrogating vendors instead of their own pool settings.
The fix has two halves. First, set your keep-alive below the shortest upstream idle timeout — 45 s against a 60 s balancer leaves a comfortable margin — and validate-before-reuse where the client supports it. Second, add exactly one retry for idempotent reads that fail with a reset, since even aligned settings lose an occasional race. The snippet models the checkout decision so the margin rule becomes concrete rather than folklore.
Load Balancer Idle Timeouts: the 60-Second Killer
If keep-alive mismatches are the mechanism, load-balancer idle timeouts are the usual loaded gun. Managed balancers ship with short defaults — AWS ALB and NLB default to 60 s in many configurations, cloud NATs often lower — and those defaults are invisible until they fire. Your dashboards show healthy targets, your pools look warm, and every minute-old connection is quietly dead.
The failure concentrates exactly where you'd least expect: low-traffic services and off-peak minutes. A checkout path doing three requests a minute holds each pooled connection idle for twenty seconds on average — safe — until a two-minute gap appears and the next request reuses a corpse. High-throughput paths never notice because their connections never rest. That inverse correlation with traffic is the fingerprint: when quieter means broker, suspect the balancer's idle timer before anything else.
Remediation is arithmetic, not heroics. Read the balancer's idle timeout from its attributes, set client keep-alive clearly beneath it, and enable TCP keep-alive probes so truly dead peers surface before reuse. Where the platform allows, raise the balancer timeout to match your slowest legitimate idle gap instead — but document the chosen numbers in the service catalog, because the next engineer will otherwise re-tune one side and revive the bug. The snippet computes the safe ceiling from real settings.
Large Uploads and Slow Readers: Writes That Outrun the Peer
Small requests sail through while large uploads reset midway, and the asymmetry has a physical cause. Servers bound request sizes, enforce auth, and validate headers before reading bodies. When your 200 MB upload violates any of those, the server responds with an error and closes the connection — while your client is still streaming chunk forty of four hundred. Your next chunk lands on a closed socket and raises ConnectionResetError, which reads as a network failure but is really a rejected upload.
Slow readers create the mirror image. A client that trickles bytes at dial-up speed can outlast the server's per-request deadline; the server gives up, closes, and the client's remaining chunks die the same death. Either direction, the signature is identical: resets correlated with body size or transfer duration, small payloads unaffected, server access logs showing 413 or 400 responses your client never read.
The efficient fix is to ask permission first. HTTP's Expect: 100-continue lets the client send headers, wait for a go-ahead, and stream the body only after approval — rejections arrive before any bytes move. Where that header isn't supported, validate size and credentials client-side and read the error response body before any retry. The snippet simulates an early rejection so the stop-early logic is testable without a real server.
Retry With Backoff: Which Resets Deserve a Second Try
Some resets deserve a second chance and some don't, and the line is idempotency. A GET that reset on a stale pooled connection can simply run again — the server never saw it, or saw it fully, and repeating it changes nothing. The same holds for HEAD, idempotent PUTs, and DELETEs. These are the requests where one retry with backoff converts a visible error into an invisible hiccup.
Writes are the opposite. A POST that resets may have executed fully before the connection died; retrying blindly double-charges, double-orders, or double-enqueues. The safe shape is an idempotency key: the client generates one key per logical operation, sends it with every attempt, and the server deduplicates. Stripe, payment gateways, and queue producers all work this way because the network makes exactly-once delivery impossible and keys fake it convincingly.
Backoff discipline matters as much as the retry decision. Retry once or twice, cap the delay, add jitter so a fleet of reset clients doesn't thundering-herd the recovering peer, and only retry the reset errors — timeouts and 5xx responses follow different policies. The snippet below shows the full shape: attempt, catch, exponential delay with jitter, and a final raise that preserves the original error when retries run out.
Harden the Client: Timeouts, Pools, and Graceful Shutdown
Retries treat the symptom; hardening treats the disease. A production HTTP client needs five settings reviewed together: timeouts that bound every wait, pools sized to the timeout budget, keep-alive aligned beneath upstream idle limits, retry rules scoped to idempotent operations, and graceful shutdown that drains connections instead of RSTing them mid-flight.
Timeouts come first because unbounded waits turn one dead peer into a pool-wide freeze. Set connect, read, and total timeouts on every client — five seconds each is a sane starting point for internal services — and size pools so the worst case still fits your thread budget. A pool of fifty connections with thirty-second timeouts can pin fifteen hundred thread-seconds; that arithmetic should be explicit in code review, not discovered during an outage.
Then close the loop operationally. Log resets with peer addresses so patterns (one bad host, one availability zone, one deploy) jump out. Dashboard reset rates beside latency so product sees the trade-off. Drain connections on shutdown with a grace period so deploys stop manufacturing RSTs. The snippet captures the configuration shape with the standard library — real clients like httpx or urllib3 take the same numbers under different names, and the values matter far more than the library.
The 60-Second Load Balancer Timeout That Ate Off-Peak Checkouts
- Inventory every timeout on the path, including the ones the cloud sets for you. The shortest idle limit on the route owns your keep-alive budget.
- Staging must reproduce idle patterns, not just throughput. Steady synthetic load will never catch a bug that needs sixty quiet seconds.
- One retry on idempotent reads is a shock absorber, not a fix. Pair it with aligned timeouts or the retry just hides the schedule.
curl -v --max-time 10 https://your-endpoint/health and read the verbose close: Connection #0 to host left intact means clean reuse, while Recv failure: Connection reset by peer reproduces your bug. Add --keepalive-time 50 to test whether shorter keep-alive dodges it.ss -tnp state established '( dport :443 )' | head -20 (or netstat -an | grep ESTABLISHED) during the incident. Connections stuck in odd states or vanishing between runs point at middlebox culls — compare against aws elbv2 describe-load-balancer-attributes --load-balancer-arn $ARN to read the idle timeout directly.sudo tcpdump -i any 'tcp[tcpflags] & tcp-rst != 0' -c 20 -nn for one minute. RSTs sourced from your LB's IP implicate idle culling; RSTs from app hosts implicate crashes or abortive closes. The source address is the whole diagnosis.python -c "import socket; s=socket.create_connection(('backend', 8000), timeout=5); s.settimeout(5); print(s.getpeername())" against the suspect backend. A hang means firewall or timeout territory; an instant reset means the peer is actively refusing — different fixes.grep -c 'ConnectionResetError' /var/log/app/*.log | sort -t: -k2 -n and plot counts against deploy markers. A step change at a deploy implicates new pool or timeout settings; a gradual ramp implicates growing idle gaps as traffic patterns shift.| File | Command / Code | Purpose |
|---|---|---|
| rst_repro.py | a, b = socket.socketpair() | What RST Means |
| keepalive_margin.py | IDLE_TIMEOUT = 60.0 # load balancer reaps idle conns after 60 s | Server and Keep-Alive Mismatch |
| lb_timeout_math.py | lb_idle_timeout = 60.0 # from describe-load-balancer-attributes | Load Balancer Idle Timeouts |
| upload_cut_short.py | CHUNK = 65536 | Large Uploads and Slow Readers |
| retry_backoff.py | random.seed(7) | Retry With Backoff |
| hardened_client.py | conn = http.client.HTTPConnection("example.com", 80, timeout=5) | Harden the Client |
Key takeaways
Common mistakes to avoid
5 patternsRetrying POST uploads blindly on every reset
Setting client keep-alive longer than the server or LB idle timeout
No timeouts on sockets or HTTP clients
Treating resets as fatal application errors
Uploading large bodies without checking early server responses
Interview Questions on This Topic
What does ConnectionResetError: [Errno 104] mean at the TCP level?
Frequently Asked Questions
20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.
That's Errors. Mark it forged?
5 min read · try the examples if you haven't