Module:
core/sillo/core/http/response.pyRelated:core/sillo/objects/http.py(MutableHeaders),core/sillo/core/encoding.py(jsonable_encoder),core/sillo/exceptions.py(HTTPException, NotFoundException),core/sillo/pagination.py(Paginator, strategies) Owner: Core HTTP team Last updated: 2026-08-11
1. Overview
Section titled “1. Overview”The sillo HTTP response system is a layered architecture built on top of the
ASGI (Asynchronous Server Gateway Interface) protocol. Every response in
sillo is an ASGI application: it implements the __call__(scope, receive, send) triple that ASGI servers (Uvicorn, Hypercorn, Daphne) expect.
The hierarchy is intentionally shallow:
graph TD
B["BaseResponse<br/><i>raw ASGI, headers, cookies, caching</i>"]
B --> P["PlainTextResponse<br/><i>text/plain</i>"]
B --> J["JSONResponse<br/><i>application/json, jsonable_encoder</i>"]
B --> H["HTMLResponse<br/><i>text/html; charset=utf-8</i>"]
B --> F["FileResponse<br/><i>async file I/O, range requests</i>"]
B --> S["StreamingResponse<br/><i>async iterator, disconnect detection</i>"]
B --> R["RedirectResponse<br/><i>3xx, Location header</i>"]
On top of these, the Responder class provides a fluent builder API that
handler functions receive. It wraps a BaseResponse internally and exposes
chainable methods (json(), text(), html(), file(), stream(),
redirect(), download(), empty(), abort(), not_found(), cache(),
paginate()).
Key design principles:
- ASGI-native: Every response is a callable
(scope, receive, send)coroutine. - Headers as byte tuples:
raw_headers: list[tuple[bytes, bytes]]is the canonical storage: matches the ASGI spec’s header format directly. - Lazy MutableHeaders: The
headersproperty wrapsraw_headersin aMutableHeadersview (fromcore/sillo/objects/http.py) for dict-style access. - Content-Length discipline:
set_body()always re-syncsContent-Length. The header is skipped for 1xx, 204, and 304 responses (RFC 9110 §6.4.1).
2. Architecture Diagram
Section titled “2. Architecture Diagram”graph TB
subgraph Handler Layer
REQ[Request] --> RESP[Responder<br/>fluent builder]
end
subgraph "Responder (core/sillo/core/http/response.py)"
RESP --> |".json()"| JSONR[JSONResponse]
RESP --> |".text()"| PLAIN[PlainTextResponse]
RESP --> |".html()"| HTMLR[HTMLResponse]
RESP --> |".file()"| FILER[FileResponse]
RESP --> |".stream()"| STREAM[StreamingResponse]
RESP --> |".redirect()"| REDIR[RedirectResponse]
RESP --> |".download()"| FILER
RESP --> |".empty()"| BASER[BaseResponse]
RESP --> |".abort()"| EXC[HTTPException]
RESP --> |".not_found()"| NFE[NotFoundException]
end
subgraph "BaseResponse"
BASER --> MH[MutableHeaders]
BASER --> ASGI["__call__(scope, receive, send)"]
JSONR --> BASER
PLAIN --> BASER
HTMLR --> BASER
FILER --> BASER
STREAM --> BASER
REDIR --> BASER
end
subgraph "ASGI Server"
ASGI --> SEND["send(http.response.start)"]
SEND --> BODY["send(http.response.body)"]
end
subgraph "Dependencies"
RESP --> |".cache()"| CACHE[enable_caching]
RESP --> |".paginate()"| PAG[Pagination system]
JSONR --> |"use_encoder"| ENCODER[jsonable_encoder<br/>core/sillo/core/encoding.py]
MH --> |"core/sillo/objects/http.py"| HEADERS[MutableHeaders]
end
style RESP fill:#4A90D9,color:#fff
style BASER fill:#7B68EE,color:#fff
style FILER fill:#E8A838,color:#fff
style STREAM fill:#50C878,color:#fff
style JSONR fill:#FF6B6B,color:#fff
3. BaseResponse: The Foundation
Section titled “3. BaseResponse: The Foundation”Source:
core/sillo/core/http/response.py, lines 119 to 533
BaseResponse is the root of the response hierarchy. It handles:
- Body rendering (
render()) - Header management (
raw_headers,_init_headers(),headersproperty) - Cookie management (
set_cookie(),delete_cookie()) - Caching (
enable_caching(),disable_caching()) - ASGI compliance (
__call__())
3.1 Construction & __init__
Section titled “3.1 Construction & __init__”# core/sillo/core/http/response.py:158-188class BaseResponse: def __init__( self, body: JSONType | Any = "", status_code: int = 200, headers: dict[str, str] | None = None, content_type: str | None = None, ): self.charset = "utf-8" self.status_code: int = status_code self.raw_headers: list[tuple[bytes, bytes]] = [] self._body = self.render(body) self.content_type: str | None = content_type self._init_headers(headers)Initialization order matters:
self._body = self.render(body): converts the body to bytes first.self._init_headers(headers): readsself._bodyto computeContent-Length.
This order means _init_headers can always access a valid _body to compute
the length. Reversing it would produce a Content-Length of 0 for non-empty bodies.
Attributes:
| Attribute | Type | Description |
|---|---|---|
charset | str | Encoding for text content (default "utf-8") |
status_code | int | HTTP status code |
raw_headers | list[tuple[bytes, bytes]] | Headers in ASGI wire format |
_body | bytes | memoryview | Rendered body content |
content_type | str | None | Content-Type value (before charset injection) |
3.2 STATUS_CODES
Section titled “3.2 STATUS_CODES”# core/sillo/core/http/response.py:144-156STATUS_CODES: ClassVar[dict] = { 200: "OK", 201: "Created", 204: "No Content", 301: "Moved Permanently", 302: "Found", 304: "Not Modified", 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 500: "Internal Server Error",}Note: This is a convenience map, not authoritative. Sillo does not look up status phrases during response construction, the ASGI server (Uvicorn, etc.) adds the reason phrase from its own table if it sends HTTP/1.1 status lines.
3.3 render(): Body Serialization
Section titled “3.3 render(): Body Serialization”# core/sillo/core/http/response.py:190-214def render(self, content: typing.Any) -> bytes | memoryview: if content is None: return b"" if isinstance(content, (bytes, memoryview)): return content return content.encode(self.charset)Behavior matrix:
| Input type | Output | Notes |
|---|---|---|
None | b"" | Empty body |
bytes | pass-through | Zero-copy for binary data |
memoryview | pass-through | Zero-copy for buffer protocol objects |
str | encoded bytes | Uses self.charset (default UTF-8) |
The memoryview pass-through is important for FileResponse when serving chunks
that may come from mmap-backed buffers.
3.4 _init_headers(): Header Bootstrap
Section titled “3.4 _init_headers(): Header Bootstrap”# core/sillo/core/http/response.py:216-269def _init_headers(self, headers: dict[str, str] | None = None):Algorithm:
- Convert user-supplied
headersdict to[(key.lower().encode("latin-1"), value.encode("latin-1")), ...]. - Check if
content-lengthandcontent-typeare already present. - If
content-lengthis missing and the status code allows a body (not 1xx, 204, 304): compute fromlen(self._body). - If
content-typeis missing andself.content_typeis set: append charset fortext/*types. - Append all user headers.
Critical detail, charset injection:
if content_type.startswith("text/") and "charset=" not in content_type.lower(): content_type += "; charset=" + self.charsetThis means HTMLResponse can set content_type="text/html; charset=utf-8" to
avoid the automatic charset suffix, or leave it bare ("text/html") and let
_init_headers add it. Both paths produce the same result.
3.5 headers Property (MutableHeaders)
Section titled “3.5 headers Property (MutableHeaders)”# core/sillo/core/http/response.py:271-288@propertydef headers(self) -> MutableHeaders: if not hasattr(self, "_headers"): self._headers = MutableHeaders(raw=self.raw_headers) return self._headersThe MutableHeaders class (from core/sillo/objects/http.py, line 437) wraps
raw_headers with dict-style access:
response.headers["x-custom"] = "value" # setdel response.headers["x-custom"] # delete"x-custom" in response.headers # checkImportant: The MutableHeaders instance holds a reference to self.raw_headers.
This means edits through response.headers and direct set_header() calls
modify the same list. The ASGI send() reads self.raw_headers directly.
Cache trap: The
MutableHeadersis cached inself._headers. If you replaceself.raw_headerswith a new list (e.g., viaset_headers(..., override_all=True)), the cached_headersbecomes an orphan. Theset_header()method avoids this by editingself.raw_headers[:]in-place (line 502).
3.6 set_header() / set_headers() / remove_header()
Section titled “3.6 set_header() / set_headers() / remove_header()”# core/sillo/core/http/response.py:483-532def set_header(self, key: str, value: str, override: bool = False) -> BaseResponse: key_bytes = key.lower().encode("latin-1") value_bytes = value.encode("latin-1") new_header = (key_bytes, value_bytes)
if override: # Edit in place to preserve MutableHeaders cache binding self.raw_headers[:] = [ (k, v) for k, v in self.raw_headers if k != key_bytes ]
self.raw_headers.append(new_header) return selfoverride parameter:
override=False(default): Appends the header, allowing duplicates.override=True: Removes all existing entries with the same key, then appends.
Renamed: this shipped as
overide, onershort. The misspelling still works as a keyword and raises aDeprecationWarning; it will be removed in a future release. Callers who passed the flag positionally were never affected.
set_headers() is a batch variant:
def set_headers(self, headers: dict[str, str], override_all: bool = False): if override_all: self.raw_headers[:] = [ (k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in headers.items() ] return for key, value in headers.items(): self.set_header(key, value)Warning:
override_all=Truereplaces the entire header list. This discards Content-Type, Content-Length, and any cookies. Use with caution.
remove_header() delegates to MutableHeaders.__delitem__:
def remove_header(self, key: str): del self.headers[key]3.7 set_body(): Late Body Replacement
Section titled “3.7 set_body(): Late Body Replacement”# core/sillo/core/http/response.py:460-475def set_body(self, content: typing.Any) -> BaseResponse: self._body = self.render(content) self.set_header("content-length", str(len(self._body)), override=True) return selfUse this when you need to replace the body after construction (e.g. in
middleware). It keeps Content-Length in sync. Without set_body(), a direct
self._body = ... assignment would leave the stale Content-Length from the
original body. The ASGI server would then send fewer or more bytes than
declared, causing connection resets.
Returns self for chaining: response.set_body(new_body).set_header(...).
3.8 set_cookie() / delete_cookie()
Section titled “3.8 set_cookie() / delete_cookie()”# core/sillo/core/http/response.py:290-391def set_cookie( self, key: str, value: str = "", max_age: int | None = None, expires: datetime | str | int | None = None, path: str | None = "/", domain: str | None = None, secure: bool | None = False, httponly: bool | None = False, samesite: typing.Literal["lax", "strict", "none"] | None = "lax",) -> Any:Cookie attribute precedence:
max_agetakes precedence overexpireswhen both are set (browsers honorMax-Agefirst).expiresacceptsdatetime,int(Unix timestamp), orstr(HTTP date).datetimeobjects are formatted viaemail.utils.format_datetime(usegmt=True).samesiteis validated with an assertion: raisesAssertionErrorfor invalid values.
Implementation detail: Uses http.cookies.SimpleCookie to build the header value, then calls
cookie.output(header="").strip() to get the raw key=value; attr=val; ... string.
# Example: setting a session cookieresponse.set_cookie( key="session_id", value="abc123", max_age=3600, httponly=True, secure=True, samesite="strict")# Produces: Set-Cookie: session_id=abc123; Max-Age=3600; Path=/; Secure; HttpOnly; SameSite=strictdelete_cookie() sets max_age=0 and expires=0, effectively expiring the
cookie in the past:
def delete_cookie(self, key: str, path: str = "/", domain: str | None = None) -> Any: cookie = self.set_cookie( key=key, value="", max_age=0, expires=0, path=path, domain=domain ) return cookie3.9 enable_caching() / disable_caching()
Section titled “3.9 enable_caching() / disable_caching()”# core/sillo/core/http/response.py:393-436def enable_caching(self, max_age: int = 3600, private: bool = True) -> None: cache_control: list[str] = [] if private: cache_control.append("private") else: cache_control.append("public") cache_control.append(f"max-age={max_age}") self.set_header("cache-control", ", ".join(cache_control))
etag = self._generate_etag() self.set_header("etag", etag)
expires = datetime.now(timezone.utc) + timedelta(seconds=max_age) self.set_header("expires", formatdate(expires.timestamp(), usegmt=True))enable_caching() sets three headers:
| Header | Value | Purpose |
|---|---|---|
Cache-Control | private, max-age=3600 | Browser-only caching (default) or public for CDN |
ETag | W/"<sha1-base64>" | Weak ETag for conditional requests |
Expires | RFC 2822 date | Legacy HTTP/1.0 cache expiry |
The ETag is a weak ETag (W/"...") because it is based on the response
body bytes, semantically equivalent but byte-identical responses from different
servers might have different ETags.
# core/sillo/core/http/response.py:477-481def _generate_etag(self) -> str: content_hash = sha1() content_hash.update(self._body) return f'W/"{b64encode(content_hash.digest()).decode("utf-8")}"'disable_caching() sets the nuclear no-cache headers:
def disable_caching(self) -> None: self.set_header("cache-control", "no-store, no-cache, must-revalidate, max-age=0") self.set_header("pragma", "no-cache") # HTTP/1.0 backward compat self.set_header("expires", "0") # Immediate expiry3.10 ASGI __call__() Protocol
Section titled “3.10 ASGI __call__() Protocol”# core/sillo/core/http/response.py:438-453async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await send({ "type": "http.response.start", "status": self.status_code, "headers": self.raw_headers, }) await send({ "type": "http.response.body", "body": self._body, })This is the ASGI response protocol (ASGI spec §6.2):
http.response.start: Sent once. Contains status code and headers.http.response.body: Sent once (or multiple times for streaming). Contains the body.
For BaseResponse, the body is sent in a single chunk. Subclasses override this
for streaming behavior:
FileResponse: Sends the body in 64 KB chunks withmore_body=True.StreamingResponse: Iterates an async generator, sending each chunk.
Type aliases used throughout:
# core/sillo/core/http/response.py:44-48Scope = typing.MutableMapping[str, typing.Any]Message = typing.MutableMapping[str, typing.Any]Receive = typing.Callable[[], typing.Awaitable[Message]]Send = typing.Callable[[Message], typing.Awaitable[None]]4. PlainTextResponse
Section titled “4. PlainTextResponse”Source:
core/sillo/core/http/response.py, lines 535 to 543
class PlainTextResponse(BaseResponse): def __init__( self, body: JSONType = "", status_code: int = 200, headers: dict[str, str] | None = None, content_type: str = "text/plain", ): super().__init__(body, status_code, headers, content_type)The simplest subclass. It just sets content_type="text/plain", which causes
_init_headers() to inject the charset automatically:
Content-Type: text/plain; charset=utf-8Usage via Responder:
return response.text("Hello, World!")5. JSONResponse
Section titled “5. JSONResponse”Source:
core/sillo/core/http/response.py, lines 546 to 584
class JSONResponse(BaseResponse): def __init__( self, content: Any, status_code: int = 200, headers: dict[str, str] | None = None, indent: int | None = None, ensure_ascii: bool = True, use_encoder: bool = True, custom_encoder: dict[type, Callable[[Any], Any]] | None = None, ):5.1 jsonable_encoder Integration
Section titled “5.1 jsonable_encoder Integration”When use_encoder=True (the default), content is pre-processed through
jsonable_encoder from core/sillo/core/encoding.py before json.dumps():
if use_encoder: from sillo.core.encoding import jsonable_encoder content = jsonable_encoder(content, custom_encoder=custom_encoder)The jsonable_encoder handles:
- Pydantic models →
.model_dump()(v2) or.dict()(v1) - datetime / date / time → ISO 8601 strings
- UUID → string representation
- Decimal → float
- Enum →
.value - Path / PurePath → string
- IPv4/IPv6 addresses → string
- SecretStr / SecretBytes → masked value
- Generators → list
- Dataclasses →
dataclasses.asdict()
5.2 Custom Encoders
Section titled “5.2 Custom Encoders”from decimal import Decimal
response.json( data, custom_encoder={Decimal: lambda d: round(float(d), 2)})Custom encoders are merged on top of the global encoder registry. They are applied only to the current response. They do not modify the global state.
5.3 Error Handling
Section titled “5.3 Error Handling”try: body = json.dumps(content, indent=indent, ensure_ascii=ensure_ascii, allow_nan=False, default=str)except (TypeError, ValueError) as e: raise ValueError(f"Content is not JSON serializable: {e!s}")allow_nan=False: PreventsNaN/Infinityin JSON (invalid per RFC 7159).default=str: Last-resort fallback for non-serializable types.
Output Content-Type: application/json (no charset. JSON is defined as
UTF-8 by RFC 8259).
6. HTMLResponse
Section titled “6. HTMLResponse”Source:
core/sillo/core/http/response.py, lines 587 to 603
class HTMLResponse(BaseResponse): def __init__( self, content: str | JSONType, status_code: int = 200, headers: dict[str, str] | None = None, ): super().__init__( body=content, status_code=status_code, headers=headers, content_type="text/html; charset=utf-8", )Explicitly sets charset=utf-8 in the content type string, bypassing the
automatic charset injection in _init_headers().
7. FileResponse: Async Streaming & Range Requests
Section titled “7. FileResponse: Async Streaming & Range Requests”Source:
core/sillo/core/http/response.py, lines 606 to 922
FileResponse is the most complex response type. It supports:
- Async file I/O via
anyio.open_file()(works with bothasyncioandtrio) - Range requests (RFC 9110 §14.4): single range, multi-range, suffix range
- Multipart byte ranges for multi-range responses
- Stat-based headers (ETag, Last-Modified, Content-Length)
7.1 Construction & MIME Detection
Section titled “7.1 Construction & MIME Detection”# core/sillo/core/http/response.py:614-641class FileResponse(BaseResponse): chunk_size = 64 * 1024 # 64KB chunks
def __init__( self, path: str | Path, filename: str | None = None, status_code: int = 200, headers: dict[str, str] | None = None, content_disposition_type: str = "inline", ): super().__init__(headers=headers) self.path = Path(path) self.filename = filename or self.path.name self.content_disposition_type = content_disposition_type self.status_code = status_code
content_type, _ = mimetypes.guess_type(str(self.path)) self.media_type = content_type or "application/octet-stream" self.set_header("content-type", self.media_type) self.set_header( "content-disposition", f'{content_disposition_type}; filename="{self.filename}"', ) self.set_header("accept-ranges", "bytes")Key attributes:
| Attribute | Description |
|---|---|
path | Path object for the file on disk |
filename | Name sent in Content-Disposition (defaults to path.name) |
media_type | MIME type (guessed from extension, fallback application/octet-stream) |
content_disposition_type | "inline" (display in browser) or "attachment" (force download) |
chunk_size | 64 KB class variable for streaming chunk size |
_ranges | Parsed range tuples for the current request |
_multipart_boundary | Boundary string for multi-range responses |
Content-Disposition modes:
inline: Browser attempts to display the file (PDFs, images).attachment: Browser prompts download.
7.2 Stat Headers (ETag, Last-Modified)
Section titled “7.2 Stat Headers (ETag, Last-Modified)”# core/sillo/core/http/response.py:643-651def set_stat_headers(self, stat_result: os.stat_result) -> None: content_length = str(stat_result.st_size) last_modified = formatdate(stat_result.st_mtime, usegmt=True) etag_base = str(stat_result.st_mtime) + "-" + str(stat_result.st_size) etag = f'"{hashlib.md5(etag_base.encode(), usedforsecurity=False).hexdigest()}"'
self.set_header("content-length", content_length, override=True) self.headers.setdefault("last-modified", last_modified) self.headers.setdefault("etag", etag)The ETag for FileResponse is a strong ETag (not weak), derived from
mtime + size. This allows conditional requests (If-None-Match, If-Modified-Since)
to work correctly for range requests where the client may have a partial download.
7.3 ASGI __call__ Lifecycle
Section titled “7.3 ASGI __call__ Lifecycle”# core/sillo/core/http/response.py:653-670async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: try: stat_result = await anyio.to_thread.run_sync(os.stat, self.path) self.set_stat_headers(stat_result) except FileNotFoundError: raise RuntimeError(f"File at path {self.path} does not exist.") else: mode = stat_result.st_mode if not stat.S_ISREG(mode): raise RuntimeError(f"File at path {self.path} is not a file.")
range_header = MutableHeaders(scope=scope).get("Range") if range_header: self._handle_range_header(range_header)
await self._send_response(scope, receive, send)Sequence:
- Stat the file (offloaded to a thread via
anyio.to_thread.run_sync). - Validate the path exists and is a regular file.
- Parse the
Rangeheader if present. - Send the response (full file, single range, or multi-range).
7.4 Range Request Parsing (_parse_ranges)
Section titled “7.4 Range Request Parsing (_parse_ranges)”# core/sillo/core/http/response.py:672-718def _parse_ranges(self, range_header: str, file_size: int) -> list[tuple[int, int]]: unit, sep, spec = range_header.strip().partition("=") if not sep or unit.strip().lower() != "bytes": raise ValueError("Only byte ranges are supported")
ranges: list[tuple[int, int]] = [] for range_str in spec.split(","): first, sep, last = range_str.strip().partition("-") if not sep: raise ValueError(f"Malformed range {range_str!r}") first, last = first.strip(), last.strip()
if not first: # Suffix range: bytes=-500 → last 500 bytes suffix = int(last) if suffix <= 0: raise ValueError("Suffix range must be positive") start, end = max(0, file_size - suffix), file_size - 1 else: start = int(first) end = file_size - 1 if not last else min(int(last), file_size - 1)
if start < 0 or start >= file_size or start > end: raise ValueError("Unsatisfiable range") ranges.append((start, end))
if not ranges: raise ValueError("No ranges given") return rangesSupported range formats:
| Format | Example | Meaning |
|---|---|---|
bytes=0-99 | First 100 bytes | [0, 99] inclusive |
bytes=100- | From byte 100 to end | [100, file_size - 1] |
bytes=-500 | Last 500 bytes (suffix) | [file_size - 500, file_size - 1] |
bytes=0-99,200-299 | Multi-range | Two separate ranges |
Clamping behavior: If the client requests bytes=0-9999 on a 500-byte file,
the end is clamped to min(9999, 499) = 499. This avoids a 416 error for
over-reaching end positions (RFC 9110 §14.1.2).
7.5 Single Range Response (206)
Section titled “7.5 Single Range Response (206)”# core/sillo/core/http/response.py:761-769if len(self._ranges) == 1: start, end = self._ranges[0] self.set_header( "content-range", f"bytes {start}-{end}/{file_size}", override=True ) self.set_header("content-length", str(end - start + 1), override=True) returnResponse headers for a single range:
HTTP/1.1 206 Partial ContentContent-Type: video/mp4Content-Range: bytes 0-999/50000Content-Length: 1000Accept-Ranges: bytes7.6 Multi-Range / Multipart Response
Section titled “7.6 Multi-Range / Multipart Response”When the client requests multiple ranges (e.g., bytes=0-99,200-299), the
response uses multipart/byteranges:
# core/sillo/core/http/response.py:776-784self._multipart_boundary = self._generate_multipart_boundary()self.set_header( "content-type", f"multipart/byteranges; boundary={self._multipart_boundary}", override=True,)self.set_header( "content-length", str(self._multipart_length(file_size)), override=True)Multipart body structure:
--boundary_abc123Content-Type: video/mp4Content-Range: bytes 0-99/50000
<100 bytes of data>--boundary_abc123Content-Type: video/mp4Content-Range: bytes 200-299/50000
<100 bytes of data>--boundary_abc123--Content-Length calculation is precise. It counts the exact bytes that will be sent, including boundaries, headers, and CRLF separators:
# core/sillo/core/http/response.py:732-743def _multipart_length(self, file_size: int) -> int: total = len(self._multipart_epilogue()) for start, end in self._ranges: total += len(self._multipart_part_header(start, end, file_size)) total += end - start + 1 total += 2 # the CRLF that closes each part body return total7.7 416 Range Not Satisfiable
Section titled “7.7 416 Range Not Satisfiable”# core/sillo/core/http/response.py:745-759def _handle_range_header(self, range_header: str) -> None: file_size = self.path.stat().st_size try: self._ranges = self._parse_ranges(range_header, file_size) except ValueError: self._ranges = [] self.set_header("content-range", f"bytes */{file_size}", override=True) self.set_header("content-length", "0", override=True) self.status_code = 416 returnWhen 416 is returned:
- The
Rangeheader is malformed or the unit is notbytes. - All requested ranges are unsatisfiable (e.g.,
bytes=1000-2000on a 500-byte file). - The suffix range is zero or negative (
bytes=-0).
The Content-Range: bytes */500 header tells the client the total file size so
it can construct a valid range request.
7.8 Async File Streaming with AnyIO
Section titled “7.8 Async File Streaming with AnyIO”# core/sillo/core/http/response.py:824-843async def _send_full_file(self, file: AsyncFile[bytes], send: Send) -> None: while True: chunk = await file.read(self.chunk_size) if not chunk: break await send({ "type": "http.response.body", "body": chunk, "more_body": True, }) await send({ "type": "http.response.body", "body": b"", "more_body": False, })AnyIO is the key abstraction. It allows the same code to run under both
asyncio and trio event loops. anyio.open_file() returns an AsyncFile
that yields to the event loop on each read().
Chunking: 64 KB is the default (chunk_size = 64 * 1024). This balances:
- Memory usage (no full file buffered in memory)
- System call overhead (not one syscall per byte)
- Kernel buffer efficiency (aligns with common page/cache sizes)
8. StreamingResponse
Section titled “8. StreamingResponse”Source:
core/sillo/core/http/response.py, lines 924 to 988
class StreamingResponse(BaseResponse): def __init__( self, content: AsyncIterator[str | bytes], status_code: int = 200, headers: dict[str, str] | None = None, content_type: str = "text/plain", ): super().__init__(headers=headers) self.content_iterator = content self.status_code = status_code self._cookies: list[tuple[str, str, dict[str, Any]]] = [] self.content_type = content_type self.headers["content-type"] = self.content_type del self.headers["content-length"] # No fixed length for streamsCritical detail: Content-Length is deleted from headers. For streaming
responses, the total body size is unknown at construction time. The ASGI server
falls back to Transfer-Encoding: chunked for HTTP/1.1, or closes the connection
to signal the end of the body for HTTP/1.0.
8.1 ASGI Spec Version Detection
Section titled “8.1 ASGI Spec Version Detection”# core/sillo/core/http/response.py:968-988async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: spec_version = tuple( map(int, scope.get("asgi", {}).get("spec_version", "2.0").split(".")) )
if spec_version >= (2, 4): try: await self.stream_response(send) except OSError: raise ClientDisconnect() else: async with anyio.create_task_group() as task_group: async def wrap(func): await func() task_group.cancel_scope.cancel()
task_group.start_soon(wrap, partial(self.stream_response, send)) await wrap(partial(self.listen_for_disconnect, receive))Why the version check?
- ASGI spec >= 2.4: The server guarantees that
OSErroris raised onsend()if the client disconnects. Simple try/except is sufficient. - ASGI spec < 2.4: The server does not raise on
send()after disconnect. We must run a concurrent listener (listen_for_disconnect) that pollsreceive()forhttp.disconnectmessages.
The wrap helper is a clever cancellation pattern: whichever coroutine finishes
first (streaming or disconnect detection) cancels the task group, which aborts
the other.
8.2 Disconnect Detection
Section titled “8.2 Disconnect Detection”# core/sillo/core/http/response.py:946-950async def listen_for_disconnect(self, receive: Receive) -> None: while True: message = await receive() if message["type"] == "http.disconnect": breakThis is a blocking loop that awaits http.disconnect from the ASGI server. When
the client drops the TCP connection, the server pushes this message type.
Raises ClientDisconnect (from core/sillo/core/http/request.py, line 100)
on OSError during send() for spec >= 2.4.
9. RedirectResponse
Section titled “9. RedirectResponse”Source:
core/sillo/core/http/response.py, lines 991 to 1008
class RedirectResponse(BaseResponse): def __init__( self, url: str, status_code: int = 302, headers: dict[str, str] = {}, ): if not 300 <= status_code < 400: raise ValueError("Status code must be a valid redirect status")
headers["location"] = quote(str(url), safe=":/%#?=@[]!$&'()*+,;")
super().__init__(body="", status_code=status_code, headers=headers)Key behaviors:
- Status code must be 300 to 399. Raises
ValueErrorotherwise. - The
Locationheader is URL-encoded viaurllib.parse.quote()with a safe set that preserves common URL characters. - Body is always empty: browsers follow the redirect without rendering it.
Common status codes:
| Code | Constant | Meaning |
|---|---|---|
| 301 | Moved Permanently | Permanent redirect (SEO transfer) |
| 302 | Found | Temporary redirect (default) |
| 303 | See Other | POST → GET redirect |
| 307 | Temporary Redirect | Preserves HTTP method |
| 308 | Permanent Redirect | Preserves HTTP method + permanent |
10. Responder: Fluent Builder
Section titled “10. Responder: Fluent Builder”Source:
core/sillo/core/http/response.py, lines 1010 to 1828
The Responder class is the primary API for building responses in handler
functions. It wraps a BaseResponse internally and exposes a fluent,
chainable interface.
10.1 Construction & Request Binding
Section titled “10.1 Construction & Request Binding”# core/sillo/core/http/response.py:1117-1119class Responder: def __init__(self, request: Request): self._response: BaseResponse | Any = None self._request = requestThe Responder is bound to a Request at construction time. This allows
redirect(name=...) to call app.url_for() using the request’s scope.
Properties:
| Property | Description |
|---|---|
headers | Fresh MutableHeaders wrapping _response.raw_headers |
body | The raw body bytes (_response._body) |
content_type | The content type string |
content_length | Content-Length header or computed from body |
status_code | HTTP status code |
10.2 Content-Type Builders: json, text, html
Section titled “10.2 Content-Type Builders: json, text, html”Each builder creates the appropriate response subclass and stores it in
self._response:
# JSONdef json(self, data, status_code=200, headers={}, indent=None, ensure_ascii=True, custom_encoder=None): new_response = JSONResponse( content=data, headers=headers, status_code=status_code, indent=indent, ensure_ascii=ensure_ascii, custom_encoder=custom_encoder, ) self._response = new_response return self
# Plain textdef text(self, content, status_code=200, headers={}): new_response = PlainTextResponse( body=content, status_code=status_code, headers=headers ) self._response = new_response return self
# HTMLdef html(self, content, status_code=200, headers={}): new_response = HTMLResponse( content=content, status_code=status_code, headers=headers ) self._response = new_response return selfAll three return self for chaining.
10.3 File Builders: file, download
Section titled “10.3 File Builders: file, download”def file(self, path, filename=None, content_disposition_type="inline", status_code=200, headers={}): new_response = FileResponse( path=path, filename=filename, status_code=status_code, headers=headers, content_disposition_type=content_disposition_type, ) self._response = new_response return self
def download(self, path, filename=None): return self.file(path, filename, content_disposition_type="attachment")download() is a thin wrapper over file() that sets content_disposition_type="attachment".
10.4 stream(): Async Iterator Streaming
Section titled “10.4 stream(): Async Iterator Streaming”def stream(self, iterator, content_type="text/plain", status_code=200, headers={}): new_response = StreamingResponse( content=iterator, status_code=status_code, headers=headers, content_type=content_type, ) self._response = new_response return selfUsage pattern:
@app.get("/events")async def sse_handler(request: Request, response: Responder): async def event_stream(): while True: yield f"data: {await get_next_event()}\n\n" await asyncio.sleep(1)
return response.stream(event_stream(), content_type="text/event-stream")10.5 redirect(): URL & Named Route Redirects
Section titled “10.5 redirect(): URL & Named Route Redirects”def redirect(self, url=None, name=None, status_code=302, headers={}, **path_params): request = self._request if url is None and name is None: raise ValueError("Either 'url' or 'name' must be provided") if url is not None and name is not None: raise ValueError("Cannot provide both 'url' and 'name'")
if name is not None: app = self._get_base_app() url_path = app.url_for(name, **path_params) url = str(request.base_url) + str(url_path)
if url is None: raise ValueError("URL is required for redirect")
new_response = RedirectResponse(url=url, status_code=status_code, headers=headers) self._response = new_response return selfTwo modes:
- Direct URL:
response.redirect(url="/login") - Named route:
response.redirect(name="user_detail", user_id=42)
Named routes use the app’s url_for() method, accessible via the request scope.
10.6 empty(), abort(), not_found()
Section titled “10.6 empty(), abort(), not_found()”def empty(self, status_code=200, headers={}): new_response = BaseResponse(status_code=status_code, headers=headers) self._response = new_response return self
def abort(self, status_code, detail=None, headers={}) -> typing.NoReturn: raise HTTPException(status_code=status_code, detail=detail, headers=headers)
def not_found(self, detail=None, headers={}) -> typing.NoReturn: raise NotFoundException(detail=detail, headers=headers)abort() and not_found() do NOT return. They raise exceptions that are
caught by the framework’s exception middleware. This is different from the other
builder methods, which return self for chaining.
@app.get("/items/{item_id}")async def get_item(request, response): item = await db.get(request.path_params["item_id"]) if item is None: response.not_found(detail=f"Item {item_id} not found") return response.json(item)Important: The
return response.json(item)line is dead code afternot_found()becausenot_found()raises unconditionally. The pattern is “guard and short-circuit”.
10.7 cache() / no_cache()
Section titled “10.7 cache() / no_cache()”def cache(self, max_age=3600, private=True): self._response.enable_caching(max_age, private) return self
def no_cache(self): self._response.disable_caching() return selfThese delegate to BaseResponse.enable_caching() / disable_caching() (§3.9).
Chaining example:
return (response .json(data) .cache(max_age=3600, private=False)) # Public cache for 1 hour10.8 paginate() / apaginate()
Section titled “10.8 paginate() / apaginate()”# core/sillo/core/http/response.py:1713-1821def paginate(self, objects, strategy="page_number", data_handler=SyncListDataHandler, **kwargs) -> Responder:Supported strategies:
| Strategy | String key | Parameters |
|---|---|---|
| Page Number | "page_number" | page_param, page_size_param, default_page, default_page_size, max_page_size |
| Limit/Offset | "limit_offset" | limit_param, offset_param, default_limit, max_limit |
| Cursor | "cursor" | cursor_param, sort_field |
Async variant (apaginate) uses AsyncPaginator + AsyncListDataHandler.
@app.get("/users")async def get_users(request, response): users = await get_all_users() return response.paginate( users, strategy="page_number", page_size=20, max_page_size=100, )10.9 Header & Cookie Chaining
Section titled “10.9 Header & Cookie Chaining”def set_header(self, key, value, override=False): self._response.set_header(key, value, override=override) return self
def set_cookie(self, key, value, max_age=None, expires=None, path="/", domain=None, secure=True, httponly=False, samesite="lax"): self._response.set_cookie(...) return self
def delete_cookie(self, key, path="/", domain=None): self._response.delete_cookie(key=key, path=path, domain=domain) return self
def set_permanent_cookie(self, key, value, **kwargs): expires = datetime.now(timezone.utc) + timedelta(days=365 * 10) self.set_cookie(key, value, expires=expires, **kwargs) return selfFull chaining example:
@app.post("/login")async def login(request, response): data = await request.json user = await authenticate(data["username"], data["password"])
if user: token = generate_jwt_token(user) return (response .json({"message": "Login successful"}) .set_cookie("auth_token", token, httponly=True, secure=True) .set_header("X-User-ID", str(user.id)) .cache(max_age=0)) # Never cache login responses else: return response.json({"error": "Invalid credentials"}, status_code=401)10.10 get_response() & __call__()
Section titled “10.10 get_response() & __call__()”def get_response(self) -> BaseResponse: return self._response
async def __call__(self, *args, **kwargs): return await self._response(*args, **kwargs)The Responder is itself an ASGI application, calling await response(scope, receive, send) delegates to the inner BaseResponse.__call__().
11. Exception Types
Section titled “11. Exception Types”Source:
core/sillo/core/http/response.py, lines 53 to 117
MalformedRangeHeader
Section titled “MalformedRangeHeader”class MalformedRangeHeader(Exception): def __init__(self, content: str = "Malformed range header.") -> None: self.content = contentRaised when a Range header cannot be parsed at all (e.g., missing = sign,
non-bytes unit).
RangeNotSatisfiable
Section titled “RangeNotSatisfiable”class RangeNotSatisfiable(Exception): def __init__(self, max_size: int) -> None: self.max_size = max_sizeRaised when the requested range exceeds the file bounds. Carries max_size
for constructing the Content-Range: bytes */{max_size} response header.
Note: In the current implementation,
_parse_ranges()raisesValueErrorinstead of these custom exceptions. The_handle_range_header()method catchesValueErrorand sets status 416 directly. These exception classes exist for external use and future refactoring.
12. Response Lifecycle Diagram
Section titled “12. Response Lifecycle Diagram”sequenceDiagram
participant Handler as Route Handler
participant Resp as Responder
participant BR as BaseResponse
participant MH as MutableHeaders
participant ASGI as ASGI Server
Handler->>Resp: response.json(data)
Resp->>BR: JSONResponse(data)
BR->>BR: render(body) → bytes
BR->>BR: _init_headers() → Content-Length, Content-Type
BR->>MH: raw_headers wrapped
Resp-->>Handler: self (for chaining)
Handler->>Resp: .set_cookie("session", "abc")
Resp->>BR: set_cookie(...)
BR->>MH: append Set-Cookie header
Handler->>Resp: .cache(max_age=3600)
Resp->>BR: enable_caching(3600)
BR->>BR: _generate_etag() → SHA-1
BR->>MH: Cache-Control, ETag, Expires
Handler-->>ASGI: await response(scope, receive, send)
ASGI->>BR: __call__(scope, receive, send)
BR->>ASGI: send(http.response.start)
BR->>ASGI: send(http.response.body)
13. Responder Builder Flow Diagram
Section titled “13. Responder Builder Flow Diagram”flowchart TB
START([Handler receives<br/>Responder]) --> BUILDER{Choose builder}
BUILDER --> |".json(data)"| JR[JSONResponse<br/>+ jsonable_encoder]
BUILDER --> |".text(content)"| TR[PlainTextResponse]
BUILDER --> |".html(content)"| HR[HTMLResponse]
BUILDER --> |".file(path)"| FR[FileResponse<br/>inline display]
BUILDER --> |".download(path)"| DR[FileResponse<br/>attachment]
BUILDER --> |".stream(iterator)"| SR[StreamingResponse]
BUILDER --> |".redirect(url)"| RR[RedirectResponse]
BUILDER --> |".empty()"| ER[BaseResponse<br/>empty body]
BUILDER --> |".abort(code)"| AE[raise HTTPException]
BUILDER --> |".not_found()"| NE[raise NotFoundException]
JR --> CHAIN{Chain methods?}
TR --> CHAIN
HR --> CHAIN
FR --> CHAIN
DR --> CHAIN
SR --> CHAIN
RR --> CHAIN
ER --> CHAIN
CHAIN --> |".set_header()"| SH[Set custom header]
CHAIN --> |".set_cookie()"| SC[Set cookie]
CHAIN --> |".cache()"| CC[Enable caching]
CHAIN --> |".no_cache()"| NC[Disable caching]
CHAIN --> |".set_body()"| SB[Replace body]
SH --> CHAIN
SC --> CHAIN
CC --> CHAIN
NC --> CHAIN
SB --> CHAIN
CHAIN --> |"return"| RETURN([Returned to<br/>ASGI server])
AE --> EXCMW([Exception middleware<br/>catches and renders])
NE --> EXCMW
style START fill:#4A90D9,color:#fff
style RETURN fill:#50C878,color:#fff
style AE fill:#FF6B6B,color:#fff
style NE fill:#FF6B6B,color:#fff
style EXCMW fill:#FFA500,color:#fff
14. FileResponse Range Request Flow
Section titled “14. FileResponse Range Request Flow”flowchart TD
CALL["__call__(scope, receive, send)"] --> STAT["os.stat(path)<br/>via anyio.to_thread"]
STAT --> EXISTS{File exists<br/>& is regular?}
EXISTS --> |No| ERROR["Raise RuntimeError"]
EXISTS --> |Yes| RANGE{Range header<br/>present?}
RANGE --> |No| FULL["_send_full_file()<br/>64KB chunks"]
RANGE --> |Yes| PARSE["_parse_ranges()"]
PARSE --> VALID{Parse<br/>successful?}
VALID --> |No| SET416["status=416<br/>Content-Range: bytes */size"]
VALID --> |Yes| COUNT{How many<br/>ranges?}
COUNT --> |"1"| SINGLE["status=206<br/>Content-Range: bytes start-end/size"]
COUNT --> |">1"| MULTI["status=206<br/>Content-Type: multipart/byteranges"]
SET416 --> SEND416["send(response.start)<br/>send(response.body, empty)"]
SINGLE --> SENDR["_send_range()<br/>seek → read → send chunks"]
MULTI --> SENDM["_send_multipart_chunk()<br/>for each range, then epilogue"]
FULL --> DONE["send(body, more_body=False)"]
SENDR --> DONE
SENDM --> DONE
style CALL fill:#4A90D9,color:#fff
style DONE fill:#50C878,color:#fff
style ERROR fill:#FF6B6B,color:#fff
style SET416 fill:#FF6B6B,color:#fff
style SINGLE fill:#E8A838,color:#fff
style MULTI fill:#E8A838,color:#fff
15. StreamingResponse ASGI Flow
Section titled “15. StreamingResponse ASGI Flow”sequenceDiagram
participant Client
participant Server as ASGI Server
participant SR as StreamingResponse
participant Iter as Async Iterator
Client->>Server: HTTP GET /stream
Server->>SR: __call__(scope, receive, send)
alt ASGI spec >= 2.4
SR->>Server: send(http.response.start)
loop For each chunk
SR->>Iter: async for chunk in iterator
Iter-->>SR: chunk
SR->>Server: send(http.response.body, more_body=True)
end
SR->>Server: send(http.response.body, more_body=False)
else ASGI spec < 2.4
par Stream to client
SR->>Server: send(http.response.start)
loop For each chunk
SR->>Iter: async for chunk
Iter-->>SR: chunk
SR->>Server: send(http.response.body, more_body=True)
end
SR->>Server: send(http.response.body, more_body=False)
and Listen for disconnect
Server->>SR: receive() → http.disconnect
Note over SR: Cancel stream coroutine
end
end
16. Code Examples
Section titled “16. Code Examples”16.1 Simple JSON API
Section titled “16.1 Simple JSON API”# core/sillo/core/http/response.py — Responder class docstring example@app.get("/users")async def get_users(request: Request, response: Responder): users = await get_all_users() return response.json(users)16.2 JSON with Pretty-Print (Debugging)
Section titled “16.2 JSON with Pretty-Print (Debugging)”@app.get("/debug/config")async def debug_config(request: Request, response: Responder): config = await load_config() return response.json(config, indent=2, ensure_ascii=False)16.3 Cached JSON Response
Section titled “16.3 Cached JSON Response”@app.get("/static-data")async def get_static_data(request: Request, response: Responder): data = await get_expensive_computation() return (response .json(data) .cache(max_age=3600, private=False)) # Public CDN cache for 1 hour16.4 File Download with Custom Filename
Section titled “16.4 File Download with Custom Filename”@app.get("/reports/{report_id}")async def download_report(request: Request, response: Responder): report_id = request.path_params["report_id"] report = await db.get_report(report_id) return response.download( f"/data/reports/{report.filename}", filename=f"report-{report_id}.pdf" )16.5 Streaming SSE (Server-Sent Events)
Section titled “16.5 Streaming SSE (Server-Sent Events)”@app.get("/events")async def event_stream(request: Request, response: Responder): async def generate(): while True: event = await get_next_event() yield f"data: {json.dumps(event)}\n\n"
return response.stream(generate(), content_type="text/event-stream")16.6 Redirect Chain
Section titled “16.6 Redirect Chain”@app.post("/old-endpoint")async def old_endpoint(request: Request, response: Responder): return response.redirect("/new-endpoint", status_code=301)
@app.get("/profile")async def profile_redirect(request: Request, response: Responder): user = await get_current_user(request) return response.redirect(name="user_profile", user_id=user.id)16.7 Authentication with Cookies
Section titled “16.7 Authentication with Cookies”@app.post("/login")async def login(request: Request, response: Responder): data = await request.json user = await authenticate(data["username"], data["password"])
if user: token = generate_jwt_token(user) return (response .json({"message": "Login successful", "user": user.to_dict()}) .set_cookie("auth_token", token, max_age=3600, httponly=True, secure=True, samesite="strict") .set_header("X-User-ID", str(user.id))) else: return response.json({"error": "Invalid credentials"}, status_code=401)16.8 Paginated List Endpoint
Section titled “16.8 Paginated List Endpoint”@app.get("/articles")async def list_articles(request: Request, response: Responder): articles = await db.get_articles() return response.paginate( articles, strategy="page_number", page_size=20, max_page_size=100, )16.9 Abort with Error
Section titled “16.9 Abort with Error”@app.delete("/admin/users/{user_id}")async def delete_user(request: Request, response: Responder): if not request.user.is_admin: response.abort(403, detail="Admins only")
user_id = request.path_params["user_id"] await db.delete_user(user_id) return response.empty(status_code=204)16.10 Direct BaseResponse Usage (No Responder)
Section titled “16.10 Direct BaseResponse Usage (No Responder)”from sillo.core.http.response import JSONResponse, FileResponse
# Direct ASGI app usageasync def my_asgi_handler(scope, receive, send): response = JSONResponse( content={"status": "ok"}, status_code=200, headers={"X-Custom": "value"}, ) response.set_cookie("session", "abc123") await response(scope, receive, send)17. Testing Patterns
Section titled “17. Testing Patterns”17.1 Testing BaseResponse Headers
Section titled “17.1 Testing BaseResponse Headers”def test_base_response_content_type(): response = BaseResponse(body="hello", content_type="text/plain") assert response.status_code == 200 assert b"text/plain" in response.raw_headers[0][1] assert response._body == b"hello"17.2 Testing JSONResponse Serialization
Section titled “17.2 Testing JSONResponse Serialization”def test_json_response_with_datetime(): from datetime import datetime, timezone data = {"created": datetime(2026, 1, 1, tzinfo=timezone.utc)} response = JSONResponse(content=data) assert b"2026-01-01" in response._body17.3 Testing Cookie Setting
Section titled “17.3 Testing Cookie Setting”def test_set_cookie_attributes(): response = BaseResponse() response.set_cookie("session", "abc", max_age=3600, httponly=True, secure=True) cookie_header = dict(response.raw_headers).get(b"set-cookie", b"").decode() assert "session=abc" in cookie_header assert "httponly" in cookie_header.lower() assert "secure" in cookie_header.lower()17.4 Testing Range Parsing
Section titled “17.4 Testing Range Parsing”def test_parse_single_range(): response = FileResponse("/tmp/test.bin") # 1000 bytes ranges = response._parse_ranges("bytes=0-99", file_size=1000) assert ranges == [(0, 99)]
def test_parse_suffix_range(): response = FileResponse("/tmp/test.bin") ranges = response._parse_ranges("bytes=-500", file_size=1000) assert ranges == [(500, 999)]
def test_parse_multi_range(): response = FileResponse("/tmp/test.bin") ranges = response._parse_ranges("bytes=0-99,200-299", file_size=1000) assert ranges == [(0, 99), (200, 299)]17.5 Testing ASGI Call
Section titled “17.5 Testing ASGI Call”import pytest
@pytest.mark.anyioasync def test_base_response_asgi_call(): response = PlainTextResponse("Hello, World!") scope = {"type": "http"} messages = []
async def receive(): return {"type": "http.request"}
async def send(message): messages.append(message)
await response(scope, receive, send)
assert messages[0]["type"] == "http.response.start" assert messages[0]["status"] == 200 assert messages[1]["type"] == "http.response.body" assert messages[1]["body"] == b"Hello, World!"18. Performance Notes
Section titled “18. Performance Notes”18.1 Header Storage
Section titled “18.1 Header Storage”Headers are stored as list[tuple[bytes, bytes]] (the ASGI native format).
This avoids conversion during send(). The ASGI server receives headers in
exactly the format it expects.
18.2 FileResponse Chunking
Section titled “18.2 FileResponse Chunking”The 64 KB chunk size (chunk_size = 64 * 1024) is tuned for:
- Memory: A single chunk fits in L2 cache on most CPUs.
- Syscall: Reduces the number of
read()/send()calls. - Kernel buffers: Aligns with typical TCP window sizes.
18.3 jsonable_encoder Overhead
Section titled “18.3 jsonable_encoder Overhead”When use_encoder=True (default), every JSON response passes through
jsonable_encoder() which recursively walks the data structure. For simple
dict/list responses, this is negligible. For deeply nested Pydantic models,
consider use_encoder=False if you know the data is already serializable.
18.4 MutableHeaders Caching
Section titled “18.4 MutableHeaders Caching”The headers property lazily creates a MutableHeaders instance and caches
it. If you modify raw_headers directly (e.g., via set_header(override=True)),
the cached MutableHeaders sees the changes because it holds a reference to
the same list. However, replacing raw_headers entirely (e.g., self.raw_headers = [...])
breaks the cache binding.
18.5 FileResponse Stat Call
Section titled “18.5 FileResponse Stat Call”The os.stat() call in FileResponse.__call__() is offloaded to a thread pool
via anyio.to_thread.run_sync(). This prevents blocking the event loop for
filesystem I/O, which matters when the file is on a network filesystem (NFS,
SMB, FUSE).
19. Common Pitfalls
Section titled “19. Common Pitfalls”19.1 Forgetting Content-Length After set_body()
Section titled “19.1 Forgetting Content-Length After set_body()”# WRONG: Direct assignment leaves stale Content-Lengthresponse._body = b"new body"
# CORRECT: Use set_body() which re-syncs Content-Lengthresponse.set_body(b"new body")19.2 Using Mutable Headers After set_headers(..., override_all=True)
Section titled “19.2 Using Mutable Headers After set_headers(..., override_all=True)”# DANGEROUS: This replaces the entire header list,# orphaning the cached MutableHeadersresponse.set_headers({"new-header": "value"}, override_all=True)
# Later edits through response.headers won't reach the wire:response.headers["another"] = "broken" # Writes to orphaned list19.3 StreamingResponse and Content-Length
Section titled “19.3 StreamingResponse and Content-Length”# WRONG: StreamingResponse deletes Content-Length in __init__# Do not try to set it back — the total size is unknownresponse = StreamingResponse(iterator)response.headers["content-length"] = "1000" # Will be wrong if stream is shorter/longer19.4 FileResponse Path Validation
Section titled “19.4 FileResponse Path Validation”FileResponse does not validate the path exists in __init__(). It defers
to __call__(). This means you can construct a FileResponse for a
nonexistent file; the RuntimeError is only raised when the ASGI server calls
it.
19.5 Redirect Status Codes
Section titled “19.5 Redirect Status Codes”# WRONG: 200 is not a redirectRedirectResponse(url="/new", status_code=200) # raises ValueError
# CORRECT: Use 300-399RedirectResponse(url="/new", status_code=302)19.6 abort() Does Not Return
Section titled “19.6 abort() Does Not Return”# WRONG: Trying to chain after abort()response.abort(403).set_header(...) # TypeError — abort() returns NoReturn
# CORRECT: abort() raises immediatelyresponse.abort(403, detail="Forbidden")19.7 Cookie SameSite Assertion
Section titled “19.7 Cookie SameSite Assertion”# WRONG: Invalid samesite value raises AssertionErrorresponse.set_cookie("key", "val", samesite="invalid") # AssertionError
# CORRECT: Use "lax", "strict", or "none"response.set_cookie("key", "val", samesite="strict")20. Cross-References
Section titled “20. Cross-References”| Module | File Path | Relationship |
|---|---|---|
MutableHeaders | core/sillo/objects/http.py:437 | Wraps raw_headers for dict-style access |
Headers | core/sillo/objects/http.py | Immutable base class for MutableHeaders |
Request | core/sillo/core/http/request.py | Input object paired with Responder |
ClientDisconnect | core/sillo/core/http/request.py:100 | Raised on client disconnect in StreamingResponse |
jsonable_encoder | core/sillo/core/encoding.py | Pre-processes content for JSONResponse |
HTTPException | core/sillo/exceptions.py:15 | Raised by Responder.abort() |
NotFoundException | core/sillo/exceptions.py:118 | Raised by Responder.not_found() |
Pagination | core/sillo/pagination.py | Used by Responder.paginate() / apaginate() |
PageNumberPagination | core/sillo/pagination.py | Strategy for page-based pagination |
LimitOffsetPagination | core/sillo/pagination.py | Strategy for limit/offset pagination |
CursorPagination | core/sillo/pagination.py | Strategy for cursor-based pagination |
SyncPaginator | core/sillo/pagination.py | Synchronous paginator wrapper |
AsyncPaginator | core/sillo/pagination.py | Asynchronous paginator wrapper |
Appendix A: Response Class Selection Guide
Section titled “Appendix A: Response Class Selection Guide”flowchart TD
Q1{What content type?} --> |"JSON data"| JSONR[JSONResponse]
Q1 --> |"Plain text"| PLAIN[PlainTextResponse]
Q1 --> |"HTML page"| HTMLR[HTMLResponse]
Q1 --> |"File on disk"| FILE[FileResponse]
Q1 --> |"Async stream"| STREAM[StreamingResponse]
Q1 --> |"Redirect"| REDIR[RedirectResponse]
Q1 --> |"Empty body"| EMPTY[BaseResponse]
Q1 --> |"Error"| ERROR[HTTPException]
JSONR --> Q2{Need pretty print?}
Q2 --> |Yes| PP["json(data, indent=2)"]
Q2 --> |No| COMPACT["json(data)"]
FILE --> Q3{Force download?}
Q3 --> |Yes| DL["download(path)"]
Q3 --> |No| INLINE["file(path)"]
REDIR --> Q4{Permanent?}
Q4 --> |Yes| PERM["redirect(url, status=301)"]
Q4 --> |No| TEMP["redirect(url, status=302)"]
style JSONR fill:#FF6B6B,color:#fff
style PLAIN fill:#4A90D9,color:#fff
style HTMLR fill:#E8A838,color:#fff
style FILE fill:#9B59B6,color:#fff
style STREAM fill:#50C878,color:#fff
style REDIR fill:#F39C12,color:#fff
style EMPTY fill:#95A5A6,color:#fff
style ERROR fill:#E74C3C,color:#fff
Appendix B: ASGI Message Types
Section titled “Appendix B: ASGI Message Types”The response system uses two ASGI message types:
http.response.start
Section titled “http.response.start”{ "type": "http.response.start", "status": 200, # HTTP status code "headers": [ # List of (name, value) byte tuples (b"content-type", b"application/json"), (b"content-length", b"42"), ],}http.response.body
Section titled “http.response.body”{ "type": "http.response.body", "body": b'{"message": "Hello"}', # bytes or memoryview "more_body": False, # True if more chunks follow}Appendix C: Header Encoding
Section titled “Appendix C: Header Encoding”All headers are encoded as Latin-1 bytes (ISO 8859-1). This is required by the ASGI spec and matches HTTP/1.1’s default encoding for header values.
key_bytes = key.lower().encode("latin-1")value_bytes = value.encode("latin-1")Header names are always lowercased for case-insensitive comparison. HTTP/2
requires lowercase header names (pseudo-headers like :status are lowercase
by definition), so this normalization is forward-compatible.
End of document. Total lines: ~1100+.