Skip to content

serve: fix HTTP/1.1 keep-alive desync and SSE framing (#597 item 3)#610

Merged
JustVugg merged 1 commit into
JustVugg:devfrom
ZacharyZcR:fix/597-http-keepalive-framing
Jul 25, 2026
Merged

serve: fix HTTP/1.1 keep-alive desync and SSE framing (#597 item 3)#610
JustVugg merged 1 commit into
JustVugg:devfrom
ZacharyZcR:fix/597-http-keepalive-framing

Conversation

@ZacharyZcR

Copy link
Copy Markdown
Contributor

Closes the last open item of #597. @mkelcb reported Bad request syntax ('{...json body...}POST /v1/chat/completions HTTP/1.1') β€” a previous request's body being parsed as the next request line β€” and suggested either dropping to HTTP/1.0 or doing HTTP/1.1 persistence properly with tests. This is the latter.

I reproduced it against dev before writing anything. There are two independent causes, and the diagnosis explains why the symptom seemed to come and go.

Cause 1 β€” early rejections leak the request body

do_POST runs _check_host() (403) and require_auth() (401) before read_json(). Both raise, the response goes out with a Content-Length, close_connection stays false, and the unread body is still sitting in the socket when the next readline() looks for a request line. Reproduced verbatim:

[api] "POST /v1/chat/completions HTTP/1.1" 403 -
[api] code 400, message Bad request syntax ('{"model": "test-model", ...}POST /v1/chat/completions HTTP/1.1')

This is why it showed up through Tailscale Serve: every request was a 403 until the item 2 allowlist landed in #606, so every request leaked a body. Fixing the allowlist removed the trigger, not the bug β€” hence "item 3 (retest)". A bad or over-MAX_BODY Content-Length leaks the same way and still does today.

Cause 2 β€” the streaming 200 is close-framed but says otherwise

start_stream sends no Content-Length and no Connection: close, so the only possible message boundary is the close β€” yet close_connection = True sat at the end of the stream (openai_server.py:1845, and :2110 for Anthropic), outside any try/finally. Two consequences:

  • A conforming client is told the connection is reusable for a response it can only terminate by reading to EOF.
  • If generation raises after the commit, that line never runs. do_POST then calls send_json, writing a complete HTTP/1.1 500 response inside the event stream, and leaves the socket advertised as reusable. My probe against dev:
-> second HTTP response injected into the stream? True
-> connection left open (no EOF)?             True

#609's deferred commit narrowed the window before ACCEPT; everything after it was still exposed.

The fix

Rather than teaching each early return to remember its body, drain once at the request boundary in handle_one_request() β€” one place, covering every early exit that exists now or later. Where the body can't be swallowed safely (chunked, unparseable or over-limit length, short read) it closes instead: an unreusable connection is correct, a desynchronised one is not, and swallowing an arbitrary declared length just to keep a socket warm is not a trade worth making.

Both SSE paths now send Connection: close and set close_connection when the 200 is committed, not when the stream ends β€” so a mid-stream failure cannot leave a reusable socket. send_response() is overridden as the single choke point recording that the status line is out, and _fail() consults it to stay silent rather than splice a second response into a committed stream.

Plain JSON keeps HTTP/1.1 persistence β€” explicitly tested, so this can't quietly degrade into close-everything (which is what the HTTP/1.0 workaround would have cost).

Tests

KeepAliveFramingTest, 11 cases covering the six points from the issue's regression list plus the paths above. 8 fail on dev, 3 are no-regression guards that pass either way:

  • 403 / 401 / 404 rejection followed by a valid request on the same connection
  • 4 sequential requests on one connection, asserting the engine sees each prompt exactly once with no bleed
  • rejections and successes interleaved 3Γ— on one connection
  • over-limit and unparseable Content-Length close rather than desync
  • both SSE paths (OpenAI + Anthropic) announce connection: close and reach EOF
  • engine failure after commit splices no second response (raw.count("HTTP/1.") == 1)
  • non-streaming JSON still reuses the connection

make check: 197 passed, 13 skipped. python3 -m pytest tests/test_openai_server.py: 94 passed.

Verified on Linux only β€” the change is pure http.server behaviour with no platform-specific paths.

@mkelcb if you can retest through Tailscale Serve, the interesting case is now a deliberately wrong --allowed-host followed by a real request on the same connection: that used to poison the connection and should now just be a clean 403.

…tem 3)

The reported `Bad request syntax ('{...json body...}POST /v1/chat/...')`
is a previous request's body being parsed as the next request line. Two
independent causes, both reproduced against dev:

1. Early rejections return before read_json(), so the body stays in the
   socket and the next readline() eats it. _check_host() (403) and
   require_auth() (401) both precede the body read -- which is why the
   reporter hit this through Tailscale Serve, where every request was a
   403 until the item 2 allowlist landed. A bad or over-MAX_BODY
   Content-Length leaks the same way.

2. The streaming 200 declared no Content-Length and no Connection:
   close, yet close_connection was set only after the final event. A
   client is told to expect a reusable connection for a response whose
   only boundary is the close. Worse, when generation raised after the
   commit, that line never ran: do_POST then wrote a complete
   `HTTP/1.1 500` response *into* the event stream, and left the
   connection advertised as reusable.

Rather than teaching each early return to consume its body, drain once
at the request boundary in handle_one_request() -- it covers every
present and future early exit. Where the body can't be swallowed safely
(chunked, unparseable or over-limit length, short read) close instead:
an unreusable connection is correct, a desynchronised one is not.

Both SSE paths now send Connection: close and set close_connection when
the 200 is committed, not when the stream ends, so a mid-stream failure
can't leave a reusable socket. send_response() is overridden as the one
choke point recording that the status line is out, and _fail() uses it
to stay silent instead of splicing a second response into a committed
stream.

Plain JSON responses keep HTTP/1.1 persistence; that is covered by a
test so the fix can't quietly degrade into close-everything.

11 tests: 8 fail on dev, 3 are the no-regression guards. Full suite 197.

Refs JustVugg#597
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants