IncompleteRead and "Connection broken: IncompleteRead" in Python (tested)

http.client.IncompleteRead and requests' ChunkedEncodingError: ('Connection broken: IncompleteRead(11 bytes read, 9 more expected)') both mean the connection closed before the body the server promised was complete. We built a local server that cuts bodies short three ways and measured what urllib and requests report. Two surprises: requests raises ChunkedEncodingError even for plain Content-Length responses, and mounting Retry(read=3) does not retry it at all -- the server was hit once.

1. Three ways a server can cut a body short

A tiny local server sends a valid status line and headers, then closes the connection before the body is complete. We read each response with urllib.request.urlopen() (which uses http.client) and with requests:

import socket, threading, time, urllib.request, requests

PORT = 8963
RESPONSES = {
    # Content-Length says 20, server sends 11 bytes and closes
    "/short": b"HTTP/1.1 200 OK\r\nContent-Length: 20\r\n\r\nhello world",
    # chunked: announces a 0x14 (20) byte chunk, sends 11 and closes
    "/chunk-cut": b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n14\r\nhello world",
    # chunked: one complete chunk, but no terminating 0-size chunk
    "/no-final-chunk": b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nb\r\nhello world\r\n",
}
def serve():
    srv = socket.socket()
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind(("127.0.0.1", PORT))
    srv.listen()
    while True:
        conn, _ = srv.accept()
        path = conn.recv(65536).split()[1].decode()
        conn.sendall(RESPONSES[path])
        conn.close()
threading.Thread(target=serve, daemon=True).start()
time.sleep(0.2)

for path in RESPONSES:
    url = f"http://127.0.0.1:{PORT}{path}"
    try:
        urllib.request.urlopen(url).read()
    except Exception as e:
        print(f"urlopen  {path:15} {type(e).__name__}: {e!r}  e.partial={e.partial!r}")
    try:
        requests.get(url)
    except Exception as e:
        print(f"requests {path:15} {type(e).__name__}: {e}")
urlopen  /short          IncompleteRead: IncompleteRead(11 bytes read, 9 more expected)  e.partial=b'hello world'
requests /short          ChunkedEncodingError: ('Connection broken: IncompleteRead(11 bytes read, 9 more expected)', IncompleteRead(11 bytes read, 9 more expected))
urlopen  /chunk-cut      IncompleteRead: IncompleteRead(0 bytes read)  e.partial=b''
requests /chunk-cut      ChunkedEncodingError: ('Connection broken: IncompleteRead(11 bytes read, 9 more expected)', IncompleteRead(11 bytes read, 9 more expected))
urlopen  /no-final-chunk IncompleteRead: IncompleteRead(11 bytes read)  e.partial=b'hello world'
requests /no-final-chunk ChunkedEncodingError: Response ended prematurely
Server didurlopen().read()requests.get()
Content-Length: 20, sent 11 bytesIncompleteRead(11 bytes read, 9 more expected), e.partial has the 11 bytesChunkedEncodingError -- although the response was not chunked
chunked, cut inside a 20-byte chunkIncompleteRead(0 bytes read), e.partial == b'' -- the 11 bytes of the cut chunk are droppedChunkedEncodingError, says 11 bytes read
chunked, no terminating 0 chunkIncompleteRead(11 bytes read)ChunkedEncodingError: Response ended prematurely

Measured: all six are the same event -- the connection closed before the body the server promised was complete. requests wraps every one of them in ChunkedEncodingError, even for a plain Content-Length response, so the exception name is not evidence that chunked encoding is involved. "Connection broken: IncompleteRead(X bytes read, Y more expected)" means the server (or a proxy, load balancer, or timeout in between) closed the connection after X bytes.

2. Retry(read=3) does not retry it

import socket, threading, time, requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

PORT = 8963
hits = 0
def serve():
    global hits
    srv = socket.socket()
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind(("127.0.0.1", PORT))
    srv.listen()
    while True:
        conn, _ = srv.accept()
        conn.recv(65536)
        hits += 1
        conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 20\r\n\r\nhello world")
        conn.close()
threading.Thread(target=serve, daemon=True).start()
time.sleep(0.2)

s = requests.Session()
s.mount("http://", HTTPAdapter(max_retries=Retry(total=3, read=3, connect=3)))
try:
    s.get(f"http://127.0.0.1:{PORT}/file")
except Exception as e:
    print(type(e).__name__, "| server hits:", hits)
ChunkedEncodingError | server hits: 1

Measured: with Retry(total=3, read=3, connect=3) mounted, the server was hit exactly once. urllib3's Retry covers failures while getting the response (connecting, sending, reading the status line and headers). requests reads the body afterwards, outside that retry loop, so a body cut short is never retried. You need your own retry around the whole call.

3. The fix: retry the whole request on a broken body

Here the server cuts the first response short and sends the complete body on the next attempt:

import socket, threading, time, requests

PORT = 8963
hits = 0
def serve():
    global hits
    srv = socket.socket()
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind(("127.0.0.1", PORT))
    srv.listen()
    while True:
        conn, _ = srv.accept()
        conn.recv(65536)
        hits += 1
        if hits == 1:   # first attempt: body cut short
            conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 20\r\n\r\nhello world")
        else:           # later attempts: complete body
            conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 20\r\n\r\nhello world, 20 byte")
        conn.close()
threading.Thread(target=serve, daemon=True).start()
time.sleep(0.2)

def get_with_body_retry(url, attempts=3, **kw):
    for i in range(attempts):
        try:
            return requests.get(url, **kw)   # body is read inside get() unless stream=True
        except (requests.exceptions.ChunkedEncodingError,
                requests.exceptions.ConnectionError):
            if i == attempts - 1:
                raise
            time.sleep(0.5 * 2 ** i)

r = get_with_body_retry(f"http://127.0.0.1:{PORT}/file")
print(r.status_code, r.content, "server hits:", hits)
200 b'hello world, 20 byte' server hits: 2

Measured: the first attempt raised ChunkedEncodingError, the helper retried, and the second attempt returned the full 20 bytes. Only retry requests that are safe to repeat (GET, HEAD, idempotent PUT). For large downloads, retrying with a Range: bytes=N- header (where N is how much you already saved) avoids starting over, if the server supports ranges. With urllib, catch http.client.IncompleteRead the same way; e.partial holds what arrived, but as measured above it can be empty for a cut chunk even when bytes were received, so don't treat it as everything the server sent.

If it happens on every attempt, the cause is on the server side or in between: an application crashing mid-response, a proxy or load balancer idle timeout (common at 30-60 seconds on long downloads), or a wrong Content-Length header. Check with curl -sv URL -o /tmp/out: curl reports the same problem as curl: (18) end of response with N bytes missing.

Frequently asked questions

Why does requests say ChunkedEncodingError when my response isn't chunked?

requests wraps urllib3's ProtocolError for any body that ends early in ChunkedEncodingError. We measured it for a plain Content-Length: 20 response that sent 11 bytes. The name is historical; read the message inside it instead.

What does IncompleteRead(11 bytes read, 9 more expected) mean?

The headers announced a body length (Content-Length, or a chunk size), 11 bytes of it arrived, and the connection closed with 9 still missing. The client is reporting what it saw; the cut happened on the server side or somewhere in between.

Will HTTPAdapter(max_retries=Retry(...)) fix it?

No. We mounted Retry(total=3, read=3, connect=3) and the server was hit exactly once. Retry applies while urllib3 is getting the response; requests reads the body after that, so a broken body raises straight to your code. Wrap the call in your own retry loop.

Can I just use the partial data?

With urllib, e.partial holds bytes received, but for a chunked response cut mid-chunk we measured e.partial == b'' even though 11 bytes arrived. Treat a broken body as failed and retry, or resume with a Range header if the server supports it.

Does http.client raise IncompleteRead for anything else?

Yes. We separately measured that a transient None from a non-blocking socket makes HTTPResponse.read() raise IncompleteRead(0 bytes read) on a chunked body even though the server sent everything -- see our guide on resp.read() failing three different ways. With ordinary blocking sockets, IncompleteRead means a real early close.

Written with AI assistance. Measured on 2026-10-09 with Python 3.13.5 (urllib.request and http.client), requests 2.32.3 and urllib3 2.3.0, against a local socket server that sends valid headers and then closes the connection before the body is complete, framed three ways. PlainHeaders is our extension; Python, requests and urllib3 are free.