Responses

This page covers three pieces: Writer (status, headers, body, streaming, compression, and respond on the writer), stario.responses helpers (HTML, JSON, redirects, empty, …), and cookies (set_cookie, get_cookie, …). Long-lived handlers also use alive().

Writer

The framework builds a Writer for each request and passes it as the second argument to your handler (async def handler(c: Context, w: Writer)). The app and HTTP stack own construction, disconnect futures, compression policy, and the transport hook—you should not call Writer(...) in normal application code (tests and advanced integrations are the exception).

Think of w as the object that turns your decisions into bytes on the wire: status line, response headers, optional cookies, and the message body—whether you send it all at once or in chunks.

How an HTTP response maps to Writer

HTTP separates three layers you control in order:

  1. Status code — which outcome this response represents (200, 404, 307, …).

  2. Headers — metadata and side effects (Content-Type, Cache-Control, Location, Set-Cookie, …). You prepare them on w.headers before the response is started (or before a helper that starts it for you).

  3. Body — zero or more bytes after the header block. Either the client knows the total size in advance (Content-Length) or the server uses chunked encoding (Transfer-Encoding: chunked).

Writer mirrors that shape:

  • Put values on w.headers while the response has not started (w.started is false). Use set / add with string names and values in application code; the type validates tokens and blocks broken framing. See Setting headers (examples) for get, set, add, rset; Cookies for Set-Cookie.

  • Call write_headers(status_code) once to send the status line and every header currently on w.headers. After this returns, the header block is fixed—HTTP does not let you change status or headers for a single response after they have been sent.

  • Send the body with write(data) (repeat for streaming) and finish with end() (optionally passing a last chunk). If you never call write_headers yourself, the first write or a one-shot helper can imply a default status and framing.

  • respond(body, content_type, status=200) combines negotiation, Content-Type, Content-Length, headers, and body in one call when you already have the full body in memory as bytes.

So: headers first (on the object), then “start response,” then body bytes, then “done.” Helpers in stario.responses are thin wrappers that do the right sequence for common cases.

Setting headers (examples)

Use w.headers before write_headers, respond, or any helper that completes the response:

python
from stario import Context, Writer
from stario import responses
 
 
async def with_cache(c: Context, w: Writer) -> None:
    w.headers.set("Cache-Control", "public, max-age=3600")
    responses.html(w, "<p>Hello</p>")
 
 
async def custom_json(c: Context, w: Writer) -> None:
    w.headers.add("X-Request-ID", str(c.req.headers.get("X-Request-ID") or "anon"))
    w.headers.set("Content-Language", "en")
    responses.json(w, {"ok": True})
 
 
async def byte_headers(c: Context, w: Writer) -> None:
    # When you already have header names/values as bytes (e.g. from config):
    w.headers.rset(b"x-env", b"prod")
    responses.text(w, "ok")

Streaming handlers set headers, then start the body manually:

python
from stario import Context, Writer
 
 
async def stream_lines(c: Context, w: Writer) -> None:
    w.headers.set("Content-Type", "text/plain; charset=utf-8")
    w.write_headers(200)
    for line in ("one", "two", "three"):
        w.write((line + "\n").encode("utf-8"))
    w.end()

Cookie helpers live in stario.cookies; they also mutate w.headers via Set-Cookie. See Cookies.

Once headers are on the wire

After write_headers (or any path that flushes headers), you cannot “upgrade” a failed body to 500 or add a missing header: the client has already seen the status and header block. Log the error, record it on c.span, and clean up server-side; the peer only sees what you already sent (and may see a truncated or incomplete body if you stop mid-stream).

Convenience: respond and stario.responses

When the full body is ready as bytes, w.respond(body, content_type, status) selects compression when appropriate, sets Content-Type and Content-Length, sends headers, and finishes the body—one coherent response.

Module stario.responses layers friendly entry points on top: html, json, text, redirect, empty. Use them for typical pages and APIs; use Writer directly for streaming, SSE, or when you need full control over chunking and ordering.

After send, read-only state includes started, completed, disconnected, shutting_down, and status_code. Datastar SSE helpers use the same Writer.

alive(): disconnect, shutdown, and cancellation

Long handlers (SSE, slow streams, relay subscribers) should not run forever without listening for “this connection or process is done.” w.alive() ties your coroutine to the same client disconnect and server shutdown futures the stack already uses (w.disconnected, w.shutting_down).

  • async with w.alive(): enters a context where a background task cancels your handler when either future completes. Your cleanup runs as the task unwinds (finally blocks, context manager __aexit__).

  • async for item in w.alive(source): forwards item from an async iterable source until disconnect/shutdown cancels the loop—handy for event streams without hand-rolling the watcher.

The idea is simple: do work only while the connection and server are still in a good state, then stop and release resources.

When you might skip alive(): if you must run a fixed workflow to completion regardless of the client (for example applying a critical command, finishing an idempotent background step, or draining an internal queue), you can drive write / end without entering alive(). You accept that the peer may disappear while you still run; avoid assumptions that anyone is still listening after disconnected is true.

See Runtime for how requests are closed and Datastar for SSE helpers that write through this Writer.

python
import asyncio
 
from stario import Context, Writer
 
 
async def long_poll(c: Context, w: Writer) -> None:
    async with w.alive():
        while True:
            await asyncio.sleep(0.5)
            # Handler task is cancelled when the client drops or the server shuts down.

Compression

Compression policy comes from the server as a shared CompressionConfig. The writer negotiates with the client using Accept-Encoding: supported tokens are weighted by q values; among ties, Stario prefers brotli, then zstd, then gzip. Very small bodies or obviously incompressible types (many binaries, fonts, archives) may skip compression to avoid overhead.

Two framing paths matter:

  • Content-Length set before write_headers — You are declaring the exact byte count that will follow. Stario sends a non-chunked body and does not attach automatic Content-Encoding on that path: the bytes you write are what the client receives. Use this when you control the final entity bytes yourself (for example redirect / empty with length 0, or a pre-serialized payload).

  • No Content-Length before write_headers — The writer uses chunked transfer and may choose a streaming compressor from Accept-Encoding, compressing chunk-by-chunk and setting Content-Encoding / Vary as needed.

respond() is a third, whole-body path: Stario may compress the full body in one pass, then set Content-Length to the compressed size so the client still sees a known-length reply. If Content-Encoding is already set on w.headers, automatic negotiation is skipped.

App.__call__ ensures writer.end() runs in a finally path for the request so keep-alive and pipelining stay consistent; keep your own headers and body semantics aligned with the path you chose (chunked vs fixed length).

class Writer(transport_write, get_date_header, on_completed, disconnect, shutdown, compression=CompressionConfig(min_size=512, zstd_level=3, zstd_window_log=None, brotli_level=4, brotli_window_log=None, gzip_level=6, gzip_window_bits=None), accept_encoding=None)

Low-level HTTP response serializer for one request/response on a connection.

Notes

Handlers normally receive a writer from the framework; constructing one yourself is for advanced or test code.

Set headers on headers, then either call respond for a whole body or write_headers followed by write / end for streaming (including SSE). The stario.responses helpers and Datastar build on these methods.

Writer.alive(source=None)

Watch disconnect and shutdown; cancel the current task when either fires.

Parameters

  • sourceOptional async iterable to iterate inside the same context (SSE loops often pass the event stream).

Returns

An async context manager; also iterable so async for x in w.alive(gen): works.

Notes

Prefer this over polling disconnected for long-lived streams.

Writer.end(data=None)

Finish the response: optional last chunk, terminating chunk for HTTP/1.1 chunked mode, then completion callback.

Parameters

  • dataFinal body bytes, or None. If the response never started, a minimal reply is synthesized (200 with body, or 204 with empty body).

Notes

The protocol relies on this being called exactly once per handler path so keep-alive and pipelining stay correct.

Writer.respond(body, content_type, status=200)

Send a full response in one shot (compression, Content-Length, body).

Parameters

  • bodyFinal entity body bytes.
  • content_typeRaw Content-Type header value (include charset when needed).
  • statusHTTP status code.

Notes

Skips negotiation if Content-Encoding is already set on headers. Uses the whole-body compression path, not per-chunk.

Writer.write(data)

Write one body chunk after headers (or send default 200 headers first if not started).

Parameters

  • dataChunk of the entity body.

Returns

self for chaining.

Raises

RuntimeError: If end already completed the response.

Notes

In chunked mode, chunk framing (and optional compression blocks) is applied automatically.

Writer.write_headers(status_code)

Send the status line and all current headers (must be called at most once).

Parameters

  • status_codeHTTP status for this response.

Returns

self for chaining.

Raises

RuntimeError: If headers were already sent.

Notes

If Content-Length is set, the body must be sent as raw bytes (no chunk framing). Otherwise the writer uses chunked encoding and may pick a streaming compressor from Accept-Encoding.

class CompressionConfig(min_size=512, zstd_level=3, zstd_window_log=None, brotli_level=4, brotli_window_log=None, gzip_level=6, gzip_window_bits=None)

Policy for picking br / zstd / gzip from Accept-Encoding and for streaming vs whole-body responses.

Fields

  • min_size(int):When body size is known, smaller payloads may skip compression to avoid overhead. default: 512
  • zstd_level(int):Zstd compression level (1-22). Negative disables offering zstd. default: 3
  • zstd_window_log(Union):Optional zstd window log (codec bounds apply). None uses the codec default. default: None
  • brotli_level(int):Brotli quality (0-11). Negative disables offering brotli. default: 4
  • brotli_window_log(Union):Optional Brotli lgwin. None uses the codec default. default: None
  • gzip_level(int):Gzip level (1-9). Negative disables offering gzip. default: 6
  • gzip_window_bits(Union):Optional gzip window bits. None uses a framework default. default: None

CompressionConfig.select(accept_encoding, *, data=None, content_type=None, streaming=False)

Choose one compressor from the client header and this config, or return None (identity).

Parameters

  • accept_encodingRaw Accept-Encoding header value, or None to disable compression.
  • dataOptional full body; with content_type or streaming, used to skip tiny or incompressible payloads.
  • content_typeRaw Content-Type bytes; binary types may skip compression.
  • streamingWhen True, min_size does not block enabling the codec (chunks are streamed).

Returns

A compressor instance, or None if negotiation says identity is best.

Notes

Among supported codecs with a positive q value, the highest q wins; ties prefer brotli, then zstd, then gzip.

Response helpers

Module stario.responses wraps Writer with helpers that set status, content type, and headers, write the body (or set up redirects and empty bodies), and finish the response in one call.

html accepts bytes, str, or Stario HTML nodes (nodes go through stario.html.render then encoding). json and text encode their values. redirect and empty cover common control-flow responses without hand-built headers.

html(w, content, status=200)

Send a complete HTML response via Writer.respond.

Parameters

  • wActive response writer for this request.
  • contentbytes, str, or Stario HTML nodes (nodes run through stario.html.render).
  • statusHTTP status code.

Notes

Sets Content-Type: text/html; charset=utf-8 and negotiates compression like other helpers.

json(w, value, status=200)

Serialize value to compact UTF-8 JSON and finish the response.

Parameters

  • wActive response writer.
  • valueJSON-serializable structure (dict, list, scalars).
  • statusHTTP status code.

Notes

Not for NDJSON or streaming; use Writer.write for line-delimited output.

text(w, text, status=200)

Send text as UTF-8 text/plain.

Parameters

  • textMessage body as a Unicode string.
  • statusHTTP status code.

redirect(w, url, status=307)

Send a redirect with an empty body and a Location header.

Parameters

  • wActive response writer.
  • urlAbsolute URL or app-root-relative path (must not start with // when relative).
  • statusRedirect status (302, 303, 307, 308, etc.).

Raises

StarioError: If url contains CR/LF or an unsafe relative form.

Notes

Uses Content-Length: 0; call before other body writes.

empty(w, status=204)

Finish with no message body (default 204 No Content).

Parameters

  • statusStatus code; 204 is typical for success-without-body.

Cookies

Module stario.cookies sets Set-Cookie via set_cookie(writer, ...), clears cookies with delete_cookie(writer, ...), and reads parsed values from req.cookies through get_cookie / get_cookies (pass c.req). Handlers take w and read the inbound message from c.req (Context).

get_cookies(req)

Return a shallow copy of all parsed request cookies.