Handlers, context, and the writer
Every HTTP exchange is async def handler(c: Context, w: Writer) -> None. Sync handlers are not supported. 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, c.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 |
c.alive / c.closing | Connection + shutdown lifecycle on the request | This page · Request |
w | Everything outbound: status, headers, body, streams | This page · Responses |
Rules of thumb
Headers sent (
w.started) → status is fixed;on_errorand default exception→response mapping cannot run. Uncaught errors are recorded on the request span instead.responses.*helpers finish the writer in one step (w.completed). After that, no further status or body bytes on the samew.SSE started → do not finish with
responses.htmlon the sameWriter.Long loops → use
c.alive()for prompt cancellation when the client drops or the server drains, or checkc.closingat explicit breakpoints if you need to decide where control flow exits (Request —alive()).
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 app.create_task(app(c, w)) — App.__call__, which resolves the route and invokes your handler — when the connection is ready to run the next message. On HTTP/1.1 pipelining, the next handler may start after the prior response completes on the wire (w.completed), while the earlier coroutine can still run post-response work.
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, raises, or is cancelled (client disconnect cancels the active handler task; c.alive() also reacts to server shutdown).
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). Afterresponses.*, the exchange is complete on the wire (w.completed), but your coroutine may keep running. Do not start a second HTTP response on the sameWriter.You can keep the connection open for a long time—SSE, slow streams—by writing through
wand usingc.alive()(or looping withwhile not c.closing:) so teardown lines up with disconnect or server shutdown.
The 204-then-continue pattern
A common command shape: acknowledge the HTTP request immediately, then mutate shared state and notify subscribers.
responses.empty(w, 204)game.paint_cell(user_id, cell_id)relay.publish("click", user_id)The client stops waiting after the 204; SSE loops on other connections pick up the new truth. The same coroutine continues—there is no background task indirection unless you want one. This is the tiles click handler in a sentence (Realtime tiles).
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 + c.alive() or explicit c.closing checks |
| 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 c.alive() on Context for asyncio, disconnect, and shutdown.
Why c.alive() matters
c.alive() (context manager or async for with a source iterable) asks the runtime to cancel your handler promptly when the client drops or the server begins shutdown, so you do not leak tasks or keep publishing into dead connections. On a keep-alive or pipelined connection, c.disconnect / c.alive() reflect the TCP peer, not an individual pipelined message — see Configuration. For relays, open the subscription first, then stream through c.alive—for example async with relay.subscribe("topic.*") as live: and async for msg in c.alive(live): inside that block. Use the bare async with c.alive(): form only when there is no separate async source iterable.
c.closing is the combined boolean guard (disconnected or shutting_down) when you prefer explicit break points instead of cancellation.
If you must finish a critical sequence regardless of the peer, you may choose not to enter c.alive() and accept that the socket might already be gone; see Request — alive().
app.on_error and async handlers
Register app.on_error(exc_type, handler) when one HTTP mapping should apply everywhere that exception type can surface—for example mapping HttpException to a stable JSON body (Mapping errors).
Error handlers are async def like route handlers. They run only while the response has not started; after headers go out, uncaught errors are recorded on the request span (fail / exception events), not rewritten into a new status line.
on_error resolves by MRO (most specific type wins). Match types deliberately—a handler for a broad validation library exception can turn database faults into 422 if you are not careful.
Next: Reading and writing Datastar signals · No validation layer in the framework · Context.alive() · Datastar · Request and context · Routing