Handlers, context, and the writer
Every HTTP exchange is async def handler(c: Context, w: Writer). This page is the design story: what each piece is for, why Stario uses a writer instead of return Response, and how that stays manageable when responses are short, streamed, or both in one handler.
Full detail on headers, body, respond, compression, alive(), and “headers already sent” is in Responses. Context and Request are documented in Request and context.
The pieces
| Piece | Role | Detail |
|---|---|---|
c.req | HTTP input (headers, query, body reader) | Request |
c.span | Telemetry for this exchange | Telemetry design |
c.route | Matched pattern + {param} / {rest...} captures | Routing · Router design |
c.state | Request-scoped dict (middleware → handler) | Middleware |
w | Everything outbound: status, headers, body, streams | This page · Responses |
Rules of thumb
Headers sent → status code is fixed;
HttpExceptioncannot rewrite it.SSE started → do not finish with
responses.htmlon the sameWriter.Long loops → tie them to
w.alive()so disconnect cancels cleanly.
When does the handler run?
The HTTP parser builds a Request and Writer once the request line and headers are complete (on_headers_complete in HttpProtocol). Stario then schedules your app coroutine with app.create_task(app(c, w)) when the connection is ready to run the next message—on HTTP/1.1 pipelining, the next handler may wait until the prior response has finished on that connection.
The body may still be streaming on the same connection; await c.req.body() or stream readers block until data arrives. You start after request headers, not necessarily after the full body.
Handler lifetime: until your coroutine ends
There is no hidden “return a Response object” step. Your coroutine runs until it returns or raises (or is cancelled, e.g. when the client disconnects during w.alive()).
That means:
You can send a complete response (
responses.html,responses.empty, …) and keep running for non-HTTP work—e.g. broadcast to aRelay, write audit logs, or schedule follow-up work (preferapp.create_taskfor shutdown-aware tasks). Do not start a second HTTP response on the sameWriterafter a helper has finished that exchange; only side effects that are not additional status/body to the client.You can keep the connection open for a long time—SSE, slow streams—by writing through
wand usingw.alive()so teardown lines up with disconnect or server shutdown.
Compared to “return a Response” frameworks
Frameworks like Starlette or FastAPI often model return JSONResponse(...) or StreamingResponse. The handler’s return value is the response description; the framework turns that into bytes on the wire.
Stario instead hands you w and asks you to drive the protocol:
| Idea elsewhere | Stario |
|---|---|
| Return a body | responses.* or w.write after headers |
| Background after response | Same coroutine after responses.empty / last write; or app.create_task |
| Long-lived stream | write_headers + chunked writes + w.alive / disconnect futures |
| Injected “request” object | Context: req, span, route, state |
What you gain: one model for short replies, streaming, and SSE. You do not split “Response” vs “StreamingResponse” mentally—it is always “what did we already tell the client via w?”
Why not return Response?
Return-value APIs work well when every handler ends in one of a few shapes: JSON, HTML string, redirect. They get awkward when:
The same coroutine sends 204 and then continues to mutate shared state or publish events—there is no single return value that describes “response done, work continues.”
SSE is a long
Writersession: the natural control flow is loops andw.write, not “build a streaming object and return it.” Frameworks then introduce generator handlers, ASGI-style receive/send, or dual APIs for “normal” vs “streaming” responses. That splits the mental model and pushes complexity into adapters that unwrap your return type.
Stario keeps one path: w is the side effect. The writer idea is borrowed from Go’s http.ResponseWriter: a small interface, same type for trivial handlers and streaming handlers. Stario adds compression negotiation, chunked vs Content-Length framing, and alive() for asyncio, disconnect, and shutdown.
Why alive() matters
w.alive() (context manager or async for) cancels your handler when the client drops or the server begins shutdown, so you do not leak tasks or keep publishing into dead connections. Use it for SSE, w.alive(live) with async with relay.subscribe(...), or any “run until the user goes away” loop.
If you must finish a critical sequence regardless of the peer, you may choose not to enter alive() and accept that the socket might already be gone; see Responses — alive().
Next: Reading and writing Datastar signals · No validation layer in the framework · Writer.alive() · Datastar · Request and context · Routing