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:
Status code — which outcome this response represents (200, 404, 307, …).
Headers — metadata and side effects (
Content-Type,Cache-Control,Location,Set-Cookie, …). You prepare them onw.headersbefore the response is started (or before a helper that starts it for you).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.headerswhile the response has not started (w.startedis false). Useset/addwith string names and values in application code; the type validates tokens and blocks broken framing. See Setting headers (examples) forget,set,add,rset; Cookies forSet-Cookie.Call
write_headers(status_code)once to send the status line and every header currently onw.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 withend()(optionally passing a last chunk). If you never callwrite_headersyourself, the firstwriteor 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 asbytes.
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:
from stario import Context, Writerfrom 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:
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 (finallyblocks, context manager__aexit__).async for item in w.alive(source):forwardsitemfrom an async iterablesourceuntil 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.
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-Lengthset beforewrite_headers— You are declaring the exact byte count that will follow. Stario sends a non-chunked body and does not attach automaticContent-Encodingon that path: the bytes youwriteare what the client receives. Use this when you control the final entity bytes yourself (for exampleredirect/emptywith length 0, or a pre-serialized payload).No
Content-Lengthbeforewrite_headers— The writer uses chunked transfer and may choose a streaming compressor fromAccept-Encoding, compressing chunk-by-chunk and settingContent-Encoding/Varyas 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
- source—Optional 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
- data—Final body bytes, or
None. If the response never started, a minimal reply is synthesized (200with body, or204with 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
- body—Final entity body bytes.
- content_type—Raw
Content-Typeheader value (includecharsetwhen needed). - status—HTTP 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
- data—Chunk 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_code—HTTP 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).
Noneuses 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.
Noneuses 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.
Noneuses 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_encoding—Raw
Accept-Encodingheader value, orNoneto disable compression. - data—Optional full body; with
content_typeorstreaming, used to skip tiny or incompressible payloads. - content_type—Raw
Content-Typebytes; binary types may skip compression. - streaming—When
True,min_sizedoes 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
- w—Active response writer for this request.
- content—
bytes,str, or Stario HTML nodes (nodes run throughstario.html.render). - status—HTTP 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
- w—Active response writer.
- value—JSON-serializable structure (
dict,list, scalars). - status—HTTP 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
- text—Message body as a Unicode string.
- status—HTTP status code.
redirect(w, url, status=307)
Send a redirect with an empty body and a Location header.
Parameters
- w—Active response writer.
- url—Absolute URL or app-root-relative path (must not start with
//when relative). - status—Redirect 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
- status—Status code;
204is 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).