Starlette¶
Haiway provides a Starlette integration plugging the context system into request handling. It
exposes an application wide declaration of the request context through ServerContext, an ASGI
middleware entering a context scope per request through ContextMiddleware, and an application
factory wiring both together through application.
Overview¶
- Context Managed: application resources are prepared once, on startup, and their state is
available to every request through
ctx - Scope per Request: each request is handled within its own context scope, recorded as one trace and described the way the HTTP semantic conventions of OpenTelemetry ask for
- Traceable Responses: responses carry the trace identifier of their request, so a client can report which one failed
- Plug In Anywhere: the middleware and the lifespan are plain Starlette pieces, so an existing application - a FastAPI one included - can be plugged in without being rewritten
Installation¶
Install the Starlette extra:
Quick Start¶
Declare the application context, then build the application from it:
from haiway import State, ctx
from haiway.httpx import HTTPXClient
from haiway.starlette import ServerContext, application
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.routing import Route
class ServiceConfig(State):
upstream: str = "https://example.com"
async def status(request: Request) -> Response:
# state declared by the application context, resolved from the request scope
config: ServiceConfig = ctx.state(ServiceConfig)
ctx.log_info("Checking %s", config.upstream)
return JSONResponse({"upstream": config.upstream})
app = application(
ServerContext(
ServiceConfig(),
disposables=(HTTPXClient(),),
),
routes=[Route("/status", status)],
)
Serve it with any ASGI server:
Server Context¶
ServerContext describes what a request context looks like. It is used by two parts of the
application, both wired by application(...):
- its
lifespanprepares the declared disposables on startup and releases them on shutdown ContextMiddlewareenters a context scope per request, carrying the prepared state
State comes from two places. Instances passed positionally are propagated as they are, which fits
configuration and other state owning no resources. Everything requiring setup or cleanup belongs in
disposables, prepared on startup and released on shutdown, whose state is propagated the same way:
context = ServerContext(
ServiceConfig(), # propagated as provided
disposables=( # prepared on startup, released on shutdown
HTTPXClient(),
PostgresConnectionPool(),
),
)
Both accept None among their elements and ignore it, which keeps a conditionally provided element
from requiring a branch. State declared directly takes precedence over state prepared by the
disposables when both provide the same type.
The disposables are the instances declared here, prepared once, so a single context backs a single
run of a single application: a lifespan which already ended cannot be entered again, and a test
exercising more than one run declares a context per run. Resources which have to be created within
the running event loop belong inside a disposable's __aenter__ rather than in its constructor.
The lifespan is what a request scope is built from, so it is not optional. Requests reaching an application whose lifespan was not installed - or arriving before its startup completed - fail rather than being served with a silently incomplete scope.
Existing Applications¶
An application which is already built is plugged in by installing the same two pieces by hand:
from haiway.starlette import ContextMiddleware, ServerContext
from starlette.applications import Starlette
context = ServerContext(disposables=(HTTPXClient(),))
app = Starlette(routes=[...], lifespan=context.lifespan)
app.add_middleware(ContextMiddleware, context=context)
add_middleware puts the middleware in front of the ones added before it, so adding it last keeps
it outermost - which is what makes the context available to the other middlewares as well. When the
application already has a lifespan of its own, compose the two with
context.composed_lifespan(existing_lifespan).
A middleware of the application still sits below its server error handling, which Starlette keeps
above every middleware it is given - so the 500 of an unhandled failure is produced outside the
scope of its request and carries none of its trace headers. Nesting that error handling below the
context middleware instead is what the factory does, and it takes a ServerErrorMiddleware of its
own:
from starlette.middleware import Middleware
from starlette.middleware.errors import ServerErrorMiddleware
app = Starlette(
routes=[...],
middleware=[
Middleware(ContextMiddleware, context=context),
# the `500` of an unhandled failure is produced here, within the scope of
# its request, so it carries the trace headers correlating it
Middleware(ServerErrorMiddleware, handler=handle_server_error, debug=False),
],
lifespan=context.lifespan,
)
The handler belongs in that middleware rather than in exception_handlers[Exception] - held in
both, it would be called twice for a single failure, and only the response of the first would be
sent. The empty slot left in the application answers what fails before a scope was entered.
FastAPI applications work the same way, and there is a factory building one directly - see FastAPI.
Request Handling¶
ContextMiddleware affects http and websocket requests, passing everything else - lifespan
included - through untouched. For each request it:
-
enters a context scope named after the request - the method and the requested path, as in
GET /users/12345, withWSin place of the method for a websocket connection, which carries none. The middleware runs before routing, so the route template behind that path is not available where the scope starts; it is recorded as an attribute once it is -
records the request into its scope as the HTTP semantic conventions of OpenTelemetry describe it, once it was handled and both the route it matched and the status it was answered with are known:
| attribute | value |
|---|---|
http.request.method |
the method as received, for an http request only |
http.route |
the route template, when the routing left one in the request scope |
url.path |
the requested path |
url.scheme |
the scheme of the request |
network.protocol.version |
the HTTP version |
http.response.status_code |
the status of the response, when one was started |
http.route is what makes the requests of a parameterized route findable as one, since the scope
name carries the path which was actually requested rather than the template behind it. FastAPI
leaves the matched route in the request scope, so a FastAPI application records it; Starlette does
not, so a plain Starlette application records none unless it reports one itself with
ctx.record_info(attributes={"http.route": ...}) from within the request. Resolving it in the
middleware would mean matching the route table a second time for every request. The query string
and the address of the caller are deliberately left out - the first carries credentials often
enough that recording it by default would leak them, the second identifies the caller
-
records
http.server.response.start.durationamong those same attributes when a response was started - the seconds its consumer waited for the headers of an answer, which the span holding the request does not report on its own. It stays on the trace rather than being aggregated -
records how long the request took as one histogram, in seconds:
http.server.request.duration, orwebsocket.server.durationfor awebsocketconnection, which is no HTTP request. Every request is measured, the ones which failed or were never answered included. It is kept by the bounded attributes of the request only:
| dimension | absent when |
|---|---|
http.request.method |
the request is a websocket connection, which has none |
http.route |
the routing left no route in the request scope |
http.response.status_code |
the request was never answered |
url.path is deliberately not among them - it carries whatever identifier a parameterized route
was called with, and one time series per value of it is how a metrics store is brought down. An
absent dimension is a series of its own, which is what keeps an unanswered request findable. The
names are exported from haiway.starlette.observability
-
adds the trace headers to the response:
trace-idholding the trace identifier of the request scope, accompanied bytraceparentandtracestatewhen the observability backend provides them. An entry which a header cannot hold - a line break, a character outside latin-1 - is left out rather than written, since the trace context is partly continued from the request. For a websocket request the response is the one denying its handshake - an accepted connection switches the protocol rather than answering, so it carries no headers of its own -
lets an exception no handler answered propagate through the scope of its request, which is what records it as the failure of that request - the exception among the details, so the trace carries its stack trace - then reraises it for the server to report. Answering it is left to the application: the server error handling of the framework produces the
500the client receives - the plain one, the traceback page of adebugapplication, or the response of a handler registered forExceptionor500. The factory nests that error handling within the scope of the request, so what it answers with carries the trace headers as well, which is what correlates a failed request with the trace recording why it failed
HTTPException, WebSocketException and ClientDisconnect are not a failure of the request they
end - the first two are how an application asks for a specific response, the third is a consumer
which went away. They are withheld while the scope of the request is left, so it does not record
them as its failure, then reraised for whatever handles them upstream.
Everything handling the request runs within its scope: the middlewares nested below, the endpoint, its background tasks and the generator of a streaming response.
Request Derived State¶
State which depends on the request - the identity of its caller, a tenant, a locale - is added by a middleware nested within the context:
from starlette.datastructures import Headers
from starlette.middleware import Middleware
from starlette.types import ASGIApp, Receive, Scope, Send
class Caller(State):
identifier: str
class CallerMiddleware:
def __init__(self, app: ASGIApp, /) -> None:
self.app: ASGIApp = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] not in ("http", "websocket"):
return await self.app(scope, receive, send)
# extends the state of the current request scope
with ctx.updating(Caller(identifier=Headers(scope=scope).get("x-caller", "anonymous"))):
await self.app(scope, receive, send)
app = application(
context,
routes=[...],
middleware=[Middleware(CallerMiddleware)],
)
Streaming Responses¶
The scope of a request stays entered until its response is finished, so a response streamed from an async generator resolves state, records observability and reports the trace of the request it belongs to while it is being produced - the middleware returns only once the last chunk was sent.
StreamResponse streams the chunks of a generator:
from collections.abc import AsyncGenerator
from haiway.starlette import StreamResponse
async def export(request: Request) -> Response:
async def rows() -> AsyncGenerator[bytes]:
async for row in Postgres.fetch_rows(QUERY): # state of the request
yield row.get_str("payload").encode() + b"\n"
return StreamResponse(rows(), media_type="application/x-ndjson")
Any live feed is streamed the same way - a server sent events feed by yielding the event stream format and declaring the headers such a feed needs:
async def updates(request: Request) -> Response:
async def events() -> AsyncGenerator[bytes]:
async for update in Updates.subscribe():
yield f"event: update\ndata: {update.payload}\n\n".encode()
return StreamResponse(
events(),
media_type="text/event-stream",
headers={
# a live feed is not cached, and not buffered by a reverse proxy
"cache-control": "no-store",
"x-accel-buffering": "no",
},
)
Closing a Stream¶
The response takes a full async generator, not any async iterable, and closes it where the streaming ends - when it ran out and when the consumer went away. That is what a generator holding resources needs: an abandoned generator is otherwise finalized by the garbage collector, in a fresh context, where a scope it opened can no longer be released.
Closing is what runs the cleanup of the generator, so it has to be able to await - releasing a connection or leaving a scope usually does. That requires a server advertising ASGI spec version 2.4 or newer, the one reporting a gone consumer by failing the send: below that version the framework ends a streamed response by cancelling it, and the cancellation would be delivered again at the first await of the cleanup, leaving the generator suspended halfway through it.
A generator opening a scope of its own has to keep it inside itself:
async def produce() -> AsyncGenerator[bytes]:
async with ctx.scope("updates", disposables=(Subscription(),)):
async for update in Updates.subscribe():
yield update.payload.encode()
async def updates(request: Request) -> Response:
# the scope lives inside the generator, so it spans the whole response
return StreamResponse(produce())
A scope entered around building the generator is already released by the time the streaming starts:
async def updates(request: Request) -> Response:
async with ctx.scope("updates", disposables=(Subscription(),)):
content = produce() # nothing was produced yet
return StreamResponse(content) # the subscription is already disposed
Calling produce() runs none of its body, so updates() leaves the scope - disposing the
subscription - before StreamResponse(content) asks for the first element. The scope kept inside
produce() is instead entered when the response streaming resumes the generator, and stays current
for the whole iteration - it is entered in the context which resumes the generator rather than one
the generator keeps to itself. That has no consumer to surprise in a response, but it does when such
a generator is iterated by your own code: see
Scopes Inside Generators.
A Failing Stream¶
A failing stream cannot be answered with an error - its response already started, and its status and
headers are long gone. The status and headers are also sent before the first element is asked for,
so this holds from the very first one: a producer which fails while setting itself up still produces
a 200 with nothing in it.
The failure is recorded where it happens, within the scope of the request, as
Response streaming failed carrying the exception - and only then reraised, which leaves the
response incomplete so the consumer can tell. Recording it there is what makes the actual failure
visible at all: on its way out it passes through the exception handling of the framework, which
replaces it with a RuntimeError about a response already started whenever a handler matches its
type, and that replacement is what the request scope would otherwise be recorded as failing with. A
failure to close the stream afterwards is recorded as a warning of its own rather than replacing
what is already on its way out.
The consumer sees a response which ends early. Where it has to know more than that, do the work which can fail before returning the response, and send a failure which happens later as part of the stream - a final event saying so - before letting it propagate.
A consumer which goes away mid-stream is not a failure of the response it ended, and is recorded as
Response streaming ended by a disconnected consumer at debug level rather than as an error. It
ends the request the same way whichever shape it arrives in - as a cancelled response below ASGI
spec version 2.4, and as a ClientDisconnect from the send above it - so a feed a consumer
eventually leaves does not turn every connection into a failed request. A gone consumer is the send
failing, which is what tells it apart from a body failing with an OSError of its own: the latter
is the failure of the response it was producing and is recorded as one, even though the framework
reports both to the server as a ClientDisconnect.
Observability¶
Request scopes are recorded through the observability backend of the server context. It accepts a
Logger, an Observability instance, or a callable preparing one per request:
A logging backend of its own is built out of that Logger for each request, so what one recorded is
released along with it: a single backend shared by the application would keep holding the scopes of
every request which never completed - an abandoned generator among them - for as long as it runs. An
Observability instance is used as provided, and a callable is invoked per request - it has to
answer with a backend, which LoggerObservability builds out of a logger - which is what continuing
an incoming trace requires.
Distributed Traces¶
The W3C trace context of every request is read from its headers and handed to that callable, as
traceparent and tracestate keyword arguments. OpenTelemetry.observability takes exactly those,
so continuing the trace of the caller is the whole wiring:
from haiway.opentelemetry import OpenTelemetry
context = ServerContext(observability=OpenTelemetry.observability)
Each request then joins the trace it arrives with, or starts its own when it arrives with none, and
its response reports back which trace handled it - trace-id, plus traceparent and tracestate
identifying the position within it. Those two are a request header format, so nothing consumes them
from a response on its own; they are there for a caller which correlates the two sides itself, next
to the trace-id a user reports.
The values are handed over as received, apart from the whitespace surrounding them, which a header carries without it being part of the value - an entry left empty by stripping it is reported as the absent one it is. Validating the rest is the responsibility of the backend, which the specification requires to reject a malformed value and start a new trace instead - what the OpenTelemetry integration does, recording a warning. Two cases are resolved before that, because a backend given a single value cannot see them:
- a request carrying several
traceparentheaders has no single position to continue, so its trace context is discarded and a new trace is started - only the first
tracestateheader is read, so a caller splitting a long trace state across several of them has to join them itself
An outgoing HTTP request carries the trace onwards when it asks to, which is what makes a call chain one trace end to end:
context = ServerContext(
observability=OpenTelemetry.observability,
disposables=(HTTPXClient(base_url=INTERNAL_URL),),
)
# within an endpoint - a request towards a service you own continues the trace
# of its caller
response = await HTTPClient.get(url="/users", trace_propagation=True)
It is off by default, and settable per request, because propagating hands internal trace identifiers to whoever is called - see Trace Propagation.
request_trace_context exposes the inbound reading on its own, for an application which needs the
trace context for something else - propagating it over a protocol Haiway does not handle, for
instance.
To pass a recording level, apply it ahead of time:
from functools import partial
from haiway import ObservabilityLevel
context = ServerContext(
observability=partial(OpenTelemetry.observability, ObservabilityLevel.DEBUG),
)
An Observability instance provided directly is used as it is, which records every request as its
own trace - continuing incoming traces needs the callable.
Configuring OpenTelemetry¶
OpenTelemetry.configure(...) has to run before the first request, which is what a disposable
expresses:
from collections.abc import Iterable
from haiway import State
from haiway.opentelemetry import OpenTelemetry
class Telemetry:
async def __aenter__(self) -> Iterable[State]:
if not OpenTelemetry.configured(): # claimed once per process
OpenTelemetry.configure(
service="api",
version="1.0.0",
environment="production",
otlp_endpoint="http://localhost:4317",
insecure=True,
)
return () # provides no state, only the configuration
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: object,
) -> None:
OpenTelemetry.force_flush() # exports what the application recorded
context = ServerContext(
observability=OpenTelemetry.observability,
disposables=(Telemetry(), HTTPXClient()),
)
Requests fail while the integration is unconfigured - preparing an observability backend without it raises - so the disposable belongs in the server context rather than somewhere later.
Note what the disposable does not do. The OpenTelemetry provider slots are process wide and
claimed once: configure(...) refuses a second call and shutdown() cannot be undone, while a
process can run more than one application - a test suite exercising several. Guarding on
configured() is what keeps the second of them from failing on startup, and flushing rather than
shutting down is what keeps it exporting. Shutting the providers down belongs at process exit, which
the SDK does on its own.
Startup and shutdown are outside of every request scope, so what the lifespan records - the context being prepared and released, and a disposable which failed to prepare - goes to the root logger rather than through this backend. There is no span for the startup of an application, and a failing disposable is reported where the logging of the application goes. Startup work which has to be traced belongs in a scope of its own, entered within an additional lifespan - see Startup Work.
When no observability is provided at all, request scopes are recorded through the root logger, so
they land wherever the logging of the application is configured to go. Providing one is still
preferable - getLogger("api"), or the OpenTelemetry factory - and gives the records a name to
filter by. Two things the default deliberately avoids: a logger named after the scope, which is what
ctx.scope would request and which allocates one per distinct request path, and a logger of our own
created on import, which setup_logging disables along with every other logger predating it.
Context Presets¶
Presets declared by the application context are available within request scopes, which lets an endpoint enter a nested scope by name:
from haiway import ContextPresets
context = ServerContext(
presets=(ContextPresets.of("summary", SummaryConfig(), disposables=(HTTPXClient,)),),
)
async def summarize(request: Request) -> Response:
async with ctx.scope("summary"): # resolved from the application presets
...
See Context Presets for how presets are composed.
Startup Work¶
Startup work owning a resource is best expressed as one of the disposables. Anything else - running
migrations, warming a cache - can be passed to application(...) as an additional lifespan, entered
within the lifespan of the context, so the disposables are already prepared when it runs:
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from haiway.postgres import Postgres, PostgresConnectionPool
from starlette.applications import Starlette
@asynccontextmanager
async def lifespan(app: Starlette) -> AsyncGenerator[None]:
async with ctx.scope("migrations", disposables=(PostgresConnectionPool(),)):
await Postgres.execute_migrations("example.migrations")
yield # suspend until shutdown
app = application(context, routes=[...], lifespan=lifespan)
Note the scope entered and exited before the yield. A scope held open across it would leak its
state into the context the server creates its request tasks in - preparing state for requests is
what the server context is for.
Startup runs outside of every context scope, the disposables of the context included: they are
prepared, but nothing entered a scope carrying their state, so ctx.state(...) resolves nothing
there. Startup work needing state enters a scope of its own, as above.
Testing¶
An application built this way is tested like any other ASGI application, entering its lifespan first
so the context is prepared. TestClient requires httpx2, installed with the httpx extra:
from starlette.testclient import TestClient
def build_application() -> Starlette: # a context per run, so an application per test
return application(
ServerContext(ServiceConfig(), disposables=(HTTPXClient(),)),
routes=[Route("/status", status)],
)
def test_status() -> None:
with TestClient(build_application()) as client: # enters the lifespan
response = client.get("/status")
assert response.status_code == 200
assert response.headers["trace-id"]
A context backs a single run, which is what the factory is for: each test builds its own context and application rather than sharing one module level application between tests which each enter its lifespan.
The lifespan of a ServerContext can also be entered directly, which is what a test exercising
state without an application needs - with a context of its own for the same reason:
async def test_upstream() -> None:
context = ServerContext(ServiceConfig(), disposables=(HTTPXClient(),))
async with context.lifespan():
...
Best Practices¶
- Declare every application resource in
disposables- shutdown then releases them in the same place, whatever went wrong - Declare a context per run - one instance backs one lifespan, so a test suite builds its application within each test rather than sharing one
- Keep the context middleware outermost so the rest of the application - middlewares included - runs within a scope
- Report the
trace-idof a failed response back to your users; it is what correlates their report with the recorded trace