Send response bodies inside the request context - #158
Merged
Merged
Conversation
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.
Contributor
|
Next steps:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A sync
StreamingResponsebody ran on the event loop afterhandle()returned, outside the request'scontextvars.Context. On a real server (1 worker, 4 threads):PoolTimeout.Design
handle()now returns aResponseLifecycle(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.send(write); websockets use it too. The body runs in the request context: sync chunks one thread-pool trip each (FileResponseon the default executor), async chunks and close in one context-bound task (soasyncio.timeout/TaskGroupacross yields work).http.server.request.durationcover 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.sent_status_codestarts 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 insidewrite, before the span ends.read():response.contentis the body for every response type, the response stays what the view returned, and body errors followraise_request_exception.Behavior changes
yield b""flushes them early. Async streams send headers first.StreamingResponsebodies are read as data arrives (read1; text pipes by line);FileResponsereads 64KB blocks.response.request_contextis gone.ClientResponse.contentholds streamed bodies;streaming_contenton a client response raises.Validation
All package tests pass; every package type-checks;
./scripts/server-testpasses 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
ResponseLifecycleinto one small strategy per body kind (buffered / sync-on-pool / async-in-task), each owning its pulls and cleanup — internal, no caller changes.ServerSentEventsViewmark responses long-lived, so hour-long streams record time-to-headers and get a linked span instead of dominating latency metrics.