An HTTP/1.1 server on a raw TCP socket — and the discovery that curl will print a complete-looking body to your terminal and then exit 28, while the response it exits 0 on is the one that lied to it.
Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3], curl 8.7.1 (x86_64-apple-darwin25.0) libcurl/8.7.1, over TCP on loopback.
cd http-from-sockets
python3 demo.py # the aha (§6) -- needs curl on PATH
python3 test_http_server.py # 17 tests, one of them on a real socket
You can also drive one framing by hand, which is the best way to feel it:
python3 http_server.py --mode none --after hold --port 8127
curl -s --max-time 1 http://127.0.0.1:8127/hello ; echo "exit=$?"
This toy is a working HTTP/1.1 server with no framework, no http.server, and no third-party anything: a listening socket, a read loop, some string splitting, and a sendall of bytes you assembled by hand. That part takes about forty lines and is not the point.
The point is the other hundred and fifty: ten different ways to end a response body, only some of which actually say where it ended. TCP will not tell the client. TCP has no messages — it has bytes, in order, and no idea that any group of them belongs together.
By the end you should be able to:
Connection: close does, and — more usefully — what it does not;recv sizes and know what it does, and does not, tell you about the network.TCP gives you a reliable, ordered byte stream. That is a genuinely hard thing to build and it is worth exactly what it costs — but read the promise carefully, because of the four words reliable, ordered, byte, stream, the last two are the ones that hurt. You will get every byte, in order. You will not be told where anything begins or ends. recv() returns "whatever is in the buffer right now," which may be half a request, or three responses, or one byte.
So any protocol running on TCP has to invent its own idea of "message." That is framing, and there are only three families of answer:
Content-Length. Cheap and exact, and it requires you to know the size before you send the first byte.HTTP/1.0 mostly used the third. One request, one response, close, done — so "the body is everything until FIN" was a complete answer and nobody had to think about it. HTTP/1.1 made connections persistent by default, because setting up a TCP connection costs a round trip and a page needs thirty objects. The moment the connection survives the response, EOF stops being available as a terminator: if the client waited for FIN it would wait forever, and if it guessed early it would read your next response as this one's body.
That is the trade this toy is about. Persistent connections bought you a round trip and charged you a framing header. Everything in the matrix in §6 is a way of not paying that charge correctly.
| Concept | Where it's used in the toy | One link |
|---|---|---|
| TCP is a byte stream with no message boundaries | read_request's two loops (lines 117–122, 131–136) — it must be willing to call recv again mid-header and mid-body |
Python socket HOWTO |
| Message framing: the three body-length rules | the whole FRAMINGS table, lines 92–103. This is the one that carries the result. |
RFC 9112 §6.3 |
recv(bufsize) returns at most bufsize, and says nothing about the wire |
read_request(conn, bufsize); §6.6 changes only this integer. Also load-bearing — for what you must not conclude. |
Python socket HOWTO |
| CRLF, and the blank line that ends the head | head(), lines 28–33 |
RFC 9112 §2.1 |
| Chunked transfer coding, terminated by a zero-length chunk | chunk() and frame_chunked, lines 36–38 and 63–65 |
MDN: Transfer-Encoding |
| Persistent connections (keep-alive) | serve's after parameter, lines 140–175 |
RFC 9112 §9 |
| FIN / orderly close as a framing signal | after="fin" → conn.close(), line 174 |
RFC 9112 §6.3 rule 8 |
| curl exit codes 0, 18, 28 | every row of §6.2 | libcurl error codes |
The two flagged rows are the whole page. The first decides what happens; the third decides what you are entitled to say about why.
The asymmetry to hold on to: rules 1 and 2 are promises the server makes; rule 3 is an event the client waits for. A promise can be wrong. An event can simply never happen.
def head(status_line, *headers):
"""Status line + headers + the blank line that ends the head."""
out = status_line + "\r\n"
for header in headers:
out += header + "\r\n"
return (out + "\r\n").encode()
def chunk(part):
"""One chunk of a chunked body: hex length, CRLF, the bytes, CRLF."""
return b"%x\r\n" % len(part) + part + b"\r\n"
There is no HTTP library under this. head() is string concatenation and one .encode(); the protocol's entire message syntax is "lines ending in CRLF, then an empty one." The + "\r\n" on the return line is the blank line, and it is the only delimiter in HTTP/1.1 that is a delimiter — everything after it is counted, not scanned.
chunk() is worth a second look because the %x is not decoration. Chunk lengths are hexadecimal, so the 13-byte and 14-byte pieces in frame_chunked go on the wire as d and e, not 13 and 14. A client reading them as decimal would ask for 13 bytes and 14 bytes anyway for the first one and get away with it, then ask for 14 and get 20. Bugs like that are why the test suite asserts the exact bytes.
def frame_correct(body):
return head("HTTP/1.1 200 OK", "Content-Type: text/plain",
"Content-Length: %d" % len(body)) + body
def frame_none(body):
return head("HTTP/1.1 200 OK", "Content-Type: text/plain") + body
def frame_short(body):
return head("HTTP/1.1 200 OK", "Content-Type: text/plain",
"Content-Length: 10") + body
Three functions, one body, and every byte after the head is identical in all three. frame_correct computes the length from the body it is about to send, which is the only version where the header cannot drift. frame_short hard-codes 10 — the thing a real server does implicitly the moment someone adds a suffix to the body after the length was computed, or a gzip layer recompresses it, or a proxy rewrites it.
frame_none is not a broken server so much as an unfinished one. It is exactly what you write first, before you have heard of framing, and it is the version that works perfectly in every manual test where you close the socket afterwards.
def frame_chunked(body):
return (head("HTTP/1.1 200 OK", "Transfer-Encoding: chunked")
+ chunk(body[:13]) + chunk(body[13:]) + b"0\r\n\r\n")
def frame_chunked_noterm(body):
return (head("HTTP/1.1 200 OK", "Transfer-Encoding: chunked")
+ chunk(body[:13]) + chunk(body[13:]))
The two functions are one expression apart. frame_chunked is 89 bytes, frame_chunked_noterm is 84, and the difference is literally b"0\r\n\r\n" — measured, not asserted; see CF2 in §6.4. Chunked encoding does not need the terminator to deliver the body. It needs it to end. A streaming server that crashes after its last chunk has sent you every byte it owed you and still produced an unterminated message.
def read_request(conn, bufsize=4096):
"""Read one request; return (request_line, headers, body, recv_sizes).
Two loops, because two different rules end two different things. The head
ends at a blank line -- a delimiter you scan for, so you must be willing
to call recv() again mid-header. The body ends after Content-Length bytes
-- a count you subtract from. There is no third rule and, if the header is
absent, no rule at all: `want` is 0 and any body sits unread in the
kernel's buffer. That asymmetry is the whole toy, seen from the other end.
"""
buf, sizes = b"", []
while b"\r\n\r\n" not in buf:
data = conn.recv(bufsize)
if not data: # client closed: no request
return None, {}, b"", sizes
sizes.append(len(data))
buf += data
raw_head, rest = buf.split(b"\r\n\r\n", 1)
The condition is while b"\r\n\r\n" not in buf and not if — the head may arrive in pieces, and a server that assumes one recv gives it a whole head works flawlessly against every small client and falls over against a large one. Note also buf.split(..., 1): whatever came in past the blank line is kept in rest rather than discarded, because a single recv can easily hand you the head and the body and the beginning of the next request together.
The if not data: return None is the honest reading of "recv returned zero bytes": on a socket that means the peer has closed, not that it is being quiet. Deleting it costs you an infinite loop the first time something connects and disconnects without speaking — which, as §6.5 shows, happens on every single run of the demo, because demo.py's own readiness probe does exactly that.
want = int(headers.get("content-length", 0))
while len(rest) < want:
data = conn.recv(bufsize)
if not data:
break
sizes.append(len(data))
rest += data
return request_line, headers, rest, sizes
headers.get("content-length", 0) defaulting to 0 is the request-side echo of the whole page: with no length header there is no body, full stop. RFC 9112 §6.3 rule 8 makes this asymmetric on purpose — for a request, "no framing" means length zero; for a response, it means read until close. A request cannot use EOF as its terminator, because the client still wants to read the answer on that same connection.
Deleting these seven lines is the difference between receiving 20 000 bytes and receiving 3 939 of them. That is CF5 in §6.4, and it is measured.
while True:
conn, _ = lsock.accept()
conns += 1
log("connection %d accepted" % conns)
while True:
request_line, _, req_body, sizes = read_request(conn, bufsize)
if request_line is None:
log("connection %d closed by client" % conns)
break
log(" %s | recv sizes %s | request body %d bytes"
% (request_line, sizes, len(req_body)))
conn.sendall(build(body))
if after == "keepalive":
continue
if after == "hold":
time.sleep(HOLD_SECONDS)
conn.close()
break
Two nested while Trues, and the inner one is the interesting one: it is persistence. after == "keepalive" means continue — go round and read another request off the same socket, which is what HTTP/1.1 does by default and what makes framing mandatory.
after == "hold" is the experimental apparatus. It writes the response and then simply does nothing for HOLD_SECONDS, keeping the socket open. That is not a pathological server; it is an ordinary keep-alive server that has finished its work and is waiting for your next request. The client cannot tell those two situations apart, and §6 is a table of what it does about that.
Everything below is a verbatim slice of one run of python3 demo.py.
The body is b"the framing is the message\n" — 26 characters and a newline, 27 bytes. Read the two failing-looking rows against each other:
none sent all 27 bytes and curl printed all 27. Then, having no rule that says the body ended, it fell through to rule 3 — read until FIN — and the server (an ordinary keep-alive server, doing nothing wrong except omitting a header) never sent one. At --max-time 1 curl gave up: exit 28, CURLE_OPERATION_TIMEDOUT. Every byte arrived. The transfer failed.short declared Content-Length: 10, so curl read exactly 10 bytes, considered the message complete, and stopped: exit 0, and it handed the caller 'the framin'. The other 17 bytes — 27 − 10 = 17 — were never delivered to anybody.If you are writing a script around curl, the first case wakes you up at 3am for a response that was fine and the second silently corrupts your data. The exit code is a statement about framing, not about the body.
Four things in that table are worth more than the headline.
close-header-only (hold) exits 28. The response says Connection: close in as many words, and the client still hangs. The header is an announcement of intent about the connection; it is not a body terminator. What ends the body under rule 3 is the FIN — an actual TCP segment, produced by an actual close(). Say you will close and then don't, and you have told the client nothing it can use. The fin row is the same response with a real close() after it: exit 0. The header does not frame. The FIN frames.
http10 (hold) exits 28 too. HTTP/1.0's default was close-when-done, so it is tempting to think the version string tells the client "expect an EOF". It does not change what the client does — it still waits for the FIN, and one that never comes times out identically. The version in the status line is not a framing mechanism either.
long and chunked-noterm swap 28 for 18 when the socket closes. long declares Content-Length: 47 (that is 27 + 20, from frame_long) and sends 27. Held open, curl waits for the missing 20 bytes and times out: 28. Closed, the FIN arrives while curl is still 20 bytes short of a promise, and it can say something much more precise — exit 18, CURLE_PARTIAL_FILE, "shorter or larger than expected." Same for the unterminated chunked response. That is the difference between "I gave up" and "you lied to me," and you only get the second diagnosis because there was a promise to check against. none has no promise, so closing it produces exit 0 and no complaint at all — the same bytes, an entirely clean bill of health.
both and both-te-first exit 0 with the correct body. Those responses carry Content-Length: 99 and Transfer-Encoding: chunked, in both possible orders. 99 is a lie about a 27-byte body, and curl ignores it completely, in both orders, because RFC 9112 §6.3 rule 3 says Transfer-Encoding wins and the Content-Length must be dropped. That precedence rule is why the combination is the classic request-smuggling primitive: it is only safe as long as every box in the chain implements the same tie-break. A front-end that believes the length and a back-end that believes the chunks will disagree about where one request ends and the next begins — and the attacker chooses the bytes in the gap.
fin column top to bottom. Every unframed response — none, close-header-only, http10 — exits 0 with the full body the moment the server closes the socket. If you close the connection after every response, framing headers are decoration and this entire page is about nothing. That is exactly why HTTP/1.0 could get away without them, and exactly what changed in 1.1: framing became mandatory the day the connection stopped ending. Every mechanism here is the cost of keeping a socket alive.
This is the whole -v trace, not a selection. Two lines in it settle the question of whether curl was fooled:
{ [27 bytes data] — it received all 27. The bytes were never lost, delayed, or dropped; they were sitting in the buffer.
Excess found writing body: excess = 17, size = 10, maxdownload = 10 — and it noticed. 27 − 10 = 17. curl knows perfectly well that the server sent more than it promised. It discards the excess, closes the connection rather than reuse a stream it can no longer find its place in, and reports success. That is a defensible choice — the alternative, keeping the connection and parsing g is the message\n as the next status line, is response smuggling — but the caller is told nothing. §6.5's keep-alive run is what that defensive close looks like from the outside.
These come from counterfactuals.py, which imports the shipped http_server.py. Real output, not reasoning.
CF1 — which Content-Length value flips the exit code? Sweep the declared length over a body that is really 27 bytes:
The flip is between 27 and 28 — one digit in one header. And the two sides are not symmetric in the slightest. Under-declare by any amount and you get exit 0 with a body truncated to exactly the number you claimed; the client has no way to know, because you never told it about the rest. Over-declare by one byte and you get a hang. Being too small is silent; being too large is loud. Almost every framing bug you will meet in production is on the silent side of that boundary.
CF2 — the five bytes.
89 − 84 = 5. Both deliver the identical 27-byte body to stdout. Five bytes of pure metadata are the entire difference between a successful transfer and a timeout.
CF3 — does the client insist on CRLF?
This one surprised me, and it is the most quietly instructive result here. Send the head with bare \n line endings, in flat violation of the grammar, and curl accepts it and exits 0. The delimiter it forgives; the count it does not. Robustness in real clients is not evenly distributed — it is concentrated wherever being strict would break existing servers, and absent wherever being lenient would mean guessing where a message ends. You cannot guess a length.
CF4 — parse the request head by scanning for \n\n.
Change the delimiter in read_request from b"\r\n\r\n" to b"\n\n" and the server never emits a response at all, because CR LF CR LF contains no two adjacent LFs. Note which side is lenient here: curl-the-client accepted my malformed LF-only response in CF3, and curl-the-client's own request is strictly CRLF. Be conservative in what you send.
CF5 — delete the body-reading loop.
Remove while len(rest) < want: — i.e. assume one recv hands you the whole request — and a 20 000-byte upload arrives as 3 939 bytes. The arithmetic: one recv(4096) returned 4 096 bytes, of which 157 were the head, leaving 4096 − 157 = 3 939 of body. No error is raised anywhere. The server just silently processes a fifth of the upload.
(That head is 157 bytes here and 156 in §6.6, because these counterfactuals run on ephemeral five-digit ports while the demo uses the fixed four-digit 8127, and the port number lives inside the Host: header. Framing arithmetic is unforgiving that way.)
CF6 — the one number that moves between runs.
1001 vs 1002. This message is the clearest single sentence in the whole toy — "timed out … with 27 bytes received", the failure and the complete body in one line — and it is also the reason demo.py runs curl with -s and never prints elapsed time. That millisecond figure is the only thing standing between this page and a reproducible transcript.
This is the price of the lie, and it is paid in connections. With correct framing, curl url/a url/b fetches both over one connection (number 2 — number 1 is the demo's readiness probe, which connects and closes without speaking, and is a real illustration of why read_request handles a zero-byte recv). With Content-Length: 10, curl uses two connections, 2 and 3, because after each response it finds 17 unaccounted-for bytes and refuses to reuse a stream it has lost its place in.
So a framing bug does not only corrupt the body. It quietly destroys keep-alive — the entire reason the framing header was needed — and converts every request into a fresh TCP handshake. On loopback that is free. Across the Atlantic it is another round trip per request, forever, and it will show up in your latency graphs as a mystery rather than as an error, because every one of those requests exits 0.
4096 is mine, not the network'sIdentical client, identical upload, three different answers to "how many pieces did it arrive in": 5, 20, or 1.
The arithmetic. Total bytes read is 20 156 every time: 20 000 of body plus a 156-byte request head (POST /upload HTTP/1.1, Host: 127.0.0.1:8127, User-Agent, Accept, Content-Length: 20000, Content-Type, and the blank line). Then:
recv's buffer bound, not TCP segmentation. Nothing in this toy observed a TCP segment boundary, and nothing in it could. recv returns at most bufsize bytes from the socket's receive buffer; how those bytes were sliced into segments on the way in, whether they were coalesced, and where any segment began are all invisible above the socket API. If you want segments you need tcpdump, and that is a different toy.
What the experiment does prove is the thing that matters for writing servers: the number of recv calls is a property of your code, not of the message. A reader that treats "one recv" as "one message" is not making an optimistic assumption about the network; it is making a false statement about an API. That is why read_request loops, and why test_recv_count_is_the_buffer_bound_not_the_wire asserts len(sizes) == ceil(len(raw) / bufsize) against an in-memory fake with no network under it at all — the relationship holds because it is arithmetic about a buffer, not physics about a wire.
Note also that ⌈·⌉ is a lower bound in general. On a slower link a recv can legitimately return less than bufsize because that is all that has arrived yet, giving more calls. It can never give fewer. On loopback the buffer is always full when we ask, so the bound is tight and the numbers above were identical across five consecutive runs.
A toy whose result depends on a port, a client, a timeout and a socket has an obvious problem: the repo requires byte-identical transcripts. What was done about it, concretely:
port_is_free() before anything starts and refused with a one-line message if busy. An ephemeral port would be more polite and would put a different number into the transcript on every run — including, as CF5 showed, a different request length.start_server, lines 45–62). A sleep(0.5) is both slower and occasionally wrong; polling connect() until it succeeds is neither. The cost is the phantom connection 1 in §6.5, which is honest and is left visible rather than filtered out.curl -s, always. The progress meter is the single largest source of transcript noise and it is timing-dependent.--max-time 1, verified sufficient for every timeout case here.after 1001 milliseconds message, are the only fields that differ between otherwise identical runs (CF6).With those five in place, five consecutive runs of demo.py produce identical output — 5 704 bytes, SHA-256 8de9778b45af6499… each time.
What remains environment-dependent, and is therefore declared in the demo's own banner rather than hidden:
Every exit code on this page is curl 8.7.1's judgement. A different client — or a different curl — may frame the same bytes the same way and still report differently; the Excess found writing body behaviour in particular is libcurl's policy, not the RFC's. The banner prints your local version, so if your numbers differ from this page's, the first line of the output tells you why.
curl is a hard dependency, on purpose. The obvious way to make this toy self-contained is to write a fifty-line Python client. It was rejected outright: a client I wrote, checking framing rules I also wrote, proves nothing except that I am internally consistent. The entire evidentiary value of §6 is that an independent implementation of RFC 9112 — one that predates this toy by twenty-five years and has no idea it exists — reads my bytes and disagrees with me. demo.py therefore checks for curl up front and exits with a message naming it rather than degrading to a fallback.
Ten framings in the core file, rather than one correct server plus broken variants in the demo. The comparison is the toy; the same call as rate-limiter carrying both algorithms and lru-cache carrying both policies. A single correct server would be about ninety lines and would teach the thing everyone already believes.
hold rather than a background thread pool. The response and what happens to the socket afterwards are two independent axes, so they are two independent parameters, and the demo crosses them. Making "hold" an explicit mode rather than an accident of scheduling is what turns "curl sometimes hangs" into a 2×10 table.
One connection at a time, no threads, no selectors. Concurrency is a different mechanism and would double the line count while teaching nothing about framing. The hold mode does block the accept loop for 30 seconds; the demo starts a fresh server per case, which costs about 10ms and keeps the toy honest about what it is.
Chunk sizes 13 and 14, hard-coded. They exist to make the hex length visible (d and e, not 13 and 14) and to prove the client reassembles across chunk boundaries. A single chunk would work and would demonstrate less.
Content-Length: 99 for the both cases. A wrong number, deliberately: if it were 27 you could not tell whether curl honoured it or ignored it. It has to be a lie for the precedence rule to be observable.
Not built: a --max-time-free demo. Without a timeout the unframed cases hang forever and there is no aha, only a stuck terminal. The timeout is not a workaround; the number 28 is the result.
epoll/kqueue plus a worker pool or an event loop. Framing gets harder there, not easier: the parser must be resumable, because a partial head arrives, the loop moves on to another socket, and it must come back and continue. This toy can block in recv because it has nothing else to do; nginx cannot, which is why real HTTP parsers are state machines rather than while ... not in buf.Transfer-Encoding is treated as chunked-or-nothing. Real implementations must parse a list (gzip, chunked), reject chunked when it is not the final coding (RFC 9112 §6.3 rule 5 makes that a 400), and handle chunk extensions and trailers.Expect: 100-continue. A real server reading a large request body must decide whether to accept it before the client sends it. That is a fourth framing-adjacent negotiation this toy skips entirely.Content-Length that describes a body that is not there (RFC 9112 §6.3 rule 1). A real client must not wait for it; a naive one built on the model in §4 would hang.read_request will happily accumulate a head of any size, which is a denial-of-service in one line. Real servers cap header size, header count, and body size, and those caps are the first thing you add after framing.settimeout, so a client that connects and says nothing pins the accept loop forever. The mirror image of the client-side problem this whole page is about.https.Answer before expanding. Each answer is derivable from the source.
A response has no Content-Length and no Transfer-Encoding. The server sends the body and keeps the socket open. How many bytes does the client receive, and does the transfer succeed?
It receives all of them and the transfer fails. This is mode=none, after=hold in §6.2: exit 28 with the complete 27-byte body on stdout. With no framing header the client falls through to RFC 9112 §6.3 rule 8 — read until the connection closes — and no close is coming, so it waits until its own timeout fires. Byte delivery and transfer success are independent here.
Your server computes Content-Length before a middleware appends a footer to the body. What does the client see, and what does it report?
It sees the body truncated to the originally computed length, and reports success. That is mode=short: Content-Length: 10 against 27 bytes gives exit 0 and 'the framin'. CF1 shows the whole shape — every under-declaration from 24 to 27 exits 0 with a body cut to exactly the declared size. The failure is silent by construction, because the client was told the message ended and had no reason to doubt it.
The second-order damage is in §6.5: curl also stops reusing the connection (2 connections for 2 requests instead of 1), so keep-alive dies too.
You add Connection: close to an unframed response but a bug leaves the socket open. Does the client survive?
No — exit 28, same as sending no header at all. That is the close-header-only, hold row. The header states an intention about connection lifetime; the body terminator under rule 3 is the FIN, an actual TCP event. The fin row of the same mode is exit 0. Announcing a close is not performing one.
Same unframed response, but the server declares Content-Length: 47 for a 27-byte body and then closes the socket. Why is the exit code 18 rather than 28, and why is that better?
Because closing while the client is still 20 bytes short of a stated promise lets it distinguish "I gave up waiting" from "you sent less than you said." Exit 18 is CURLE_PARTIAL_FILE. It is better because it is diagnosable: the client can say which side was wrong.
Note what this implies. The unframed version of the same close (none, fin) exits 0 — identical bytes, no complaint, because with no declared length there is nothing to check the delivery against. A wrong Content-Length is more debuggable than a missing one.
Your server sends both Content-Length: 99 and Transfer-Encoding: chunked. It works fine in testing. Why is it a security bug?
Because it works fine only as long as every hop agrees on the tie-break. RFC 9112 §6.3 rule 3 says Transfer-Encoding wins and intermediaries must strip the Content-Length — curl obeys this regardless of header order (both and both-te-first both exit 0 with the correct body). But if a front-end proxy honours the length and the back-end honours the chunks, they disagree about where the request ends. The bytes in the gap are treated as the start of the next request by one of them, and an attacker chooses those bytes. That is HTTP request smuggling (CL.TE / TE.CL).
The server logs recv sizes [4096, 4096, 4096, 4096, 3772]. What can you conclude about the network?
Nothing. That list is a property of the constant passed to recv, not of the wire. §6.6 reads the identical upload as 20 recvs (bufsize 1024) or 1 recv (bufsize 65536); the counts follow ⌈20156 / bufsize⌉. recv returns at most bufsize bytes out of the kernel's receive buffer and reports nothing about how the data was segmented, coalesced, or timed on its way in. Segment boundaries are invisible above the socket API — you would need tcpdump.
The only sound inference is the arithmetic one, and it is a lower bound: a slower link can produce more calls than ⌈total / bufsize⌉, never fewer.
Reaching past the toy. You run three replicas behind a load balancer that keeps persistent connections to each. One replica has the frame_short bug. What do your dashboards show?
Almost certainly a latency and connection-count anomaly, not an error rate. Every affected response exits 0 with a truncated body, so HTTP-status dashboards stay green. What moves is connection churn: as in §6.5, the client side abandons the connection after each bad response rather than reusing it, so that replica's share of new TCP handshakes (and TLS handshakes, in production) climbs while the other two stay flat. Downstream you get corrupt or short payloads roughly a third of the time, surfacing as parse errors in whatever consumes the body — a long way from the replica that caused them.
The generalisation worth keeping: a framing bug degrades into a performance signal plus a data bug, and almost never into an error-code signal.
Connection: close, and why 1.1 defaults the other way from 1.0. The context for §6.2's hold/fin split.recv again until your message is complete. Read the myreceive example next to read_request.CURLE_PARTIAL_FILE, "shorter or larger than expected") and 28 (CURLE_OPERATION_TIMEDOUT) actually mean. Worth skimming the whole list; it is a catalogue of the ways a byte stream can disappoint you.both row becomes when a front-end and a back-end resolve the Content-Length/Transfer-Encoding tie-break differently. CL.TE, TE.CL and TE.TE, with concrete payloads.--max-time, -s, and -v, the three flags that make §6 reproducible.