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

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
wEverything outbound: status, headers, body, streamsThis page · Responses

Rules of thumb

  1. Headers sent → status code is fixed; HttpException cannot rewrite it.

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

  3. 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 a Relay, write audit logs, or schedule follow-up work (prefer app.create_task for shutdown-aware tasks). Do not start a second HTTP response on the same Writer after 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 w and using w.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 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 + w.alive / disconnect futures
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 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