Responses

The Writer turns decisions into bytes on the wire.

The Writer sends status, headers, and body; the w.* methods wrap the common cases. Inbound cookies and disconnect handling live on Context.

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 — do not call Writer(...) in normal application code.

w turns decisions into bytes on the wire: status line, response headers, optional cookies, and the message body.

Response phases#

HTTP separates three layers you control in order:

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

  2. Headers — metadata on w.headers before the response starts (Content-Type, Cache-Control, Location, Set-Cookie, …).

  3. Body — zero or more bytes after the header block (Content-Length or chunked Transfer-Encoding).

Typical sequence:

  • Set values on w.headers while w.started is false.

  • Call write_headers(status_code) once to send the status line and headers.

  • Send the body with write(data) (repeat for streaming) and finish with end() (optionally passing a last chunk).

  • respond(body, content_type, status=200) combines negotiation, Content-Type, Content-Length, headers, and body when the full body is already bytes. content_type is bytes, e.g. b"text/plain; charset=utf-8".

The w.* methods perform the right sequence for common cases.

Setting headers#

from stario import Context, Writer
 
 
async def with_cache(c: Context, w: Writer) -> None:
    w.headers.set("Cache-Control", "public, max-age=3600")
    w.html("<p>Hello</p>")
 
 
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 mutate w.headers via Set-Cookie. See Cookies.

After headers are sent#

After write_headers — or any path that flushes headers — you cannot change status or add headers for that exchange. Log the error, record it on c.span, and clean up server-side; the peer only sees what was already sent. For bounded streaming, await w.drain() between w.write() calls: it waits until the transport can take more bytes and returns at once once the response is finished.

Read-only state#

w exposes started, completed, status_code, and closing — transport closing or closed. c exposes disconnected, shutting_down, and closing; handler work should stop when the client left or the app is draining. See Context.

The framework finishes a started message with w.end(), writes 500 when nothing was sent, and calls w.abort() when an error occurs after headers were sent.

Compression#

Compression policy comes from ServerConfig.compression (Configuration). The writer negotiates with Accept-Encoding; tokens are weighted by q values; among ties Stario prefers zstd, then brotli, then gzip — zstd costs less CPU on live responses at near-equal wire size. Bodies below min_size skip compression on whole-body (respond) paths. Chunked streams negotiate without a minimum body size. Incompressible Content-Type values skip compression on both paths.

Two framing paths matter:

  • Content-Length set before write_headers — non-chunked body; bytes written are sent as-is without automatic Content-Encoding on that path.

  • No Content-Length before write_headers — chunked transfer; streaming compressor may set Content-Encoding / Vary.

respond() may compress the full body in one pass, then set Content-Length to the compressed size. If Content-Encoding is already set on w.headers, automatic negotiation is skipped.

class Writer(*args, **kwargs)#

Low-level HTTP response for one request.

Set headers on headers, then respond for a whole body or write_headers followed by write / end for streaming.

Writer.abort()#

Close without framing a failed started response as complete.

async Writer.drain()#

Wait until the client has taken enough of what was written.

write() never blocks, so a loop writing large chunks to a slow client would buffer everything in memory. await w.drain() after each chunk keeps that bounded. Returns immediately while the connection keeps up, or once it is closed (check closing).

Writer.empty(status=204)#

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

Writer.end(data=None)#

Finish the response. Optional final body bytes.

Writer.html(content, status=200)#

Send bytes, str, or markup nodes as text/html.

Writer.json(value, status=200)#

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

Writer.redirect(url, status=307)#

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

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

Send a full response in one shot.

Writer.text(body, status=200)#

Send body as UTF-8 text/plain and finish the response.

Writer.write(data)#

Write one body chunk; sends default 200 headers if needed.

Writer.write_headers(status_code)#

Send the status line and current headers (at most once).

HEAD is handled by the server: the headers go out exactly as declared (Content-Length keeps the GET entity size) and the body is dropped.

class CompressionConfig(*, min_size=512, zstd_level=3, zstd_window_log=None, brotli_level=4, brotli_window_log=None, gzip_level=1, gzip_window_bits=None, preference=None)#

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

Configure only via the constructor; fields are not meant for post-init mutation.

CompressionConfig.enabled_encodings()#

Enabled encodings in preference order.

CompressionConfig.make_compressor(encoding)#

Build a compressor for an encoding already selected by negotiation.

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

Return True when negotiation could pick a compressor (shared by select and Writer fast paths).

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).

Response methods#

The w.* methods set status, content type, and headers, write the body (or set up redirects and empty bodies), and finish the response in one call. They sit on Writer, so w. completion shows them next to the transport methods.

html accepts pre-encoded bytes, a str, or Stario markup rendered via stario.markup.render. text is for human-readable str only — for arbitrary bytes such as files and images, use w.respond(data, b"application/octet-stream") or stream with write_headers + write. redirect requires a 3xx status. json serializes with stario.json.dumps_bytes — see JSON. Unsafe redirect URLs raise StarioError from normalized_location.

For conditional GET (304), set cache validators on w.headers before calling empty:

w.headers.set("ETag", etag)
w.empty(304)

redirect uses normalized_location for URL safety before setting the Location header. empty sends a zero-length body.

normalized_location(url)#

Validate a redirect target and return a percent-encoded URL string.

Cookies#

Read inbound cookies from c.req.cookies on the request. Write outbound cookies with stario.cookies.set_cookie(w, …) and stario.cookies.delete_cookie(w, …).

delete_cookie must use the same path, domain, secure, httponly, and samesite values as the original set_cookie so browsers match and clear the cookie.

from stario import Context, Writer
from stario import cookies
 
 
async def login(c: Context, w: Writer) -> None:
    token = "..."
    cookies.set_cookie(w, "sid", token, httponly=True, secure=True, path="/app")
    w.empty()

There is no get_cookie on stario.cookies — read c.req.cookies.get("sid") instead.