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

PieceRoleDetail
c.reqHTTP input (headers, query, body reader)Request
c.spanTelemetry for this exchangeTelemetry design
c.routeMatched pattern + {param} / {rest...} capturesRouting · Router design
c.stateRequest-scoped dict (middleware → handler)Middleware
c.alive / c.closingConnection + shutdown lifecycle on the requestThis page · Request
wEverything outbound: status, headers, body, streamsThis page · Responses

Rules of thumb

  1. Headers sent (w.started) → status is fixed; on_error and default exception→response mapping cannot run. Uncaught errors are recorded on the request span instead.

  2. responses.* helpers finish the writer in one step (w.completed). After that, no further status or body bytes on the same w.

  3. SSE started → do not finish with responses.html on the same Writer.

  4. Long loops → use c.alive() for prompt cancellation when the client drops or the server drains, or check c.closing at 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 a Relay, write audit logs, or schedule follow-up work (prefer app.create_task for shutdown-aware tasks). After responses.*, the exchange is complete on the wire (w.completed), but your coroutine may keep running. Do not start a second HTTP response on the same Writer.

  • You can keep the connection open for a long time—SSE, slow streams—by writing through w and using c.alive() (or looping with while 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.

python
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 elsewhereStario
Return a bodyresponses.* or w.write after headers
Background after responseSame coroutine after responses.empty / last write; or app.create_task
Long-lived streamwrite_headers + chunked writes + c.alive() or explicit c.closing checks
Injected “request” objectContext: 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 Writer session: the natural control flow is loops and w.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