Errors and observability¶
Errors that say what went wrong without leaking internals, and what tells you how the app is doing: request IDs, deadlines, health checks, metrics and traces, and logs as JSON.
In an app: the example. Every class and function: the API reference.
Problem details¶
FastAPIProblemDetails answers errors with RFC 9457 problem details
(application/problem+json) instead of FastAPI's {"detail": ...}, and without telling clients
what they shouldn't know:
from fastapi_alcazaba.observability import FastAPIProblemDetails, Problem
problems = FastAPIProblemDetails()
problems.install(app)
@app.post("/orders")
async def order(item: Item) -> Order:
if stock[item.id] < item.quantity:
raise Problem(
409,
f"Only {stock[item.id]} left",
type="https://example.com/problems/out-of-stock",
title="Out of stock",
extensions={"available": stock[item.id]},
)
...
{"type": "https://example.com/problems/out-of-stock", "title": "Out of stock", "status": 409,
"detail": "Only 3 left", "instance": "/orders", "available": 3, "request_id": "4bf92f35…"}
- Members:
type(about:blankunless aProblemsays otherwise),title(the RFC 9110 status phrase),status,detailwhen there's something to tell,instance(the request's path, percent-encoded) andrequest_idwhenRequestIDMiddlewaregave the request one. - HTTPExceptions: FastAPI's, Starlette's and their subclasses (
BreakerOpen, the rate limiter's 429…) keep their headers:Retry-After,WWW-Authenticate,Allow. A 4xx's detail is sent, since it's written for clients (expose_detail=Falsekeeps it); a 5xx's never is: anHTTPException(500, str(error))becomes a bare 500, and the hidden detail is logged as a warning. A detail that isn't a string goes in anerrorsmember. Codes under 400 (a redirect raised as an exception) keep FastAPI's answer. - Validation errors: a 422 whose
errorslist each error'sloc,msgandtype, never itsinput(FastAPI echoes what was sent, passwords included) nor itsctx. At mostmax_errors=100, so that a huge invalid body doesn't get a huge answer. - Unhandled exceptions: a 500 that says nothing about them. Starlette raises the exception
again after the response, so the server logs it with its traceback (uvicorn's "Exception in
ASGI application", with the request ID when you use
RequestIDLogFilter) andTestClientraises it in tests; it isn't logged a second time. Withdebug=TrueStarlette shows its traceback page instead. - Problem: an
HTTPExceptionwith atypeURI, atitleand extension members, whose names are checked (three or more letters, digits or_, starting with a letter; never a standard member) and whose values are encoded when it's raised. Its detail is always sent, also with a 5xx: you wrote it for the client.problems.response(request, Problem(...))answers one from your own exception handlers. - Alcazaba's middlewares too: what they answer themselves (the 504 of a timeout, the 413 of a
body too large, the 503 of maintenance, the 403 of CSRF, the idempotency errors) is a problem
as well, headers such as
Retry-Afterkept, once it's installed on the app. - Handlers you register for a status code or an exception subclass still win; install it before the app starts.
Request IDs¶
RequestIDMiddleware gives every request an ID, to find all the log lines of one request and to
let a client quote it when it reports an error:
import logging
from fastapi_alcazaba.observability import RequestIDLogFilter, RequestIDMiddleware, request_id
app.add_middleware(RequestIDMiddleware) # last: the outermost
handler = logging.StreamHandler()
handler.addFilter(RequestIDLogFilter())
handler.setFormatter(logging.Formatter("%(asctime)s [%(request_id)s] %(name)s: %(message)s"))
logging.getLogger().addHandler(handler)
@app.post("/jobs")
async def create_job() -> dict[str, str | None]:
return {"ticket": request_id()}
With uvicorn, add the filter to its handlers too ("filters": {"request_id": {"()":
"fastapi_alcazaba.observability.RequestIDLogFilter"}} in its log_config), and its access and
error lines carry the ID.
- Incoming IDs: the request's
X-Request-ID(header=for another) is reused when a proxy or the client set one that's safe to log: a single value matching[A-Za-z0-9._:-]{1,128}. Anything else (line breaks that would forge log lines, an endless value, several values) gets a new random one, 128 bits in hex.trust_incoming=Falsealways makes a new one. - Everywhere in the request:
request_id()returns it in endpoints, dependencies, sync endpoints' threads and WebSockets; None outside a request. The response carries it in the same header (unless the app set that header itself), and so do WebSocket handshakes. - In errors: after an unhandled exception the ID stays set for the rest of the request's
task, so Starlette's 500 handler and the server's error log, which run outside every
middleware, still know which request failed.
FastAPIProblemDetailsputs it in every problem, 500s included, and in their header. - In logs:
RequestIDLogFiltersetsrecord.request_id(default="-"outside a request; arequest_idpassed inextra=is kept). Add it to handlers: a logger's filters don't see the records of its child loggers. - Add it last, the outermost, so that the answers of the other middlewares (a 413, a 504) carry the ID too.
Request timeouts¶
RequestTimeoutMiddleware gives every request a deadline, so that a hung dependency or a client
that trickles its body doesn't hold a request (and its database connection, its memory…)
forever:
from fastapi_alcazaba.observability import RequestTimeoutMiddleware
app.add_middleware(
RequestTimeoutMiddleware,
timeout=30,
paths={"/reports": 120, "/events": None}, # Server-Sent Events: no deadline
)
- Before the response starts: at the deadline the app is cancelled, its
finallyblocks and the exit of itsyielddependencies run, and the client gets a 504 ({"detail": "Request timed out"}, or a problem withFastAPIProblemDetails). Reading the request body counts: a client that sends it a byte at a time runs out of time too. - While a response streams: it's too late for a 504. The app is cancelled all the same and
the TimeoutError raised again, so the server closes the connection: the client sees an
incomplete body (an unfinished chunked read), never a short one that looks complete. Give
streaming endpoints their own deadline in
paths, or None. - After the response: once its last byte is sent the deadline is off, so background tasks run as long as they need.
- Sync endpoints run in a thread that can't be interrupted: the client gets its 504 on time,
but the thread runs to its end, after the exit of its
yielddependencies has run. Endpoints that may hit the deadline are better async. - Paths:
pathssets other deadlines by path prefix (the longest wins, None for none), matched like the routes, without theroot_path;timeout=Noneleaves the other paths without one. The app's own TimeoutErrors go through untouched, and WebSockets and lifespan events too. - Both cases are logged as warnings with the method and the route template. A plain ASGI middleware: streaming responses still stream.
Health checks¶
health_router() adds the two endpoints that load balancers and orchestrators probe:
from fastapi_alcazaba.observability import HealthCheck, health_router
async def database() -> None:
async with engine.connect() as connection:
await connection.execute(text("SELECT 1"))
app.include_router(
health_router({"database": database, "search": HealthCheck(ping_search, critical=False)})
)
Point the liveness probe at /health/live and the readiness probe (or the load balancer's
health check, e.g. the target group of an ECS service) at /health/ready.
- Live vs ready:
/health/liveonly says that the process answers./health/readyruns the checks concurrently, each with a timeout (timeout=2.0, or the check's own), and answers 200 (ok, ordegradedwhen only non-critical checks fail) or 503 (fail) with each check's status and duration. Keep dependencies out of liveness: a probe that checks the database gets healthy containers restarted every time it blips. A check passes when it returns anything butFalsewithout raising. Sync checks run in a daemon thread of their own, off the event loop, so one that hangs holds up neither the shutdown nor the threads of the loop's default executor; it isn't started again, under the same name in the same router, until its thread finishes. - The cache is checked, but isn't critical: the cache's backend (
cache=, else the app's orFastAPICache's) is asked for a key under its prefix, straight, not through the circuit breaker. The cache fails open, so its outage makes the appdegraded, not unready: taking every instance out of the load balancer would turn a Redis outage into a full one.include_cache=Falseleaves it out. - No leaked errors: failures are logged with their traceback and never returned: a public probe mustn't tell the world the database's hostname or connection string.
- Probes don't pile up: concurrent
/readyrequests share one run of the checks, and its result answers the next ones forreuse_for=1.0seconds, so a flood of probes doesn't flood the database. A probe that goes away doesn't cancel the run the others are waiting for. - The endpoints are left out of the OpenAPI schema unless
include_in_schema=True; responses carryCache-Control: no-store.
Metrics and traces¶
Alcazaba logs under fastapi_alcazaba.* with a NullHandler, as libraries do: its warnings (a
breaker that opens, maintenance turned on, a backend that fails) show once the app configures
logging, e.g. logging.basicConfig(level=logging.INFO). uvicorn alone only configures its own
loggers.
Alcazaba only uses the OpenTelemetry API (the otel extra): it records nothing until the app
configures an SDK, and never configures one itself. The simplest way is OpenTelemetry's own
zero-code instrumentation, with the standard variables:
pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a install # instrumentations for FastAPI, httpx2, redis, SQLAlchemy…
OTEL_SERVICE_NAME=shop-api \
OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=production \
OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.internal:4317 \
OTEL_PYTHON_LOG_CORRELATION=true \
OTEL_PYTHON_FASTAPI_EXCLUDED_URLS="health/live,health/ready" \
opentelemetry-instrument uvicorn app:app
Give the collector's endpoint explicitly, over TLS: traces and logs carry what your app does. Then Alcazaba adds to them:
- Spans: one per cached call (
fastapi_alcazaba <function>, withfastapi_alcazaba.result: hit, miss, refresh or bypass) and, inside it, one per backend call (fastapi_alcazaba.backend get,set,lock…, with failures recorded); instrumented Redis/Valkey clients nest their own inside. Each run of a scheduled job is the root of a trace of its own (fastapi_alcazaba.scheduler <job>, failures recorded), so what the job does (queries, calls) is grouped under it instead of scattered. So is each run of a task (fastapi_alcazaba.task <task>, with its queue, ID and attempt), linked to the span that enqueued it. - On the request's span: the events
fastapi_alcazaba.rate_limit(a request limited or unchecked),fastapi_alcazaba.breaker.refused,.openedand.closed,fastapi_alcazaba.outbound.refused(an outbound call to an origin whose breaker is open) andfastapi_alcazaba.csrf.refused, and the attributefastapi_alcazaba.idempotencywith the outcome of an Idempotency-Key. - Never: keys, tokens, identities, codes or request bodies. Attributes are names, routes and outcomes, few enough to keep the cardinality of the metrics low.
| Metric | Attributes |
|---|---|
fastapi_alcazaba.lookups |
fastapi_alcazaba.result (hit, miss, refresh, bypass), fastapi_alcazaba.namespace |
fastapi_alcazaba.stores |
fastapi_alcazaba.outcome (stored, uncacheable, too_large, unencodable, failed), fastapi_alcazaba.namespace |
fastapi_alcazaba.entry.size |
fastapi_alcazaba.namespace |
fastapi_alcazaba.backend.duration |
fastapi_alcazaba.operation (get, set, delete, incr, lock, unlock, extend_lock, tag, incr_window) |
fastapi_alcazaba.backend.errors |
fastapi_alcazaba.operation, error.type |
fastapi_alcazaba.rate_limit |
fastapi_alcazaba.outcome (allowed, limited, unchecked), fastapi_alcazaba.limiter (its name, or the route) |
fastapi_alcazaba.breaker.calls |
fastapi_alcazaba.outcome (success, failure, refused), fastapi_alcazaba.breaker |
fastapi_alcazaba.breaker.transitions |
fastapi_alcazaba.state (open, closed), fastapi_alcazaba.breaker |
fastapi_alcazaba.scheduler.runs |
fastapi_alcazaba.outcome (ran, failed, skipped, overlapping, unchecked), fastapi_alcazaba.job |
fastapi_alcazaba.idempotency |
fastapi_alcazaba.outcome (stored, replayed, conflict, mismatch, not_kept, invalid, too_large, unchecked) |
fastapi_alcazaba.tokens.checks |
fastapi_alcazaba.outcome (valid, revoked, unchecked) |
fastapi_alcazaba.webauthn |
fastapi_alcazaba.ceremony (register, authenticate), fastapi_alcazaba.outcome (success, challenge, origin, rp_id, signature, counter…): a burst of origin or rp_id is a phishing page |
fastapi_alcazaba.totp.verifications |
fastapi_alcazaba.outcome (success, wrong, replayed, malformed, locked, unchecked): a burst of wrong or locked is someone guessing |
fastapi_alcazaba.totp.recovery |
fastapi_alcazaba.outcome (used, wrong, replayed, locked, unchecked) |
fastapi_alcazaba.otp.verifications |
fastapi_alcazaba.outcome (success, wrong, missing, exhausted, malformed, unchecked): a burst of wrong or exhausted is someone guessing |
fastapi_alcazaba.lock.wait |
fastapi_alcazaba.acquired (true, false): seconds spent acquiring locks |
fastapi_alcazaba.lock.lost |
locks whose renewals couldn't keep up |
fastapi_alcazaba.health.checks |
fastapi_alcazaba.check, fastapi_alcazaba.status (ok, fail) |
fastapi_alcazaba.health.check.duration |
fastapi_alcazaba.check |
fastapi_alcazaba.concurrency |
fastapi_alcazaba.outcome (acquired, local: counted in the process while the backend failed, refused, unavailable), fastapi_alcazaba.limiter |
fastapi_alcazaba.leader.transitions |
fastapi_alcazaba.leader, fastapi_alcazaba.event (elected, lost, resigned) |
fastapi_alcazaba.maintenance.refused |
requests refused during maintenance |
fastapi_alcazaba.flags.evaluations |
fastapi_alcazaba.flag, fastapi_alcazaba.result (on, off, default) |
fastapi_alcazaba.problems |
fastapi_alcazaba.status, fastapi_alcazaba.kind (http_exception, problem, validation, unhandled) |
fastapi_alcazaba.request_timeouts |
fastapi_alcazaba.outcome (504, cut: already streaming), fastapi_alcazaba.route |
fastapi_alcazaba.request_ids |
fastapi_alcazaba.source (incoming, generated, rejected): many rejected may be a misconfigured proxy, or someone forging log lines |
fastapi_alcazaba.outbound.breaker |
fastapi_alcazaba.event (opened, refused), server.address |
fastapi_alcazaba.outbound.ssrf.blocked |
fastapi_alcazaba.reason (scheme, host, port, address) |
fastapi_alcazaba.webhooks |
fastapi_alcazaba.outcome (accepted, replayed, rejected, malformed, too_large, unchecked, unavailable) |
fastapi_alcazaba.api_keys |
fastapi_alcazaba.outcome (accepted, malformed, unknown, wrong_secret, expired, insufficient_scope) |
fastapi_alcazaba.auth |
fastapi_alcazaba.strategy, fastapi_alcazaba.outcome (success, failure: credentials there but refused, unauthenticated: no strategy found a user) |
fastapi_alcazaba.auth.unkey |
fastapi_alcazaba.strategy, fastapi_alcazaba.code (Unkey's: VALID, NOT_FOUND, RATE_LIMITED, USAGE_EXCEEDED…; unavailable): a burst of NOT_FOUND is someone guessing keys |
fastapi_alcazaba.authz |
fastapi_alcazaba.outcome (allowed, denied, unauthenticated, unavailable): a burst of denied is someone probing, of unavailable a policy service that is down |
fastapi_alcazaba.csrf.refused |
fastapi_alcazaba.kind (http, websocket), fastapi_alcazaba.reason (fetch_site, origin, no_source, token) |
fastapi_alcazaba.sessions |
fastapi_alcazaba.event (created, updated, rotated, deleted, touched, rejected: forged or altered, unreadable, unavailable) |
fastapi_alcazaba.uploads |
fastapi_alcazaba.outcome (accepted, too_large, too_many_pixels, unsupported, unreadable, empty) |
fastapi_alcazaba.uploads.size |
fastapi_alcazaba.content_type (the detected one): bytes of the uploads accepted |
fastapi_alcazaba.tasks.enqueued |
fastapi_alcazaba.queue, fastapi_alcazaba.task |
fastapi_alcazaba.tasks |
fastapi_alcazaba.queue, fastapi_alcazaba.task, fastapi_alcazaba.outcome (succeeded, retried, failed, interrupted; for refused tasks malformed, tampered, too_large, unknown_task, invalid_arguments, abandoned, with the task only when it's registered) |
fastapi_alcazaba.tasks.duration |
fastapi_alcazaba.queue, fastapi_alcazaba.task, fastapi_alcazaba.outcome |
fastapi_alcazaba.tasks.reclaimed |
fastapi_alcazaba.queue: tasks taken over from workers that stopped answering |
fastapi_alcazaba.passwords |
fastapi_alcazaba.outcome (success, wrong, locked, unknown, malformed, too_long, unchecked; reset_used, reset_unknown, reset_replaced, reset_malformed, reset_unchecked): a burst of wrong or locked is someone guessing |
fastapi_alcazaba.mail |
fastapi_alcazaba.outcome (sent, failed) |
fastapi_alcazaba.accounting |
fastapi_alcazaba.meter, fastapi_alcazaba.outcome (allowed, exceeded, unauthenticated, unchecked, refunded) |
fastapi_alcazaba.accounting.usage |
fastapi_alcazaba.meter: the units charged |
fastapi_alcazaba.accounting.undelivered |
fastapi_alcazaba.sink, fastapi_alcazaba.reason (error, timeout): usage records a sink didn't write |
fastapi_alcazaba.audit |
fastapi_alcazaba.action, fastapi_alcazaba.outcome (success, failure, denied): a burst of denied is someone probing |
fastapi_alcazaba.audit.undelivered |
fastapi_alcazaba.sink, fastapi_alcazaba.reason (error, timeout): events a sink didn't write |
Structured logs¶
Experimental
New in 0.7: its API may still change in any release.
One line of JSON per record, for the log pipeline to search (CloudWatch, Loki, Elasticsearch, a
SIEM): logging_config sends every record, Uvicorn's included, to standard output through
JSONFormatter.
import uvicorn
from fastapi_alcazaba.observability import logging_config
uvicorn.run("app:app", log_config=logging_config(static={"service": "shop"}))
# or logging.config.dictConfig(logging_config(...)) at start, with uvicorn's --log-config
logger.info("Order placed", extra={"order_id": 42, "card_token": token})
{"time":"2026-11-02T09:14:03.512+00:00","level":"INFO","logger":"shop.orders","service":"shop","message":"Order placed","request_id":"5f0c…","trace_id":"4bf9…","span_id":"00f0…","extra":{"order_id":42,"card_token":"[redacted]"}}
- What each line has: the time (UTC, to the millisecond), level, logger and message; the
staticfields; the ID of the request being handled (request IDs) and its OpenTelemetry trace and span, so a line leads to its request and its trace; the exception's type, message and the last 30 frames of its stack. extra=is redacted as the audit trail redacts details: the values of keys that name a secret (token,password,secret,authorization...;redactadds words) and strings that look like credentials become[redacted], and every value is bounded.- Uvicorn's access lines have the method, the status, the client and the path without its
query, which may carry tokens, codes or e-mail addresses.
access_log=Falsedrops them, when the proxy in front logs requests already. - One line per record, whatever the message holds: line breaks in it are escaped, so nobody can forge a line by putting one in a field that ends up logged.