Home › Python › ConnectionResetError 104: Fix Abrupt Peer Disconnects
Intermediate 5 min · September 23, 2026

ConnectionResetError 104: Fix Abrupt Peer Disconnects

Retry idempotent reads once with backoff and fix the timeout gap.

N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.

Follow
✓ Production
production tested
September 24, 2026
last updated
1,942
articles · all by Naren
Before you start⏱ 11 min
  • ✓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
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • 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.
✦ Definition~90s read
What is Python ConnectionResetError 104 Fix?

ConnectionResetError is Python's name for the TCP RST condition: the peer aborted the connection instead of closing it cleanly, and your next read or write met the wreckage. TCP is a stateful conversation — sequence numbers, acknowledgments, retransmit timers — and both kernels hold matching state for every connection.

★
Imagine you're mid-sentence on a call and the other person doesn't say goodbye — the line just goes dead with a click.

A RST packet tells your kernel to destroy that state immediately and discard anything in flight. The socket call that next touches the dead connection gets ECONNRESET, errno 104, raised as ConnectionResetError.

The senders of RSTs form a short lineup. Peer processes that crash or get OOM-killed leave sockets the kernel must abort. Sockets closed abortively with SO_LINGER zeroed discard data and RST on purpose. Load balancers, NAT gateways, and firewalls reap idle or half-open connections, sometimes spoofing RSTs from either endpoint.

Overloaded servers with full accept queues RST newcomers instead of handshaking.

What makes the error maddening is its delay: the reset may arrive seconds before your code touches the socket, so the traceback frames an innocent line. The fix direction follows from that — investigate the peer and the path (was it alive, what are its timeouts, what middleboxes sit between), align keep-alive budgets below the shortest idle limit, and retry only what idempotency allows.

The rest of this article turns each of those into a concrete procedure.

Plain-English First

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.

rst_repro.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import socket
import struct

a, b = socket.socketpair()
b.send(b"unread-bytes")
# Abortive close: discard unsent data and emit RST, like a crashed peer
a.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
a.close()
try:
    data = b.recv(4096)
    print("recv returned:", data)
except ConnectionResetError as exc:
    print("ConnectionResetError:", exc)
try:
    b.sendall(b"more")
    print("second write buffered")
except (ConnectionResetError, BrokenPipeError) as exc:
    print(type(exc).__name__, "on write:", exc)
finally:
    b.close()
📊 Production Insight
A team chased a database driver for a week over reset tracebacks before tcpdump showed RSTs sourced from an overloaded NAT gateway. The driver was the victim; the network was the sender.
🎯 Key Takeaway
RST is an abort, not a goodbye — diagnose the peer and the path, not the raising line.

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.

keepalive_margin.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
IDLE_TIMEOUT = 60.0  # load balancer reaps idle conns after 60 s

def checkout(idle_for, timeout):
    if idle_for > timeout:
        return "stale: far side already closed — first use earns a reset"
    return "fresh: safe to reuse"

print(checkout(75.0, IDLE_TIMEOUT))
print(checkout(12.0, IDLE_TIMEOUT))
print("rule: hold pools below the shortest upstream timeout, e.g. 45 s")
📊 Production Insight
A checkout service held pools at 120 s behind a 60 s balancer and reset every off-peak payment. Dropping client keep-alive to 45 s ended the failures overnight.
🎯 Key Takeaway
Your keep-alive must sit below the shortest upstream idle timeout — measure the path, then set it.

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.

lb_timeout_math.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
lb_idle_timeout = 60.0  # from describe-load-balancer-attributes
server_idle_timeout = 75.0
MARGIN = 15.0

safe_keepalive = min(lb_idle_timeout, server_idle_timeout) - MARGIN
print("LB idle:", lb_idle_timeout, "s")
print("server idle:", server_idle_timeout, "s")
print("safe pool keep-alive:", safe_keepalive, "s")
assert safe_keepalive > 0, "margins must leave a positive keep-alive"
print("pool setting: keep-alive=45s, retry once on stale reads")
📊 Production Insight
A migration team documented every app timeout except the new balancer's 60 s default. Off-peak checkouts reset for two days before anyone thought to query the LB attributes.
🎯 Key Takeaway
Read the balancer's idle number, set pools beneath it, and write both down where the next engineer looks.

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.

upload_cut_short.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
CHUNK = 65536
payload = b"x" * (CHUNK * 4)

sent = 0
peer_gone_after = CHUNK * 2  # server rejected the upload early

def fake_send(chunk):
    global sent
    if sent >= peer_gone_after:
        raise ConnectionResetError(104, "Connection reset by peer")
    sent += len(chunk)

try:
    for i in range(0, len(payload), CHUNK):
        fake_send(payload[i:i + CHUNK])
    print("upload complete:", sent, "bytes")
except ConnectionResetError as exc:
    print("upload cut at", sent, "bytes:", exc)
    print("action: read the error response body before retrying")
⚠ Check Before You Stream
Large uploads fail on the server's terms. Validate size, auth, and checksum expectations before streaming a single body byte.
📊 Production Insight
A media uploader streamed gigabytes into connections the server had rejected in the headers. Expect: 100-continue cut wasted bandwidth 80% and ended the reset storms.
🎯 Key Takeaway
Big bodies need pre-approval: expect-continue or client-side validation before streaming.

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.

retry_backoff.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import random
import time

random.seed(7)
failures_left = 2

def flaky_call():
    global failures_left
    if failures_left > 0:
        failures_left -= 1
        raise ConnectionResetError(104, "Connection reset by peer")
    return "200 OK"

def call_with_backoff(fn, retries=4, base=0.01):
    for attempt in range(retries):
        try:
            return fn()
        except ConnectionResetError as exc:
            if attempt == retries - 1:
                raise
            delay = base * (2 ** attempt) + random.uniform(0, base)
            print(f"attempt {attempt + 1} reset ({exc}); retry in {delay:.3f}s")
            time.sleep(delay)

print(call_with_backoff(flaky_call))
📊 Production Insight
A payments client retried POSTs blindly after resets and double-charged 300 customers. Idempotency keys turned the same resets into harmless duplicate attempts the server deduped.
🎯 Key Takeaway
Retry idempotent reads once with jittered backoff; gate every write behind an idempotency key.

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.

hardened_client.pyPYTHON
1
2
3
4
5
6
7
8
9
import http.client

conn = http.client.HTTPConnection("example.com", 80, timeout=5)
print("timeout:", conn.timeout)
print("rules: timeout=5, pool maxsize=10, keep-alive below LB idle")
print("retries: 1x on idempotent GET after reset, keys on POST")
conn.close()
print("client hardened: no unbounded waits remain")
📊 Production Insight
A service with no client timeouts froze all 200 workers on one dead peer during a deploy. Five-second timeouts plus draining shutdowns turned future deploys into non-events.
🎯 Key Takeaway
Timeouts, sized pools, aligned keep-alive, scoped retries, draining shutdowns — review all five together.
● Production incidentPOST-MORTEMseverity: high

The 60-Second Load Balancer Timeout That Ate Off-Peak Checkouts

Symptom
Two days after the migration, checkout errors spiked to 3% during off-peak minutes and vanished at peak. Logs showed ConnectionResetError on payment calls with no backend errors at all. The pattern inverted every expectation — quieter traffic meant more failures — and the team chased the payment provider for a day before looking at the new balancer.
Assumption
The team assumed timeouts only needed to agree pairwise — app versus database, app versus API — and nobody inventoried the middle. The ALB's 60 s default was invisible infrastructure, and staging never caught it because synthetic traffic there never idled a connection for a full minute.
Root cause
The new ALB carried the default 60 s idle timeout while every service pool kept connections alive for 120 s. Any pooled connection idle between 60 and 120 s was already dead server-side; the next checkout request reused it and received RST, raising ConnectionResetError. The client had no stale-read retry, so each reset became a user-facing checkout failure at a rate that tracked exactly with traffic lulls.
Fix
Client keep-alive dropped to 45 s across all services, one retry on stale-connection reads added for idempotent endpoints, and every LB idle timeout documented in the service catalog with an alert when pool settings exceed it. A soak test with realistic idle gaps joined the release pipeline. Reset-driven checkout errors fell to zero within a day.
Key lesson
  • 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.
Production debug guideFive checks that separate a bad backend, an idle cull, a crashing peer, a firewall, and a deploy regression.5 entries
Symptom · 01
Resets cluster on specific endpoints or backends
→
Fix
Run 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.
Symptom · 02
Resets arrive in waves at fixed intervals
→
Fix
Run 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.
Symptom · 03
You cannot tell whether the peer or the network reset you
→
Fix
Run 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.
Symptom · 04
One backend resets while its twins stay healthy
→
Fix
Run 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.
Symptom · 05
You need to prove which change introduced the resets
→
Fix
Run 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.
ConnectionResetError 104 — Causes and Fixes at a Glance
Root CauseHow to ConfirmFixPrevention
Peer crashed or closed abortively (RST)tcpdump shows RST flags; server logs show restart or OOM near the timestampRetry idempotent requests once with backoff; fix the crashing peerHealth checks, graceful shutdown with connection draining enabled
Keep-alive idle longer than server/LB timeoutSawtooth resets at exactly the idle gap; first request on recycled conns failsShorten pool keep-alive below the upstream timeout; retry once on stale readsDocument every LB and server timeout in one place; assert pool settings against them
Load balancer idle timeout kills the connCloud metrics show target resets aligned with the LB idle setting (often 60 s)Raise the LB idle timeout or lower client keep-alive; enable keep-alive probesLoad-test with realistic idle gaps, not just sustained throughput
Large upload rejected mid-streamServer access log shows 413/400 while the client reports a reset mid-bodyRead the error response before retrying; use Expect: 100-continueValidate size and auth client-side before streaming big bodies
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
rst_repro.pya, b = socket.socketpair()What RST Means
keepalive_margin.pyIDLE_TIMEOUT = 60.0 # load balancer reaps idle conns after 60 sServer and Keep-Alive Mismatch
lb_timeout_math.pylb_idle_timeout = 60.0 # from describe-load-balancer-attributesLoad Balancer Idle Timeouts
upload_cut_short.pyCHUNK = 65536Large Uploads and Slow Readers
retry_backoff.pyrandom.seed(7)Retry With Backoff
hardened_client.pyconn = http.client.HTTPConnection("example.com", 80, timeout=5)Harden the Client

Key takeaways

1
A RST means the peer aborted the connection
look at the peer and the path, not the line that raised.
2
Keep-alive longer than the server or LB idle timeout manufactures resets on a schedule; stay below it.
3
Load-balancer idle defaults (often 60 s) beat most client pool settings
align them deliberately.
4
Large uploads that outrun server limits reset mid-stream; check approval before streaming.
5
Retry idempotent reads once with capped backoff and jitter; gate writes behind idempotency keys.
6
Harden every client with timeouts, sized pools, and reset-rate dashboards so noise never pages.

Common mistakes to avoid

5 patterns
×

Retrying POST uploads blindly on every reset

Symptom
Duplicate orders, double charges, or repeated side effects after a reset that actually arrived.
Fix
Retry only idempotent reads (GET, HEAD) automatically; re-validate uploads with an idempotency key before resending.
×

Setting client keep-alive longer than the server or LB idle timeout

Symptom
Every first request on a recycled connection resets, in a sawtooth pattern matching the idle gap.
Fix
Set pool keep-alive below the shortest upstream idle timeout (for example 45 s under a 60 s ALB) and validate before reuse.
×

No timeouts on sockets or HTTP clients

Symptom
Threads pile up on dead peers, pools exhaust, and one slow endpoint freezes the whole service.
Fix
Set connect, read, and total timeouts on every client (for example timeout=5) and size pools for the timeout budget.
×

Treating resets as fatal application errors

Symptom
Alert fatigue from routine peer behavior, with real error signals buried in reset noise.
Fix
Log resets at warning or debug with peer context, count them as a traffic metric, and page only on elevated rates.
×

Uploading large bodies without checking early server responses

Symptom
Megabytes stream into a connection the server already rejected, wasting bandwidth and time.
Fix
Use Expect: 100-continue or chunked checks for large uploads so a rejection stops the stream before it starts.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What does ConnectionResetError: [Errno 104] mean at the TCP level?
Q02JUNIOR
Why does the first request on a reused keep-alive connection reset?
Q03SENIOR
Why must POST retries use idempotency keys after a reset?
Q04SENIOR
A large upload resets midway while small ones pass. What is happening?
Q05SENIOR
How do you design a service where peer resets can never page anyone?
Q01 of 05JUNIOR

What does ConnectionResetError: [Errno 104] mean at the TCP level?

ANSWER
The peer sent a TCP RST, aborting the connection instead of the FIN handshake. Common causes: the peer process crashed or was OOM-killed, it closed abortively with SO_LINGER 0, or a load balancer or firewall culled the connection. Your next read or write then raises ConnectionResetError with errno 104.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
What makes a peer send RST instead of closing cleanly?
02
Which requests are safe to retry after a reset?
03
How do I stop keep-alive resets behind a load balancer?
04
My repro uses SO_LINGER 0 — is that realistic?
05
How is ConnectionResetError different from a timeout?
06
What does a hardened production HTTP client look like?
N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.

Follow
✓ Verified
production tested
September 24, 2026
last updated
1,942
articles · all by Naren
🔥

That's Errors. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
Python BrokenPipeError 32 Fix
14 / 18 · Errors
Next
Python Requests SSLError Fix
→