FastAPI¶
A full-featured HTTP entrypoint: the platform middleware stack, RFC 9457 error handling, Kubernetes probes, per-request DI scopes and a graceful drain — on a FastAPI app you still fully control.
from fastapi import APIRouter
from servicewright import AppSpec, Service
from servicewright.adapters.fastapi import FastApiEntrypoint, HttpConfig
router = APIRouter()
@router.get("/orders/{order_id}")
async def get_order(order_id: str) -> dict:
return {"id": order_id}
http = FastApiEntrypoint(config=HttpConfig(port=8000), routers=(router,))
service = Service(spec, entrypoints=[http])
The /system namespace¶
Everything operational lives under one prefix, so it is trivial to keep off your public router:
| Path | What |
|---|---|
/system/health/livez |
liveness probe |
/system/health/readyz |
readiness probe (503 when unhealthy) |
/system/metrics |
in-app Prometheus metrics (when metrics=True) |
/system/docs |
Swagger UI |
/system/redoc |
ReDoc |
/system/openapi.json |
the schema |
These paths are excluded from request logging and from tracing by default.
Constructor¶
FastApiEntrypoint(
config=HttpConfig(),
routers=(),
routes_registerer=None,
middlewares=MiddlewareConfig(),
exception_handlers=None,
default_exception_handlers=True,
error_renderer=None,
metrics=False,
configure_app=None,
kind="http",
essential=True,
)
| Argument | Meaning |
|---|---|
config |
Server, app and health configuration. See below. |
routers |
APIRouter instances to include. |
routes_registerer |
(app, ctx) -> None \| Awaitable, called at bind time. For routes that need the ServiceContext. |
middlewares |
The middleware stack configuration. |
exception_handlers |
{ExceptionType: handler}, appended after the defaults. |
default_exception_handlers |
Install the built-in handlers. |
error_renderer |
Wire-format renderer; None means RFC 9457. See Errors. |
metrics |
Expose in-app request metrics at /system/metrics. |
configure_app |
(app, ctx) -> None, the final hook after everything is wired. |
kind, essential |
Telemetry label and failure semantics. See Entrypoints. |
HttpConfig¶
| Field | Default | Meaning |
|---|---|---|
host |
"0.0.0.0" |
Bind host |
port |
8000 |
Bind port (0 picks a free one; read it back from bound_port) |
graceful_timeout |
10.0 |
uvicorn's own shutdown timeout |
title |
None |
OpenAPI title; defaults to AppSpec.service_name |
version |
"0.0.0" |
OpenAPI version |
openapi_url |
/system/openapi.json |
Schema path |
docs_url |
/system/docs |
Swagger UI path |
redoc_url |
/system/redoc |
ReDoc path |
redirect_slashes |
False |
|
fastapi_kwargs |
{} |
Merged into the FastAPI(...) call |
uvicorn_kwargs |
{} |
Merged into the uvicorn.Config(...) call |
health |
HealthConfig() |
Probe routes: enabled, liveness_path, readiness_path |
Per-request dependency scope¶
UnitScopeMiddleware is installed automatically as the outermost middleware and opens one
unit scope per request. Reach it three ways:
from servicewright.adapters.fastapi import UnitScopeDep, current_unit_scope
# 1. The dependency (most common)
@router.get("/orders/{order_id}")
async def get_order(order_id: str, scope: UnitScopeDep) -> dict:
use_case = await scope.get(GetOrder)
return await use_case.execute(order_id)
# 2. Off the request
async def handler(request: Request):
scope = request.state.unit_scope
# 3. From anywhere in the call tree
def deep_inside_something():
scope = current_unit_scope()
Note
The middleware wraps every HTTP request, /system/* routes included, so a readiness probe
opens and immediately closes a scope too. With dishka that costs almost nothing — dependencies
are built on get, and a probe resolves none. It only matters if your container does eager
work on scope entry.
Why the scope survives streaming responses
The middleware is raw ASGI, not BaseHTTPMiddleware. The latter hands control back as soon as
the response starts, which would close the scope — and with it your database session —
while a streaming body is still being produced and before BackgroundTasks run. Awaiting the
inner app to completion keeps the scope alive for the whole exchange.
Letting your DI integration own the scope¶
If your DI library's own FastAPI integration already opens a request scope — dishka's
setup_dishka() with FromDishka / @inject handlers, for instance — switch the adapter's
middleware off so the two never open two scopes per request:
from dishka.integrations.fastapi import setup_dishka
from servicewright.adapters.fastapi import FastApiEntrypoint, MiddlewareConfig
def configure_app(app: FastAPI, ctx: ServiceContext) -> None:
setup_dishka(ctx.container.container, app) # dishka's ContainerMiddleware, outermost
http = FastApiEntrypoint(
routers=(router,),
middlewares=MiddlewareConfig(unit_scope=False),
configure_app=configure_app,
)
unit_scope=False drops UnitScopeMiddleware from the stack and nothing else: the context,
logging, error and Sentry layers, the probes and the graceful drain do not depend on it. In this
mode UnitScopeDep, request.state.unit_scope and current_unit_scope() raise LookupError —
resolve through the integration that owns the scope. See
dishka for the full picture,
including what happens if both end up installed.
Middleware stack¶
from servicewright.adapters.fastapi import (
CORSMiddlewareConfig,
GZipMiddlewareConfig,
LoggingMiddlewareConfig,
MiddlewareConfig,
)
middlewares = MiddlewareConfig(
unit_scope=True,
context=True,
sentry=True,
processing_time=True,
context_setters=None,
logging=LoggingMiddlewareConfig(enabled=True, ignored_paths=["/system/health/livez"]),
gzip=GZipMiddlewareConfig(enabled=True, minimum_size=1000),
cors=CORSMiddlewareConfig(allow_origins=["https://app.example.com"]),
custom=[(MyMiddleware, {"option": 1})],
)
Order, outermost first:
flowchart TD
REQ["request"] --> A["UnitScope"]
A --> B["context"]
B --> C["unhandled-error"]
C --> D["sentry"]
D --> E["processing-time"]
E --> F["logging"]
F --> G["gzip"]
G --> H["CORS"]
H --> I["your custom middleware"]
I --> J["handler"]
| Layer | Does |
|---|---|
| UnitScope | opens the per-request DI scope; everything below runs inside it. unit_scope=False leaves it out |
| context | extracts/mints correlation ids, binds them, echoes X-Request-ID |
| unhandled-error | renders a masked 500 inside the context layer, so it carries the request id |
| sentry | enriches the Sentry scope from the request context |
| processing-time | adds an X-Process-Time response header |
| logging | one structured line per request, through the configured logging sink |
| gzip, CORS | Starlette's own |
| custom | yours, innermost |
CORS defaults to no origins
CORSMiddlewareConfig starts with allow_origins=[], so nothing is allowed until you say so.
Combining allow_credentials=True with allow_origins=["*"] raises a ValueError at
construction — that combination is insecure and browsers reject it anyway.
The request id¶
ContextMiddleware owns it. It takes the client's X-Request-ID when the value is log-safe,
generates one otherwise, binds it into the context store and returns it
in the response header.
Change the header name, or stop echoing:
from servicewright.adapters.fastapi import CorrelationIdMiddlewareConfig
MiddlewareConfig(correlation_id=CorrelationIdMiddlewareConfig(header_name="X-Correlation-ID"))
MiddlewareConfig(correlation_id=CorrelationIdMiddlewareConfig(enabled=False)) # bind, do not echo
Error handling¶
Five handlers are installed by default, all rendering through one pluggable renderer:
| Situation | Result |
|---|---|
RequestValidationError |
422, code="validation_error", sanitized field errors |
HTTPException 4xx |
that status, detail and headers preserved |
HTTPException 5xx |
masked internal error |
ServiceError |
its kind's status; masked when public=False |
DeadlineExceededError |
504, code="deadline_exceeded" |
| anything unhandled | masked 500, logged with the request id |
FastApiEntrypoint(
error_renderer=MyEnvelopeRenderer(), # own the wire format
exception_handlers={LegacyError: my_handler}, # add your own types
default_exception_handlers=False, # or opt out entirely
)
Full details in Errors.
Metrics¶
Instruments the app with prometheus-fastapi-instrumentator and exposes /system/metrics on the
API's own port, with /system/* excluded from instrumentation.
This is independent of the standalone exposition server that the
Prometheus backend starts when
settings.metrics.enabled is true. Both write into the same registry.
To tune the instrumentator, wire it yourself:
from servicewright.adapters.fastapi import MetricsInstrumentatorConfig, setup_metrics_instrumentator
def configure_app(app, ctx) -> None:
setup_metrics_instrumentator(
app,
config=MetricsInstrumentatorConfig(init_kwargs={"should_group_status_codes": False}),
)
FastApiEntrypoint(metrics=False, configure_app=configure_app)
Tracing¶
With ObsConfig(tracing="otel") and a configured settings.tracing, the entrypoint asks the
tracing sink to instrument the app at bind time — before the middleware stack, so spans wrap the
whole chain. The /system/* paths and logging.ignored_paths are excluded automatically, on top
of anything in settings.tracing.excluded_urls.
With tracing off, this is a no-op. Nothing is imported.
Registering routes¶
Three ways, in the order they are applied:
# 1. Routers
FastApiEntrypoint(routers=(orders_router, users_router))
# 2. A registerer, when routes need the ServiceContext
async def register_routes(app: FastAPI, ctx: ServiceContext) -> None:
catalog = await ctx.app_scope.get(Catalog)
app.include_router(build_router(catalog))
FastApiEntrypoint(routes_registerer=register_routes)
# 3. The final hook, after everything else is wired
def configure_app(app: FastAPI, ctx: ServiceContext) -> None:
app.mount("/static", StaticFiles(directory="static"))
FastApiEntrypoint(configure_app=configure_app)
Lifecycle notes¶
The app has no lifespan that manages your container. The Host already ran bootstrap, the
application scope, warmup and pre_start before bind() is called.
bind()builds the app and opens the listening socket.serve()runs uvicorn with its signal capture neutralized, and returns — still accepting — when the Host's stop event is set.drain(grace)closes the listener and lets in-flight requests finish.stop()is the hard stop.
http = FastApiEntrypoint(config=HttpConfig(port=0))
# after bind:
http.bound_port # e.g. 54312
http.app # the built FastAPI instance, or None before bind
Testing¶
Build the app without running a server:
from servicewright.testing import FakeContainer, FakeSettings
ctx = ServiceContext(bootstrap=..., app_scope=..., health=HealthRegistry())
app = await FastApiEntrypoint(routers=(router,)).build_app(ctx)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/orders/42")
See Testing for the full setup.
Handy header types¶
Common headers as ready-made annotated dependencies:
from servicewright.adapters.fastapi import (
AuthorizationHeader, # Authorization
IdempotencyKey, # Idempotency-Key, validated and length-capped
XFingerprintHeader, # X-Fingerprint
XUserId, # X-User-Id, parsed as UUID
)
@router.post("/orders")
async def create_order(user_id: XUserId, idempotency_key: IdempotencyKey) -> dict: ...