Handlers and the writer

One signature covers a reply, work after the reply, and a stream.

Every Stario handler has the same shape:

async def handler(c: Context, w: Writer) -> None:
    ...

c is the request and everything around it. w is the response. The handler writes to w and returns nothing. This essay explains why it is built that way and what the shape buys you.

The handler starts early and can run late#

Stario calls the handler as soon as it can: once the headers arrive for a streamed or Expect: 100-continue body, or once the whole request has arrived for a small one. So the body may still be in flight. From that moment the function can run for as long as it likes. It can reply at once, reply and keep working, or write to the response for an hour.

That is the whole idea. A handler is not "the thing that computes a response". It is a function that runs during a request and has the means to write to the connection. When the bytes go out is your decision.

Two rules follow. The handler must write a response; if it returns without writing one, Stario logs an error and the client gets a 500. And every handler is async. Almost everything a handler does wants await, and two kinds of handler would split the stack in half for little gain.

Why two objects#

c is the incoming side: the request, the matched route, the span for telemetry, and c.state, a dict for anything you want to carry through this request. It is valid for the life of the handler and never changes state.

w is the outgoing side. It has a lifecycle. Before you write, headers are open and you can set them. After the first write, the status is fixed. After the response is finished, further writes fail.

Those states belong on their own object. If one object held both, you would have a "fresh" context and a "spent" context, and every read of the request would have to ask whether the response was already gone. Splitting them keeps c simple and puts all the lifecycle on w.

One shape, three kinds of work#

Because the handler decides when to write, the same signature covers three cases that other frameworks give different types.

A normal page reads c, writes once, and returns:

async def home(c: Context, w: Writer) -> None:
    w.html(home_view())

Work after the reply writes first, then keeps going. The browser has its page; the coroutine is still running. That is a background task without a second API:

async def save(c: Context, w: Writer) -> None:
    w.html(saved_view())
    await notify_followers()

A stream starts the response and writes to it over time. Server-sent events are this. There is no streaming response type to switch to; you keep writing on the same w:

async def ticks(c: Context, w: Writer) -> None:
    w.headers.set("Content-Type", "text/event-stream")
    w.write_headers(200)
    async with c.alive():
        while True:
            w.write(b"data: tick\n\n")
            await asyncio.sleep(1)

c.alive() ends the handler when the client leaves or the server shuts down. The w.* response methods and SSE(w) are built on the same writer; they do not add a second handler shape.

What is on the context#

c.req is the request: method, path, headers, query, cookies, body.

c.match is the route hit: .pattern is the route address and .params are the captured values.

c.span is telemetry for this request. Attributes, events, and steps you add here show up in the trace.

c.state is a dict for this request. Middleware puts the current user or a session here so handlers do not repeat the lookup.

c.closing and c.alive() tell a long-running handler when to stop. Use them whenever you loop while a connection stays open. The exact forms are in Request and context.

What the writer promises#

w follows the response on the wire: status, then headers, then a body in one write or many. You tell it what to send. There is no response object that the framework converts later.

On a long stream this has one consequence worth knowing. You have sent 200 and several patches. Then the tenth write fails. You cannot change the status; the client saw success long ago. The request span, however, can still record the failure. The status is what went on the wire. The span is what happened.

Compression#

The writer is the last step before the socket, so it is the place that applies Content-Encoding. Do not compress in the handler. Write the plain body.

The writer compresses when three things hold. The client sent Accept-Encoding. The content type can shrink, such as HTML, JSON, or text, and not an image or a video. And a complete body is at least min_size bytes, 512 by default; a streamed body has no minimum, because the headers go out before the size is known.

If you set Content-Encoding yourself, or set Content-Length and write raw chunks, the writer leaves the bytes alone. The rules are in Responses.

Next: Live pages uses the open writer to keep a whole page current.