Skip to content

Send response bodies inside the request context - #158

Merged
davegaeddert merged 1 commit into
masterfrom
response-body-in-request-context
Sep 24, 2026
Merged

davegaeddert merged 1 commit into
masterfrom
response-body-in-request-context

Conversation

@davegaeddert

Copy link
Copy Markdown
Member

Problem

A sync StreamingResponse body ran on the event loop after handle() returned, outside the request's contextvars.Context. On a real server (1 worker, 4 threads):

  • One 2s query in a streaming generator made an unrelated request take 1.7s (0.8ms alone), with 3 threads idle.
  • The generator got a different DB connection than the view. Each keep-alive client that received such a stream pinned a pool connection until it disconnected; 4 idle clients exhausted a 4-connection pool and the next request failed with 500 PoolTimeout.
  • The test client closed sync streams before tests could read them, so streamed bodies read as empty.

Design

handle() now returns a ResponseLifecycle (plain/internal/handlers/response_lifecycle.py) that owns a response from the view's return until it's closed: the response, the request context, and the still-open SERVER span.

  • Server writers only frame bytes. H1 and H2 drive it with send(write); websockets use it too. The body runs in the request context: sync chunks one thread-pool trip each (FileResponse on the default executor), async chunks and close in one context-bound task (so asyncio.timeout / TaskGroup across yields work).
  • One close. The closers run once, in the request context, then the span ends — so the span and http.server.request.duration cover sending the body, and a body that raises partway is logged in-context and recorded on the span. Cancel-safe: a close never runs while a pull is still on a thread, and a cancel can't drop the framework's closers.
  • Writers report what went out. sent_status_code starts as the view's status; a writer that answers differently (a 500 for a body failing before its headers, a 503 refused upgrade) settles it inside write, before the span ends.
  • The test client drives the same object with a sync read(): response.content is the body for every response type, the response stays what the view returned, and body errors follow raise_request_exception.

Behavior changes

  • Sync streams send headers with the first chunk on both H1 and H2 (so an early failure gets a 500); yield b"" flushes them early. Async streams send headers first.
  • File-like StreamingResponse bodies are read as data arrives (read1; text pipes by line); FileResponse reads 64KB blocks.
  • response.request_context is gone. ClientResponse.content holds streamed bodies; streaming_content on a client response raises.

Validation

All package tests pass; every package type-checks; ./scripts/server-test passes with nothing skipped (h2spec 146/146, Autobahn 301/301, slow-HTTP, wrk, h2load). Each regression test was confirmed to fail with its bug re-introduced. Real server vs master: the body runs on a pool thread with the view's connection, an unrelated request stays at ~1ms during a slow stream, and idle keep-alive clients no longer exhaust the pool.

Costs (vs master): buffered responses +~2µs/request; files ~10x faster; async streams +~0.25µs/chunk; fast sync generators of many small chunks ~6x slower in throughput — master's number came from running them on the loop. Docs say to yield chunks of a few KB.

Follow-ups

  • Split ResponseLifecycle into one small strategy per body kind (buffered / sync-on-pool / async-in-task), each owning its pulls and cleanup — internal, no caller changes.
  • Let ServerSentEventsView mark responses long-lived, so hour-long streams record time-to-headers and get a linked span instead of dominating latency metrics.
  • Budgeted handoff pulls for sync bodies, only if a realistic export benchmark needs them.

A sync StreamingResponse body ran on the event loop after `handle()`
returned, outside the request's contextvars.Context: one slow query in a
generator stalled every connection on the worker, the generator used a
different database connection than the view's, and keep-alive clients
pinned those connections until the pool ran out.

`handle()` now returns a `ResponseLifecycle` that owns the response from
the view's return until it's closed. Both protocol writers drive it with
`send()`; the test client drives the same object with `read()`. The body
runs in the request context (sync chunks on the thread pool, async
chunks in one context-bound task), the closers run there once it's done,
and the SERVER span ends after the close, so it covers sending the body
and records a body that fails partway. Writers report the status that
actually went out.
@pullapprove5

pullapprove5 Bot commented Sep 24, 2026

Copy link
Copy Markdown
Contributor
PASS: 1 review scope passed
Scope Progress
✅ code 1/1

View in PullApprove

Next steps:

@davegaeddert
davegaeddert merged commit 70476ee into master Sep 24, 2026
8 checks passed
@davegaeddert
davegaeddert deleted the response-body-in-request-context branch September 24, 2026 18:59
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.

1 participant