Script API¶
Everything you use from a routing script lives under the http namespace:
The namespace gives you three decorators for registering handlers —
@http.route, @http.middleware, @http.on_startup — and three objects your
handlers work with — http.Request, http.Response, and http.Client.
Handlers may be sync or async. Prefer async for anything that awaits I/O (an
outbound http.Client call, a cache lookup); it keeps the loop free.
Decorators¶
@http.route(path, methods=None)¶
Registers a handler for a route. The handler receives a single
Request and must return a Response —
returning anything else (including None) is a script error and produces a
500.
@http.route("/orders/{id}", methods=["GET", "DELETE"])
async def order(req):
oid = req.path_params["id"]
if req.method == "DELETE":
return http.Response(status=204)
return http.Response(
status=200,
headers={"Content-Type": "application/json"},
body=f'{{"id": "{oid}"}}'.encode(),
)
pathis a pattern with named segments:/users/{id}, or a catch-all/static/{*rest}. Params are extracted, URL-decoded, and exposed onreq.path_params.methodsis a list of HTTP method strings; the default is["GET"]. Methods are upper-cased for you. A route with several methods dispatches to the same handler — branch onreq.method.
@http.middleware¶
Registers a request guard. Middlewares run in registration order before the
matched route handler, each receiving the Request:
- Return a
Responseto short-circuit — the route handler is not called. - Return
Noneto continue to the next middleware (or the route handler).
@http.middleware
async def require_token(req):
if req.header("authorization") != "Bearer s3cr3t":
return http.Response(status=401, body=b"unauthorized")
return None # None → continue to the route handler
Typical uses: authentication, IP allow-listing, rate limiting, request logging.
Request guard, not a wrapper
Middleware today is a request guard — it runs before the route and can
only short-circuit. The wrap-around (req, call_next) form that also
rewrites the response is a roadmap item; until then, do
post-processing inside the route handler.
@http.on_startup¶
Registers a startup hook. It runs once, to completion, after the script loads and before any listener accepts. Use it to preload data, warm caches, or open shared clients.
No @http.on_shutdown yet
A script-level teardown hook (the counterpart to @http.on_startup) for
cleanup that must run in Python on the way down — deregister from a
service registry (e.g. a SIP gateway DELETE-ing itself from the trunk
registry), flush a
write buffer, close a pool opened at startup, release a lease, emit a final
metric. It can't just be a signal handler: Python's signal module only
works on the main thread and siphon runs handlers on worker threads, so a
script can't catch SIGTERM itself — only siphon can hand it a callback. On
the roadmap: it needs a siphon-side shutdown signal exposed to
addon tasks. Until then a registered handler is not invoked (the runtime
warns loudly rather than failing silently); do must-run-on-exit cleanup in
Rust or via your orchestrator (k8s preStop / grace period).
http.Request¶
The inbound request, passed as the only argument to handlers and middleware.
| Attribute | Type | Description |
|---|---|---|
method |
str |
"GET", "PUT", … |
path |
str |
Request path. |
path_params |
dict[str, str] |
Values extracted from the matched route, URL-decoded. |
query_params |
dict[str, str] |
Parsed query string. |
headers |
dict |
Lowercase-keyed headers. |
client |
str |
Remote socket address as "ip:port". |
| Method | Returns | Description |
|---|---|---|
req.body() |
bytes |
The buffered request body. |
req.header(name) |
str \| None |
A single header, case-insensitive. |
@http.route("/items", methods=["GET"])
async def list_items(req):
limit = int(req.query_params.get("limit", "100"))
...
http.Response¶
The outbound response your handler returns.
| Parameter | Default | Description |
|---|---|---|
status |
200 |
HTTP status code. |
headers |
None |
Response headers as a dict. |
body |
None |
bytes or str (UTF-8 encoded). |
A Response also exposes a .body property and .raise_for_status() — most
useful on the responses you get back from an http.Client call.
http.Client¶
An outbound HTTP client wrapping a pooled reqwest::Client. Two construction
modes:
http.Client("api") # named — looks up clients.api in http.yaml
http.Client(base_url="https://example.com", # inline
verify="/path/ca.crt",
cert=("/path/c.crt", "/path/c.key"))
Named clients share the pool configured under clients.<name> in
http.yaml; prefer them for anything hot. Inline
clients are handy for one-offs.
Constructor keyword arguments:
| Argument | Description |
|---|---|
name |
Positional. Look up clients.<name> from config. |
base_url |
Base URL; relative request paths are joined onto it. |
verify |
Path to a custom CA bundle to verify the server certificate. |
cert |
Client-cert identity for mTLS — a combined PEM or a (cert, key) pair. |
timeout_ms |
Per-request timeout in milliseconds. |
http2_prior_knowledge |
Start cleartext connections in h2c. |
All request methods are coroutines returning a Response:
| Method | Signature |
|---|---|
get |
await c.get(path, *, headers=None) |
post |
await c.post(path, *, body=None, headers=None) |
put |
await c.put(path, *, body=None, headers=None) |
patch |
await c.patch(path, *, body=None, headers=None) |
delete |
await c.delete(path, *, headers=None) |
The client supports the async with lifecycle (__aenter__ / __aexit__);
resp.raise_for_status() raises on a 4xx/5xx.
A note on state¶
Python handlers may run across threads (that is what lets siphon scale on free-threaded CPython). Keep mutable runtime state out of module globals — put it in Rust (a siphon primitive) or an external store. The REST API cookbook uses a process-local dict purely for illustration and calls this out.
Testing your scripts¶
You can unit-test HTTP scripts without binding a real listener. The
siphon-sip SDK (pip install
siphon-sip) mocks the http namespace — the @http.route / @http.middleware
/ @http.on_startup decorators and the Request / Response / Client types —
and ships an HttpTestHarness that dispatches mock requests through the
middleware chain into your route handlers. Outbound http.Client calls are
recorded and answered from canned responses, so a route that calls upstream is
testable in isolation:
from siphon_sdk.http_testing import HttpTestHarness
from siphon_sdk.http import MockResponse
def test_user_proxy():
harness = HttpTestHarness()
# canned upstream response for the outbound http.Client call
harness.add_response(MockResponse(status=200, body=b'{"name":"alice"}'))
harness.load_script("examples/rest_api.py")
resp = harness.request("GET", "/users/42")
assert resp.status == 200
# assert on what the script sent upstream
assert harness.sent_requests[0]["path"] == "/v1/users/42"
The mock also gives IDEs and LLMs the full type hints and docstrings for the
namespace, which helps when authoring scripts. It tracks this crate's runtime
surface — CI (scripts/check_sdk_parity.py) fails if they drift.
Roadmap¶
@http.on_shutdown— a script-level teardown hook run once on graceful shutdown (SIGTERM/SIGINT), the counterpart to@http.on_startup. For cleanup that must run in Python before the process exits: deregister from a service registry (a SIP gatewayDELETE-ing itself from the trunk registry — see the trunk registry example), flush a write buffer, close a pool opened at startup, release a lease, emit a final metric. It can't be a plain signal handler — Python'ssignalmodule only works on the main thread and siphon runs handlers on worker threads, so only siphon (which owns the signal) can hand the script a callback. Needs a siphon-side shutdown signal exposed to addon tasks; until then a registered handler is not invoked (the runtime warns loudly). Connection draining is separate — leave it to k8s readiness + grace period.- Response-rewriting middleware — the wrap-around
(req, call_next)form. Today middleware is a request guard; post-process inside the route handler. - Body streaming for large upload/download — v1 buffers whole bodies, capped.
- Live route reload on script hot-reload.