from __future__ import annotations
import asyncio
import contextlib
import dataclasses
import inspect
import logging
import re
import sys
import traceback
import urllib.parse
import weakref
from collections import defaultdict
from collections.abc import (
AsyncGenerator,
AsyncIterable,
Callable,
Iterable,
Iterator,
Mapping,
)
from typing import TYPE_CHECKING, Any, Union, cast
if TYPE_CHECKING:
from http.cookies import Morsel
__all__ = [
"Route",
"WebSocketRoute",
"Router",
"DependencyError",
"DependencyCycleError",
"DependencyScopeError",
"DependencyResolutionError",
"RouteNotFoundError",
]
from starlette.concurrency import run_in_threadpool
from starlette.exceptions import HTTPException
from starlette.responses import JSONResponse
from starlette.responses import Response as StarletteResponse
from starlette.types import ASGIApp, Receive, Scope, Send
from starlette.websockets import (
WebSocket,
WebSocketClose,
WebSocketDisconnect,
WebSocketState,
)
from . import status_codes
from .contracts import (
inferred_response_model,
response_body_kind,
response_status_allows_body,
response_type_adapter,
)
from .errors import (
INTERNAL_SERVER_ERROR,
PROBLEM_JSON,
legacy_error_payload,
problem_bytes_for,
problem_payload_for,
)
from .formats import get_formats
from .models import Request, Response, _format_sse_event, _sse_with_heartbeat
from .params import _Depends
from .streaming import SSE
logger = logging.getLogger("responder")
_STREAM_END = object()
_STREAM_PENDING = object()
def _next_stream_item(iterator: Iterator[Any]) -> tuple[bool, Any]:
"""Read one sync iterator item without leaking StopIteration into asyncio."""
try:
return False, next(iterator)
except StopIteration:
return True, None
async def _iterate_stream_source(source: Any) -> AsyncGenerator[Any, None]:
"""Adapt sync and async iterables to one cancellation-safe async stream."""
if isinstance(source, AsyncIterable):
iterator = source.__aiter__()
try:
async for item in iterator:
yield item
finally:
close = getattr(iterator, "aclose", None)
if close is not None:
await close()
return
iterator = iter(source)
try:
while True:
done, item = await run_in_threadpool(_next_stream_item, iterator)
if done:
return
yield item
finally:
close = getattr(iterator, "close", None)
if close is not None:
await run_in_threadpool(close)
async def _close_stream_source(source: Any) -> None:
"""Close a not-yet-consumed stream source without starting it."""
close = getattr(source, "aclose", None)
if close is not None:
await close()
return
close = getattr(source, "close", None)
if close is not None:
await run_in_threadpool(close)
[docs]
class DependencyError(Exception):
"""Base class for dependency-injection configuration/resolution errors."""
[docs]
class DependencyCycleError(DependencyError):
"""A dependency depends on itself (directly or transitively)."""
[docs]
class DependencyScopeError(DependencyError):
"""An app-scoped dependency illegally depends on the request or a
request-scoped dependency."""
[docs]
class DependencyResolutionError(DependencyError):
"""A dependency parameter is neither the request nor a registered
dependency."""
class RouteNotFoundError(LookupError):
"""``url_for`` was asked to reverse an endpoint or route name that no
registered route matches."""
_UUID_RE = r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
_CONVERTORS = {
"int": (int, r"\d+"),
"str": (str, r"[^/]+"),
"float": (float, r"\d+(?:\.\d+)?"),
"path": (str, r".+"),
"uuid": (str, _UUID_RE),
}
PARAM_RE = re.compile("{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?}")
_KNOWN_HTTP_METHODS = frozenset(
{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", "CONNECT"}
)
def _class_view_methods(endpoint: Any) -> set[str]:
"""HTTP methods a class-based view instance implements via ``on_*`` handlers,
plus implicit HEAD-for-GET and OPTIONS.
OPTIONS is always included: when a class defines no ``on_options`` handler
(and no ``on_request``), CBV dispatch answers OPTIONS automatically with
``200`` + ``Allow`` — the same treatment method-restricted function routes
get at the router level — so advertising it is accurate.
"""
methods = {
name[3:].upper()
for name in dir(endpoint)
if name.startswith("on_")
and name[3:].upper() in _KNOWN_HTTP_METHODS
and callable(getattr(endpoint, name, None))
}
if "GET" in methods:
methods.add("HEAD")
methods.add("OPTIONS")
return methods
def _auto_options_view(allow: str) -> Callable:
"""A synthetic view answering ``OPTIONS`` with ``200`` + ``Allow`` for a
class-based view that defines neither ``on_options`` nor ``on_request``,
mirroring the router-level automatic OPTIONS response for
method-restricted function routes."""
def options_view(req: Request, resp: Response, **kwargs: Any) -> None:
resp.status_code = status_codes.HTTP_200
resp.headers["Allow"] = allow
resp.content = b""
return options_view
# Headers that frame a specific body; a replacement response builds its own
# (via ``Response.body``), so stale ones from the abandoned body must not leak.
_BODY_FRAMING_HEADERS = frozenset(
{"content-type", "content-length", "content-range", "transfer-encoding"}
)
def _copy_response_metadata(source: Response, target: Response) -> None:
"""Copy user-set headers and cookies from ``source`` onto ``target``.
Used when a timed-out request's response is rebuilt from scratch: metadata
that completed before the view started (request-ID headers, CORS, cookies
from before_request hooks) carries over, while headers framing the old
body do not — ``Response`` keeps body-derived headers out of ``.headers``
(they are computed from the body at render time), so the dict is copied
wholesale minus the framing names a view may have set explicitly (e.g.
``resp.file()``'s Content-Length). Reads are a single snapshot: the
abandoned view thread may keep mutating ``source`` afterwards, but
``target`` stays isolated.
"""
for key, value in list(source.headers.items()):
if key.lower() not in _BODY_FRAMING_HEADERS:
target.headers[key] = value
for key, morsel in list(source.cookies.items()):
# Morsel.copy() returns a Morsel at runtime; typeshed types it as
# the inherited dict.copy, hence the cast.
target.cookies[key] = cast("Morsel[str]", morsel.copy())
def compile_path(path: str) -> tuple[re.Pattern, dict[str, type], dict[str, str]]:
path_re = "^"
param_convertors: dict[str, type] = {}
param_convertor_names: dict[str, str] = {}
idx = 0
for match in PARAM_RE.finditer(path):
param_name, convertor_type = match.groups(default="str")
convertor_type = convertor_type.lstrip(":")
if convertor_type not in _CONVERTORS:
raise ValueError(
f"Unknown path convertor {convertor_type!r} in route {path!r}. "
f"Available convertors: {', '.join(sorted(_CONVERTORS))}."
)
if param_name in param_convertors:
raise ValueError(
f"Duplicate path parameter {param_name!r} in route {path!r}."
)
convertor, convertor_re = _CONVERTORS[convertor_type]
path_re += re.escape(path[idx : match.start()])
path_re += rf"(?P<{param_name}>{convertor_re})"
param_convertors[param_name] = convertor
param_convertor_names[param_name] = convertor_type
idx = match.end()
path_re += re.escape(path[idx:]) + "$"
return re.compile(path_re), param_convertors, param_convertor_names
_VIEW_PARAM_CACHE: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
def _view_param_names(view: Callable, skip: int = 2) -> tuple[str, ...]:
"""Return the names of a view's parameters beyond the first ``skip``
(``req, resp`` for HTTP views, ``ws`` for WebSocket handlers).
Results are cached per underlying function, since signature inspection
is comparatively expensive and views never change shape at runtime.
"""
cache_key = getattr(view, "__func__", view)
try:
return _VIEW_PARAM_CACHE[cache_key][skip:]
except (KeyError, TypeError):
pass
try:
parameters = inspect.signature(view).parameters
except (TypeError, ValueError):
return ()
names = []
for param in parameters.values():
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
continue
names.append(param.name)
result = tuple(names)
try:
_VIEW_PARAM_CACHE[cache_key] = result
except TypeError:
pass
return result[skip:]
_VIEW_HINTS_CACHE: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
_VIEW_RETURN_HINT_CACHE: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
def _view_type_hints(view: Callable) -> dict:
"""Resolved type hints for a view, cached per underlying function.
Returns an empty dict if hints can't be resolved (e.g. an unresolvable
forward reference), so type-hint features degrade gracefully.
"""
cache_key = getattr(view, "__func__", view)
try:
return _VIEW_HINTS_CACHE[cache_key]
except (KeyError, TypeError):
pass
try:
import typing
hints = typing.get_type_hints(view)
except Exception:
hints = {}
try:
_VIEW_HINTS_CACHE[cache_key] = hints
except TypeError:
pass
return hints
def _view_return_hint(view: Callable) -> Any:
"""Resolved return hint with ``Annotated`` metadata preserved."""
cache_key = getattr(view, "__func__", view)
try:
return _VIEW_RETURN_HINT_CACHE[cache_key]
except (KeyError, TypeError):
pass
try:
import typing
hint = typing.get_type_hints(view, include_extras=True).get("return")
except Exception:
hint = _view_type_hints(view).get("return")
try:
_VIEW_RETURN_HINT_CACHE[cache_key] = hint
except TypeError:
pass
return hint
def _is_pydantic_model(tp: Any) -> bool:
"""Whether ``tp`` is a Pydantic ``BaseModel`` subclass (duck-typed)."""
return (
isinstance(tp, type)
and hasattr(tp, "model_validate")
and hasattr(tp, "model_fields")
)
_REQUEST_TYPES = (Request, WebSocket)
_HTTP_REQUEST_NAMES = frozenset({"req", "request"})
_WS_REQUEST_NAMES = frozenset({"ws", "websocket", "req", "request"})
_RESERVED_DEP_NAMES = frozenset({"req", "request", "resp", "response", "ws", "websocket"})
_AUTH_INJECTION_NAMES = frozenset({"auth", "principal", "user"})
_DEP_PARAMS_CACHE: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
def _dep_param_specs(provider: Callable) -> tuple[tuple[str, Any], ...]:
"""Cached ``(name, annotation)`` specs for a provider's injectable params."""
key = getattr(provider, "__func__", provider)
try:
return _DEP_PARAMS_CACHE[key]
except (KeyError, TypeError):
pass
try:
params = inspect.signature(provider).parameters
except (TypeError, ValueError):
specs: tuple[tuple[str, Any], ...] = ()
else:
hints = _view_type_hints(provider)
specs = tuple(
(n, hints.get(n))
for n, p in params.items()
if p.kind not in (p.VAR_POSITIONAL, p.VAR_KEYWORD)
)
try:
_DEP_PARAMS_CACHE[key] = specs
except TypeError:
pass
return specs
_DEPENDS_PARAMS_CACHE: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
def _depends_params(view: Callable) -> dict[str, _Depends]:
"""Cached ``{param_name: Depends(...)}`` for a view/provider's inline
``Depends`` defaults. Signature inspection is comparatively expensive and
runs on the per-request path."""
key = getattr(view, "__func__", view)
try:
return _DEPENDS_PARAMS_CACHE[key]
except (KeyError, TypeError):
pass
try:
params = inspect.signature(view).parameters
except (TypeError, ValueError):
result: dict[str, _Depends] = {}
else:
result = {
name: param.default
for name, param in params.items()
if isinstance(param.default, _Depends)
}
try:
_DEPENDS_PARAMS_CACHE[key] = result
except TypeError:
pass
return result
_BODY_MODEL_CANDIDATES_CACHE: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
def _body_model_candidates(endpoint: Callable) -> tuple[tuple[str, Any], ...]:
"""Cached ``(name, model)`` pairs for an endpoint's Pydantic-model body
parameters that have no default. Works for function views and (bound) CBV
methods alike — the cache keys on ``__func__``, so per-request bound
methods memoize correctly. Marker-driven parameters (e.g. an
``Annotated[Model, Form()]`` form model) are excluded; they are resolved
by the marker pipeline instead. The per-request path/dependency/auth
filtering is applied by the caller; only the signature/hint inspection is
memoized here (it runs on every write request otherwise)."""
key = getattr(endpoint, "__func__", endpoint)
try:
return _BODY_MODEL_CANDIDATES_CACHE[key]
except (KeyError, TypeError):
pass
from .params import marker_params
hints = _view_type_hints(endpoint)
marker_names = {spec.name for spec in marker_params(endpoint, hints)}
try:
sig_params: Any = inspect.signature(endpoint).parameters
except (TypeError, ValueError):
sig_params = {}
candidates = tuple(
(name, hints[name])
for name in _view_param_names(endpoint)
if _is_pydantic_model(hints.get(name))
and name not in marker_names
and (
name not in sig_params or sig_params[name].default is inspect.Parameter.empty
)
)
try:
_BODY_MODEL_CANDIDATES_CACHE[key] = candidates
except TypeError:
pass
return candidates
def _is_wsgi_app(app: Any) -> bool:
"""Whether ``app`` looks like a WSGI (rather than ASGI) application.
ASGI apps are coroutine functions, callables whose ``__call__`` is a
coroutine, or expose ``__asgi_app__``; anything else with a two-positional
call signature (``environ, start_response``) is treated as WSGI. Deciding
this from the signature — instead of from the text of a ``TypeError`` raised
while calling the app — means a genuine ``TypeError`` inside a mounted ASGI
sub-app keeps its real traceback rather than being misclassified as WSGI.
"""
if hasattr(app, "__asgi_app__") or not callable(app):
return False
# ASGI if the app (a function) or its bound ``__call__`` (an instance) is a
# coroutine. ``inspect.signature`` resolves an instance to its ``__call__``
# signature (self excluded), so a two-positional signature => WSGI.
if inspect.iscoroutinefunction(app):
return False
if not inspect.isroutine(app) and inspect.iscoroutinefunction(app.__call__):
return False
try:
positional = [
p
for p in inspect.signature(app).parameters.values()
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
]
except (TypeError, ValueError):
return False
return len(positional) == 2
_ASGI_STYLE_CACHE: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
def _is_asgi_style(fn: Callable) -> bool:
"""Whether ``fn`` has an ASGI call signature (``scope, receive, send``).
Used to decide how a custom default endpoint is dispatched: ASGI-style
callables (like the built-in :meth:`Router.default_response`) are invoked
directly, while Responder ``(req, resp)`` views are dispatched through
normal :class:`Route` semantics. Cached per underlying function.
"""
if inspect.isclass(fn):
return False
key = getattr(fn, "__func__", fn)
try:
return _ASGI_STYLE_CACHE[key]
except (KeyError, TypeError):
pass
if not _is_async(fn):
result = False # ASGI apps are always awaitable; sync => a view.
else:
try:
params = inspect.signature(fn).parameters.values()
except (TypeError, ValueError):
result = True # Uninspectable async callable: assume ASGI.
else:
positional = [
p
for p in params
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
]
result = (
any(p.kind == p.VAR_POSITIONAL for p in params) or len(positional) == 3
)
try:
_ASGI_STYLE_CACHE[key] = result
except TypeError:
pass
return result
def _quote_url_params(
params: dict[str, Any], convertor_names: dict[str, str]
) -> dict[str, str]:
"""URL-quote path-parameter values for URL building (``Route.url``).
``{param:path}`` segments keep ``/`` unescaped; every other parameter value
is fully percent-encoded (spaces become ``%20``, slashes ``%2F``, etc.).
Note that ASGI servers percent-decode the path before route matching, so a
non-path parameter value containing ``/`` cannot produce a matchable URL:
the ``%2F`` decodes back to ``/`` and the default ``[^/]+`` segment pattern
never matches it. Values without ``/`` (e.g. containing spaces) round-trip
fine. Use a ``{param:path}`` convertor when values may contain slashes.
"""
return {
name: urllib.parse.quote(
str(value), safe="/" if convertor_names.get(name) == "path" else ""
)
for name, value in params.items()
}
def _accepts_arg_count(view: Callable, count: int) -> bool:
try:
params = inspect.signature(view).parameters.values()
except (TypeError, ValueError):
return True
positional = [
p
for p in params
if p.kind
in (
p.POSITIONAL_ONLY,
p.POSITIONAL_OR_KEYWORD,
)
]
return any(p.kind == p.VAR_POSITIONAL for p in params) or len(positional) >= count
def _is_request_param(name: str, annotation: Any, names: frozenset[str]) -> bool:
"""Whether a provider parameter should receive the request/websocket."""
if name in names:
return True
return isinstance(annotation, type) and issubclass(annotation, _REQUEST_TYPES)
_STRINGY_PATH_CONVERTORS = frozenset({"str", "path", "uuid"})
_PATH_PARAM_ADAPTERS_CACHE: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
def _path_param_adapters(view: Callable) -> dict[str, Any]:
"""Pydantic adapters for bare, same-name path params on ``view``.
Explicit ``Path(...)`` markers are excluded here; they are resolved by the
marker pipeline so aliases, metadata, and constraints stay centralized.
"""
key = getattr(view, "__func__", view)
try:
return _PATH_PARAM_ADAPTERS_CACHE[key]
except (KeyError, TypeError):
pass
try:
from pydantic import TypeAdapter
except ImportError: # pragma: no cover - pydantic is a core dep
return {}
from .params import marker_params
hints = _view_type_hints(view)
explicit_path_params = {
spec.name for spec in marker_params(view, hints) if spec.location == "path"
}
adapters: dict[str, Any] = {}
parameters: Any
try:
parameters = inspect.signature(view).parameters
except (TypeError, ValueError):
parameters = {}
for param in parameters.values():
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
continue
if param.name in _RESERVED_DEP_NAMES or param.name in explicit_path_params:
continue
annotation = hints.get(param.name)
if annotation is None:
continue
try:
adapter = TypeAdapter(annotation)
except Exception:
adapter = None
if adapter is not None:
adapters[param.name] = adapter
try:
_PATH_PARAM_ADAPTERS_CACHE[key] = adapters
except TypeError:
pass
return adapters
def _coerce_typed_path_params(
view: Callable, path_params: dict[str, Any], convertor_names: dict[str, str]
) -> dict[str, Any]:
"""Validate/coerce bare same-name path params from handler annotations.
This only applies to string-like route segments (plain ``{id}``, ``{id:path}``,
``{id:uuid}``) so explicit route convertors such as ``{id:int}`` keep their
existing runtime behavior unless the user opts into ``Path(...)`` markers.
"""
adapters = _path_param_adapters(view)
if not adapters:
return {}
values: dict[str, Any] = {}
errors: list[dict] = []
for name, raw in path_params.items():
if convertor_names.get(name) not in _STRINGY_PATH_CONVERTORS:
continue
adapter = adapters.get(name)
if adapter is None:
continue
try:
values[name] = adapter.validate_python(raw)
except Exception as exc:
if hasattr(exc, "errors"):
for err in exc.errors():
err = dict(err)
err["loc"] = ["path", name]
errors.append(err)
else:
errors.append({"loc": ["path", name], "msg": str(exc)})
if errors:
raise _MarkerValidationError(errors)
return values
class _MarkerValidationError(Exception):
"""Carries aggregated 422 errors from Query/Header/Cookie/Path markers."""
def __init__(self, errors):
self.errors = errors
async def _get_form(request):
"""Parse the request's form/multipart body once (spooling large uploads to
disk via Starlette). Delegates to the shared Request helper."""
return await request._parsed_form()
def _form_value(form, spec):
"""Pull a Form()/File() marker's raw value from parsed form data."""
if spec.location == "file":
files = [v for v in form.getlist(spec.lookup) if not isinstance(v, str)]
if spec.is_sequence:
return files if files else ...
return files[0] if files else ...
if spec.is_sequence:
values = [v for v in form.getlist(spec.lookup) if isinstance(v, str)]
return values if values else ...
value = form.get(spec.lookup)
return value if isinstance(value, str) else ...
def _form_model_data(form: Any, model: Any) -> dict[str, Any]:
"""Collect a Pydantic form model's raw field values from parsed form data.
Text fields come through as strings (coerced by the model); fields whose
submitted values are uploaded files receive the ``UploadFile`` objects
directly. Sequence-typed fields collect every value sent under the field
name; scalar fields take the last one. Fields absent from the form are
omitted, so model defaults apply and missing required fields surface as
Pydantic ``missing`` errors.
"""
from .params import _is_sequence
data: dict[str, Any] = {}
for name, field in model.model_fields.items():
key = getattr(field, "alias", None) or name
raw = form.getlist(key)
if not raw:
continue
files = [v for v in raw if not isinstance(v, str)]
chosen: list[Any] = files if files else list(raw)
data[key] = chosen if _is_sequence(field.annotation) else chosen[-1]
return data
def _validate_form_model(
spec: Any, form: Any, values: dict[str, Any], errors: list[dict]
) -> None:
"""Bind an entire parsed form onto a Pydantic-model ``Form()`` parameter.
Field-level failures are appended to ``errors`` with ``["form", field]``
locations, matching the shape JSON body-model validation reports.
"""
data = _form_model_data(form, spec.annotation) if form is not None else {}
if not data and not spec.required:
values[spec.name] = spec.marker.default
return
try:
values[spec.name] = spec.annotation.model_validate(data)
except Exception as exc:
if hasattr(exc, "errors"):
for err in exc.errors():
err = dict(err)
err["loc"] = ["form", *err.get("loc", ())]
errors.append(err)
else:
errors.append({"loc": ["form", spec.lookup], "msg": str(exc)})
async def _resolve_markers(
view: Callable, request: Request | WebSocket, path_params: dict[str, Any]
) -> tuple[dict, set]:
"""Validate a view's Query/Header/Cookie/Path/Form/File markers into kwargs.
Returns ``({param: value}, drop_keys)`` where ``drop_keys`` are path-param
names a renamed ``Path`` marker consumed (so the raw URL key doesn't leak as
an unexpected kwarg); raises :class:`_MarkerValidationError` on any
validation failure. Works for function views, CBV methods, and WebSocket
handlers alike (a WebSocket has no body, so ``Form()``/``File()`` markers
on a handler resolve as missing).
"""
from .params import marker_params, raw_value
specs = marker_params(view, _view_type_hints(view))
if not specs:
return {}, set()
form = None
if any(s.location in ("form", "file") for s in specs) and hasattr(
request, "_parsed_form"
):
form = await _get_form(request)
values: dict[str, Any] = {}
drop: set = set()
errors: list[dict] = []
for spec in specs:
if spec.location == "path" and spec.lookup != spec.name:
drop.add(spec.lookup)
if spec.name in path_params and spec.location != "path":
continue # path parameter wins over a marker of the same name
if spec.location == "form" and _is_pydantic_model(spec.annotation):
_validate_form_model(spec, form, values, errors)
continue
if spec.location in ("form", "file"):
raw = _form_value(form, spec) if form is not None else ...
else:
raw = raw_value(spec, request, path_params)
if raw is ...:
if spec.required:
errors.append(
{
"loc": [spec.location, spec.lookup],
"msg": "field required",
"type": "missing",
}
)
else:
values[spec.name] = spec.marker.default
continue
if spec.adapter is None:
values[spec.name] = raw
continue
try:
values[spec.name] = spec.adapter.validate_python(raw)
except Exception as exc:
if hasattr(exc, "errors"):
for err in exc.errors():
err = dict(err)
err["loc"] = [spec.location, spec.lookup]
errors.append(err)
else:
errors.append({"loc": [spec.location, spec.lookup], "msg": str(exc)})
if errors:
raise _MarkerValidationError(errors)
return values, drop
_ASYNC_CACHE: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
def _is_async(fn: Callable) -> bool:
"""Whether ``fn`` (or its ``__call__``) is a coroutine function, cached.
A view/hook's async-ness is fixed at definition time, so this memoizes the
(comparatively costly) ``inspect`` check off the per-request hot path.
"""
key = getattr(fn, "__func__", fn)
try:
return _ASYNC_CACHE[key]
except (KeyError, TypeError):
pass
result = inspect.iscoroutinefunction(fn) or inspect.iscoroutinefunction(
getattr(fn, "__call__", None) # noqa: B004 - inspecting __call__, not testing callability
)
try:
_ASYNC_CACHE[key] = result
except TypeError:
pass
return result
async def _invoke_provider(
provider: Callable, kwargs: dict
) -> tuple[Any, Callable | None]:
"""Call a provider with pre-resolved kwargs, returning ``(value, teardown)``.
Providers may be sync/async functions or sync/async generators (code after
``yield`` runs as teardown), including callable instances whose ``__call__``
is a generator. Sub-dependencies and the request are passed in via
``kwargs`` by the resolver.
"""
# For a callable instance, the generator-ness lives on __call__, not the
# object itself; inspect that (calling provider(**kwargs) still dispatches
# to __call__). For a plain function/method, inspect it directly.
target = provider
if not (inspect.isfunction(provider) or inspect.ismethod(provider)):
target = getattr(provider, "__call__", provider) # noqa: B004 - inspecting __call__, not testing callability
if inspect.isasyncgenfunction(target):
agen = provider(**kwargs)
value = await agen.__anext__()
async def teardown_async():
try:
await agen.__anext__()
except StopAsyncIteration:
pass
return value, teardown_async
if inspect.isgeneratorfunction(target):
gen = provider(**kwargs)
value = await run_in_threadpool(next, gen)
async def teardown_sync():
await run_in_threadpool(lambda: next(gen, None))
return value, teardown_sync
# ``_is_async`` (unlike ``iscoroutinefunction``) also detects a callable
# instance whose ``__call__`` is async — e.g. an auth scheme used as a
# dependency — so those are awaited rather than run in a thread.
if _is_async(provider):
return await provider(**kwargs), None
return await run_in_threadpool(provider, **kwargs), None
class _RequestResolver:
"""Resolves a request's dependency graph: recursive sub-dependencies,
whole-graph memoization, cycle detection, and reverse-topological teardown.
"""
__slots__ = (
"registry",
"app_deps",
"request",
"req_names",
"override_names",
"cache",
"provider_cache",
"teardowns",
"stack",
)
def __init__(
self, registry, app_deps, request, req_names, override_names=frozenset()
):
self.registry = registry
self.app_deps = app_deps
self.request = request
self.req_names = req_names
self.override_names = override_names
self.cache: dict[str, Any] = {}
self.provider_cache: dict[Any, Any] = {}
self.teardowns: list[Callable] = []
# Resolution stack of (identity, label) pairs. Cycle detection uses
# the identity (registry name or provider object id) — never the bare
# ``__name__``, which two distinct providers may share — while labels
# keep error messages readable.
self.stack: list[tuple[Any, str]] = []
def _check_cycle(self, key: Any, label: str) -> None:
keys = [k for k, _ in self.stack]
if key in keys:
path = [lbl for _, lbl in self.stack[keys.index(key) :]] + [label]
raise DependencyCycleError("Dependency cycle: " + " -> ".join(path))
def _chain(self) -> str:
return " -> ".join(label for _, label in self.stack)
def _depends_on_override(self, name, seen=None):
"""Whether app-dep ``name`` transitively depends on an overridden dep.
Such an app-dep must be resolved request-scoped (not served from — or
written to — the app cache), so a ``dependency_overrides`` block reaches
deep into the app-scoped graph and restores cleanly afterward.
"""
if not self.override_names:
return False
if seen is None:
seen = set()
if name in seen:
return False
seen.add(name)
if name in self.override_names:
return True
provider, _scope = self.registry[name]
for pname, _ann in _dep_param_specs(provider):
if pname in self.registry and self._depends_on_override(pname, seen):
return True
return False
async def resolve(self, name):
if name in self.cache: # whole-graph memo
return self.cache[name]
provider, scope = self.registry[name]
if scope == "app" and not self._depends_on_override(name):
value = await self.app_deps.resolve(name, self.registry)
self.cache[name] = value
return value
key = ("dep", name)
self._check_cycle(key, name)
self.stack.append((key, name))
try:
kwargs: dict[str, Any] = {}
specs = _dep_param_specs(provider)
depends = _depends_params(provider)
for pname, ann in specs:
if _is_request_param(pname, ann, self.req_names):
kwargs[pname] = self.request
elif pname in depends:
kwargs[pname] = await self.resolve_provider(depends[pname].provider)
elif pname in self.registry:
kwargs[pname] = await self.resolve(pname)
else:
chain = self._chain()
raise DependencyResolutionError(
f"Parameter {pname!r} of dependency {name!r} is neither the "
f"request (name it 'req' / annotate 'Request'), a registered "
f"dependency, nor an inline Depends(...). "
f"Dependency chain: {chain}."
)
finally:
self.stack.pop()
value, teardown = await _invoke_provider(provider, kwargs)
self.cache[name] = value
if teardown is not None:
self.teardowns.append(teardown)
return value
async def resolve_provider(self, provider: Callable) -> Any:
key = provider
try:
return self.provider_cache[key]
except (KeyError, TypeError):
pass
label = getattr(provider, "__name__", provider.__class__.__name__)
ident = ("provider", id(provider))
self._check_cycle(ident, label)
self.stack.append((ident, label))
try:
kwargs: dict[str, Any] = {}
depends = _depends_params(provider)
for pname, ann in _dep_param_specs(provider):
if _is_request_param(pname, ann, self.req_names):
kwargs[pname] = self.request
elif pname in depends:
kwargs[pname] = await self.resolve_provider(depends[pname].provider)
elif pname in self.registry:
kwargs[pname] = await self.resolve(pname)
else:
chain = self._chain()
raise DependencyResolutionError(
f"Parameter {pname!r} of dependency provider {label!r} "
"is neither the request, a registered dependency, nor an "
f"inline Depends(...). Dependency chain: {chain}."
)
finally:
self.stack.pop()
value, teardown = await _invoke_provider(provider, kwargs)
try:
self.provider_cache[key] = value
except TypeError:
pass
if teardown is not None:
self.teardowns.append(teardown)
return value
async def teardown(self):
for td in reversed(self.teardowns):
try:
await td()
except Exception:
logger.exception("Dependency teardown failed")
def _accepts_json(scope: Scope) -> bool:
"""Whether the request's Accept header asks for JSON."""
for key, value in scope.get("headers", []):
if key == b"accept":
return b"json" in value
return False
def _validation_errors(exc: Any) -> list[dict] | None:
"""Extract structured validation errors from an exception if available."""
error_values = getattr(exc, "errors", None)
if error_values is None:
return None
if callable(error_values):
try:
error_values = error_values()
except TypeError:
return None
if error_values is None:
return None
if isinstance(error_values, tuple):
return list(error_values)
if isinstance(error_values, list):
return error_values
return [{"msg": str(error_values)}]
def _error_payload(scope, status_code, detail=None, *, title=None, errors=None):
if scope.get("problem_details"):
return problem_payload_for(scope, status_code, detail, title=title, errors=errors)
return legacy_error_payload(status_code, detail, title=title, errors=errors)
def _trace(scope: Scope, stage: str, **values: Any) -> None:
if not scope.get("trace_dispatch"):
return
route = scope.get("route_pattern") or scope.get("path")
bits = " ".join(f"{key}={value!r}" for key, value in values.items())
logger.debug("dispatch.%s %s %s", stage, route, bits)
def _callable_label(fn: Callable) -> str:
return getattr(fn, "__name__", fn.__class__.__name__)
def _fresh_child_scope(child_scope: dict) -> dict:
return {k: dict(v) if isinstance(v, dict) else v for k, v in child_scope.items()}
class BaseRoute:
route: str
endpoint: Callable
#: Per-route CSRF override, snapshotted at registration; ``None`` inherits
#: the app-wide ``API(csrf=...)`` default.
_csrf: bool | None = None
def url(self, **params: Any) -> str:
raise NotImplementedError()
def matches(self, scope: Scope) -> tuple[bool, dict]:
raise NotImplementedError()
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
raise NotImplementedError()
async def _dispatch_hook(self, hook: Callable, *args: Any) -> None:
"""Invoke a hook, awaiting coroutines and offloading sync callables.
Shared by the HTTP and websocket before/after hook runners; each caller
layers its own short-circuit and error-handling policy around it.
"""
if _is_async(hook):
await hook(*args)
else:
await run_in_threadpool(hook, *args)
async def _route_auth_injections(
self, request: Request | WebSocket
) -> dict[str, Any]:
route_auth = getattr(self.endpoint, "_route_auth", ())
if not route_auth:
return {}
principals = []
for auth in route_auth:
if hasattr(auth, "authenticate"):
principal = await auth.authenticate(request)
elif _is_async(auth):
principal = await auth(request)
else:
principal = await run_in_threadpool(auth, request)
if principal is not None or getattr(auth, "optional_auth", False):
principals.append(principal)
value = principals[0] if len(principals) == 1 else principals
request.state.auth = value
request.state.user = value
return {"auth": value, "principal": value, "user": value}
async def _run_route_dependencies(self, resolver: _RequestResolver) -> None:
for dependency in getattr(self.endpoint, "_route_dependencies", ()):
await resolver.resolve_provider(dependency.provider)
class Route(BaseRoute):
"""An HTTP route that maps a URL pattern to an endpoint.
Supports path parameters with type convertors (``{id:int}``, ``{slug:str}``,
``{pk:uuid}``, ``{value:float}``, ``{rest:path}``).
"""
def __init__(
self,
route: str,
endpoint: Callable,
*,
before_request: bool = False,
methods: list[str] | None = None,
name: str | None = None,
) -> None:
if not route.startswith("/"):
raise ValueError(f"Route path must start with '/', got {route!r}.")
self.route = route
self.endpoint = endpoint
self.before_request = before_request
self.name = name
self.methods: set[str] | None = {m.upper() for m in methods} if methods else None
self._response_models: dict[int, Any] = {}
self._stream_mode: str | None = None
self._stream_model: Any = None
self._stream_heartbeat: float | None = None
self.path_re: re.Pattern
self.param_convertors: dict[str, type]
self.param_convertor_names: dict[str, str]
(
self.path_re,
self.param_convertors,
self.param_convertor_names,
) = compile_path(route)
# Strip type annotations for URL generation (e.g. {id:int} -> {id})
self._url_template = PARAM_RE.sub(r"{\1}", route)
def __repr__(self) -> str:
return f"<Route {self.route!r}={self.endpoint!r}>"
def url(self, **params: Any) -> str:
"""The route's URL with ``params`` substituted (values URL-quoted;
``{param:path}`` segments keep their slashes)."""
return self._url_template.format(
**_quote_url_params(params, self.param_convertor_names)
)
@property
def path_template(self) -> str:
"""The route with convertor annotations stripped (``/users/{id}``)."""
return self._url_template
@property
def endpoint_name(self) -> str:
return self.endpoint.__name__
@property
def description(self) -> str | None:
return self.endpoint.__doc__
def matches(self, scope: Scope) -> tuple[bool, dict]:
if scope["type"] != "http":
return False, {}
if self.methods:
method = scope.get("method", "").upper()
# HEAD is implicitly supported wherever GET is.
if method not in self.methods and not (
method == "HEAD" and "GET" in self.methods
):
return False, {}
path = scope["path"]
match = self.path_re.match(path)
if match is None:
return False, {}
matched_params = match.groupdict()
for key, value in matched_params.items():
matched_params[key] = self.param_convertors[key](value)
return True, {"path_params": {**matched_params}}
def _exchange(self, scope: Scope, receive: Receive) -> tuple[Request, Response]:
formats = scope.get("formats") or get_formats()
request = Request(scope, receive, api=scope.get("api"), formats=formats)
response = Response(
req=request,
formats=formats,
auto_etag=scope.get("auto_etag", False),
auto_vary=scope.get("auto_vary", False),
)
return request, response
def _problem_content_type(self, scope: Scope, response: Response) -> None:
if scope.get("problem_details"):
response.mimetype = PROBLEM_JSON
response.headers["Content-Type"] = PROBLEM_JSON
def _set_error_response(
self,
scope: Scope,
response: Response,
status_code: int,
detail: str | None = None,
*,
title: str | None = None,
errors: list[dict] | None = None,
exc: Exception | None = None,
) -> None:
response.reset_for_error()
if scope.get("problem_details"):
response.content = problem_bytes_for(
scope,
status_code,
detail,
title=title,
errors=errors,
request=response.req,
exc=exc,
)
self._problem_content_type(scope, response)
else:
response.media = _error_payload(
scope, status_code, detail, title=title, errors=errors
)
async def _send_validation_error(
self,
scope: Scope,
receive: Receive,
send: Send,
response: Response,
exc: Exception,
) -> None:
response.status_code = 422
errors = _validation_errors(exc)
if errors is None:
errors = [{"msg": str(exc)}]
self._set_error_response(
scope,
response,
422,
"Validation failed",
title="Validation Error",
errors=errors,
exc=exc,
)
await response(scope, receive, send)
async def _run_hooks(
self, hooks: Iterable[Callable], request: Request, response: Response
) -> bool:
for hook in hooks:
_trace(request._starlette.scope, "before_hook", hook=_callable_label(hook))
args = (request, response) if _accepts_arg_count(hook, 2) else (request,)
await self._dispatch_hook(hook, *args)
if response.status_code is not None:
return False
return True
async def _validate_params_model(self, request: Request) -> None:
params_model = getattr(self.endpoint, "_params_model", None)
if params_model is None:
return
data = {}
for key in request.params:
values = request.params.get_list(key)
data[key] = values if len(values) > 1 else values[-1]
request.state.validated_params = params_model(**data)
async def _body_injections(
self,
scope: Scope,
request: Request,
path_params: dict,
views: list[Callable],
) -> list[dict[str, Any]]:
"""Per-view Pydantic body-model injections, parallel to ``views``.
The body is read and parsed at most once per request, and each
``(name, model)`` pair is validated once — so a class-based view whose
``on_request`` and ``on_post`` both declare the same model share a
single validated instance.
"""
injections: list[dict[str, Any]] = [{} for _ in views]
if request.method not in ("POST", "PUT", "PATCH", "DELETE"):
return injections
dep_names = scope.get("dependencies") or {}
auth_names = (
_AUTH_INJECTION_NAMES
if getattr(self.endpoint, "_route_auth", ())
else frozenset()
)
body: Any = None
parsed = False
validated: dict[tuple[str, Any], Any] = {}
for index, view in enumerate(views):
model_params = [
(name, model)
for name, model in _body_model_candidates(view)
if name not in path_params
and name not in dep_names
and name not in auth_names
]
if not model_params:
continue
if not parsed:
body = await request.media()
parsed = True
if not isinstance(body, dict):
raise TypeError("Request body must be a JSON object")
view_values: dict[str, Any] = {}
for name, model in model_params:
key = (name, model)
if key not in validated:
validated[key] = model.model_validate(body)
view_values[name] = validated[key]
injections[index] = view_values
return injections
async def _validate_inputs(
self,
scope: Scope,
receive: Receive,
send: Send,
request: Request,
response: Response,
path_params: dict,
views: list[Callable],
) -> tuple[bool, list[dict[str, Any]]]:
try:
await self._validate_params_model(request)
injections = await self._body_injections(scope, request, path_params, views)
except HTTPException:
raise
except Exception as exc:
await self._send_validation_error(scope, receive, send, response, exc)
return False, []
return True, injections
def _views_for(self, request: Request) -> list[Callable]:
if not inspect.isclass(self.endpoint):
return [self.endpoint]
endpoint = self.endpoint()
views = []
on_request = getattr(endpoint, "on_request", None)
if on_request:
views.append(on_request)
method_name = f"on_{request.method.lower()}"
view = getattr(endpoint, method_name, None)
if view is None and request.method.upper() == "HEAD":
# HEAD is implicitly served by on_get, mirroring function views
# (the response side already strips the body for HEAD).
view = getattr(endpoint, "on_get", None)
if view is not None:
views.append(view)
elif on_request is None:
allow = ", ".join(sorted(_class_view_methods(endpoint)))
if request.method.upper() == "OPTIONS":
# No on_options handler: answer OPTIONS automatically with
# 200 + Allow, mirroring the router-level treatment of
# method-restricted function routes.
return [_auto_options_view(allow)]
# RFC 9110 §15.5.6: a 405 must list the methods the target
# supports, mirroring the router-level function-route 405.
raise HTTPException(
status_code=status_codes.HTTP_405, headers={"Allow": allow}
)
return views
async def _view_kwargs(
self,
view: Callable,
request: Request,
resolver: _RequestResolver,
path_params: dict,
injected: dict[str, Any],
auth_injected: dict[str, Any],
) -> dict[str, Any]:
kwargs = dict(path_params)
kwargs.update(
_coerce_typed_path_params(view, path_params, self.param_convertor_names)
)
if injected:
kwargs.update(injected)
marker_values, drop_keys = await _resolve_markers(view, request, path_params)
for key in drop_keys:
kwargs.pop(key, None)
kwargs.update(marker_values)
depends_params = _depends_params(view)
dependencies = resolver.registry
for name in _view_param_names(view):
if name in kwargs:
continue
if name in depends_params:
kwargs[name] = await resolver.resolve_provider(
depends_params[name].provider
)
elif name in dependencies:
kwargs[name] = await resolver.resolve(name)
elif name in auth_injected:
kwargs[name] = auth_injected[name]
return kwargs
async def _invoke_view(
self, view: Callable, request: Request, response: Response, kwargs: dict
) -> Any:
if inspect.isasyncgenfunction(view) or inspect.isasyncgenfunction(
getattr(view, "__call__", None) # noqa: B004 - inspecting __call__
):
return view(request, response, **kwargs)
if _is_async(view):
return await view(request, response, **kwargs)
return await run_in_threadpool(view, request, response, **kwargs)
def _typed_stream_item(self, response: Response, item: Any) -> Any:
model = self._stream_model
adapter = response_type_adapter(model)
if self._stream_mode == "sse":
metadata: dict[str, Any] = {}
data = item
if isinstance(item, SSE):
if item.comment is not None:
if any(
value is not None
for value in (item.data, item.event, item.id, item.retry)
):
raise ValueError(
"SSE(comment=...) must be a comment-only event"
)
if not isinstance(item.comment, str):
raise TypeError("SSE comment must be a string")
return {"comment": item.comment}
data = item.data
metadata = {
key: value
for key, value in {
"event": item.event,
"id": item.id,
"retry": item.retry,
}.items()
if value is not None
}
if item.event is not None and not isinstance(item.event, str):
raise TypeError("SSE event must be a string")
if item.id is not None and (
isinstance(item.id, bool) or not isinstance(item.id, (str, int))
):
raise TypeError("SSE id must be a string or integer")
if item.retry is not None and (
isinstance(item.retry, bool)
or not isinstance(item.retry, int)
or item.retry < 0
):
raise ValueError("SSE retry must be a non-negative integer")
validated = adapter.validate_python(data)
dumped = adapter.dump_python(validated, mode="json")
return {"data": dumped, **metadata}
validated = adapter.validate_python(item)
return adapter.dump_json(validated) + b"\n"
def _log_stream_contract_failure(
self, scope: Scope, exc: Exception, view: Callable
) -> None:
logger.error(
"Stream contract failed for %s %s in %s; expected %r",
scope.get("method", "HTTP"),
scope.get("route_pattern", scope.get("path", "?")),
_callable_label(view),
self._stream_model,
exc_info=exc,
)
def _set_typed_stream_headers(self, response: Response) -> None:
if self._stream_mode == "sse":
response.mimetype = "text/event-stream"
response.headers["Cache-Control"] = "no-cache"
response.headers["Connection"] = "keep-alive"
response.headers["X-Accel-Buffering"] = "no"
else:
response.mimetype = "application/x-ndjson"
async def _close_abandoned_typed_streams(
self, response: Response, *, include_pending: bool = False
) -> None:
sources = list(response._discarded_typed_streams)
response._discarded_typed_streams = []
if include_pending and response._typed_stream is not None:
sources.append(response._typed_stream)
response._typed_stream = None
for source in sources:
try:
await _close_stream_source(source)
except Exception:
logger.exception("Failed to close an abandoned typed stream")
async def _prepare_typed_stream(
self, scope: Scope, response: Response, view: Callable
) -> None:
if self._stream_mode is None:
return
await self._close_abandoned_typed_streams(response)
status = response.status_code if response.status_code is not None else 200
if status >= 400 or not response_status_allows_body(status):
await self._close_abandoned_typed_streams(
response, include_pending=True
)
return
pending = response._typed_stream
if pending is None:
exc = TypeError(
f"@api.{self._stream_mode} handler did not return an iterable"
)
if getattr(scope.get("api"), "debug", False):
raise exc
self._response_model_failure(
scope, response, exc, model=self._stream_model, view=view
)
return
source = pending
response._typed_stream = None
self._set_typed_stream_headers(response)
iterator = _iterate_stream_source(source)
if response.req.method == "HEAD":
await _close_stream_source(source)
async def empty_stream():
if False: # pragma: no cover - makes this an async generator
yield b""
response._stream = empty_stream
return
first_task: asyncio.Future[Any] | None = None
try:
if self._stream_mode == "sse" and self._stream_heartbeat:
first_task = asyncio.ensure_future(anext(iterator))
# Let an immediately-yielding producer complete so its first
# item can still fail before headers. A genuinely idle
# producer remains pending and the heartbeat path starts.
await asyncio.sleep(0)
if first_task.done():
first = first_task.result()
else:
first = _STREAM_PENDING
else:
first = await anext(iterator)
except StopAsyncIteration:
first = _STREAM_END
except Exception as exc:
await iterator.aclose()
if getattr(scope.get("api"), "debug", False):
raise
self._response_model_failure(
scope, response, exc, model=self._stream_model, view=view
)
return
if first is not _STREAM_END and first is not _STREAM_PENDING:
try:
first = self._typed_stream_item(response, first)
except Exception as exc:
await iterator.aclose()
if getattr(scope.get("api"), "debug", False):
raise
self._response_model_failure(
scope, response, exc, model=self._stream_model, view=view
)
return
async def validated_items():
try:
if first is _STREAM_PENDING:
assert first_task is not None
try:
pending_item = await first_task
yield self._typed_stream_item(response, pending_item)
except StopAsyncIteration:
return
except Exception as exc:
if getattr(scope.get("api"), "debug", False):
raise
self._log_stream_contract_failure(scope, exc, view)
return
elif first is not _STREAM_END:
yield first
async for item in iterator:
try:
yield self._typed_stream_item(response, item)
except Exception as exc:
if getattr(scope.get("api"), "debug", False):
raise
self._log_stream_contract_failure(scope, exc, view)
return
except asyncio.CancelledError:
raise
except Exception as exc:
self._log_stream_contract_failure(scope, exc, view)
if getattr(scope.get("api"), "debug", False):
raise
finally:
if first_task is not None and not first_task.done():
first_task.cancel()
with contextlib.suppress(BaseException):
await first_task
await iterator.aclose()
if self._stream_mode == "sse":
async def body():
events = validated_items()
if self._stream_heartbeat:
events = _sse_with_heartbeat(events, self._stream_heartbeat)
async for event in events:
yield _format_sse_event(event)
else:
body = validated_items
response._stream = body
def _apply_result(
self,
response: Response,
result: Any,
view: Callable,
) -> None:
if result is None:
return
if isinstance(result, tuple):
if len(result) not in (2, 3):
raise TypeError(
"A returned tuple must be (body, status) or (body, status, headers)"
)
result, returned_status, *rest = result
if (
isinstance(returned_status, bool)
or not isinstance(returned_status, int)
or not 100 <= returned_status <= 599
):
raise ValueError(
"The status in a returned tuple must be an integer "
f"from 100 through 599 (got {returned_status!r})"
)
response.status_code = returned_status
if rest and rest[0] is not None:
if not isinstance(rest[0], Mapping):
raise TypeError(
"The headers in a returned tuple must be a mapping or None"
)
response.headers.update(rest[0])
status = response.status_code if response.status_code is not None else 200
if (
self._stream_mode is not None
and status < 400
):
if not isinstance(result, (AsyncIterable, Iterable)) or isinstance(
result, (str, bytes, bytearray, dict)
):
raise TypeError(
f"@api.{self._stream_mode} handler must return a sync or "
"async iterable of typed items"
)
response._reset_body()
response._typed_stream = result
return
response_model, explicit_response_model = self._response_model(view, status)
if response_model is not None:
kind = (
"media" if explicit_response_model else response_body_kind(response_model)
)
if kind == "text":
response.text = result
elif kind == "bytes":
response._reset_body()
response.content = result
response.mimetype = "application/octet-stream"
else:
response._reset_body()
response.media = result
return
if isinstance(result, (dict, list)):
response.media = result
elif isinstance(result, str):
response.text = result
elif isinstance(result, bytes):
response.content = result
elif isinstance(result, (bool, int, float)):
response.media = result
elif hasattr(result, "model_dump") or (
dataclasses.is_dataclass(result) and not isinstance(result, type)
):
response.media = result
else:
raise TypeError(
"Unsupported handler return value "
f"{type(result).__name__}; mutate resp or return a JSON scalar, "
"dict, list, str, bytes, model, dataclass, or "
"(body, status[, headers]) tuple"
)
async def _run_views(
self,
views: list[Callable],
request: Request,
response: Response,
resolver: _RequestResolver,
path_params: dict,
injections: list[dict[str, Any]],
auth_injected: dict[str, Any],
) -> None:
for view, injected in zip(views, injections, strict=True):
_trace(request._starlette.scope, "handler", view=_callable_label(view))
kwargs = await self._view_kwargs(
view, request, resolver, path_params, injected, auth_injected
)
result = await self._invoke_view(view, request, response, kwargs)
self._apply_result(response, result, view)
def _response_model(
self, view: Callable | None = None, status: int | None = None
) -> tuple[Any, bool]:
"""Resolve a status-specific, explicit, or inferred response contract."""
if status is not None:
status_models = dict(getattr(self, "_response_models", {}) or {})
if view is not None and view is not self.endpoint:
status_models.update(getattr(view, "_response_models", {}) or {})
if status in status_models:
return status_models[status], True
if status >= 400:
return None, False
if self._stream_mode is not None:
return None, False
unset = object()
explicit = unset
if view is not None:
explicit = getattr(view, "_response_model", unset)
if explicit is unset:
explicit = getattr(self.endpoint, "_response_model", unset)
if explicit is not unset:
return (None if explicit is False else explicit), True
target = view or self.endpoint
if inspect.isclass(target):
return None, False
return_hint = _view_return_hint(target)
return inferred_response_model(return_hint), False
def _response_model_failure(
self,
scope: Scope,
response: Response,
exc: Exception | None = None,
*,
model: Any = None,
view: Callable | None = None,
) -> None:
logger.error(
"Response contract failed for %s %s in %s; expected %r",
scope.get("method", "HTTP"),
scope.get("route_pattern", scope.get("path", "?")),
_callable_label(view or self.endpoint),
model,
exc_info=exc,
)
response.status_code = 500
self._set_error_response(
scope,
response,
500,
INTERNAL_SERVER_ERROR,
errors=_validation_errors(exc) if exc is not None else None,
exc=exc,
)
def _validate_response_model(
self, scope: Scope, response: Response, view: Callable | None = None
) -> None:
status = response.status_code if response.status_code is not None else 200
resp_model, explicit_model = self._response_model(view, status)
if resp_model is None:
return
if not response_status_allows_body(status):
return
kind = "media" if explicit_model else response_body_kind(resp_model)
if response._stream is not None or response._deferred_content is not None:
exc = TypeError(
"A streaming or file response cannot satisfy a typed response "
"contract; use response_model=False"
)
if getattr(scope.get("api"), "debug", False):
raise exc
self._response_model_failure(
scope, response, exc, model=resp_model, view=view
)
return
if response.media is not None or (response.content is None and kind == "media"):
value = response.media
target = "media"
elif response.content is not None and kind in ("text", "bytes"):
value = response.content
target = kind
else:
exc = TypeError(
f"The response body uses a channel incompatible with {resp_model!r}"
)
if getattr(scope.get("api"), "debug", False):
raise exc
self._response_model_failure(
scope, response, exc, model=resp_model, view=view
)
return
try:
adapter = response_type_adapter(resp_model)
validated = adapter.validate_python(value)
if target == "media":
response.media = adapter.dump_python(validated, mode="json")
else:
response.content = adapter.dump_python(validated, mode="python")
except Exception as exc:
if getattr(scope.get("api"), "debug", False):
raise
self._response_model_failure(
scope, response, exc, model=resp_model, view=view
)
async def _send_timeout_response(
self, scope: Scope, receive: Receive, send: Send, response: Response
) -> None:
"""Send a 504 on ``response`` — a fresh object carrying only the
metadata snapshotted from the abandoned response (hook-set headers,
cookies). Content is built directly rather than via
``_set_error_response``: its ``reset_for_error()`` would wipe that
carried-over metadata, and there is no stale body here to reset.
"""
response.status_code = 504
if scope.get("problem_details"):
response.content = problem_bytes_for(
scope, 504, "Request timed out", request=response.req
)
self._problem_content_type(scope, response)
elif _accepts_json(scope):
response.media = _error_payload(scope, 504, "Request timed out")
else:
response.text = "Request timed out"
await response(scope, receive, send)
async def _run_after_hooks(
self, scope: Scope, request: Request, response: Response
) -> None:
route_after = getattr(self.endpoint, "_route_after", ())
after_requests = scope.get("after_requests", [])
for hook in (*route_after, *after_requests):
_trace(scope, "after_hook", hook=_callable_label(hook))
args = (request, response) if _accepts_arg_count(hook, 2) else (request,)
try:
await self._dispatch_hook(hook, *args)
except Exception as exc:
logger.exception("after_request hook failed")
if getattr(scope.get("api"), "debug", False):
raise
response.status_code = 500
self._set_error_response(
scope, response, 500, INTERNAL_SERVER_ERROR, exc=exc
)
return
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
request, response = self._exchange(scope, receive)
path_params = scope.get("path_params", {})
before_requests = scope.get("before_requests", {"http": [], "ws": []})
route_before = getattr(self.endpoint, "_route_before", ())
if not await self._run_hooks(
(*before_requests.get("http", []), *route_before), request, response
):
await response(scope, receive, send)
return
# Seed the route's declared default success status (route(status_code=…))
# after the hooks ran — a hook that sets resp.status_code skips the
# handler, and an explicit assignment in the view still wins.
default_status = getattr(self.endpoint, "_default_status_code", None)
if default_status is not None:
response.status_code = default_status
route_csrf = getattr(self, "_csrf", None)
if scope.get("csrf_enabled", False) if route_csrf is None else route_csrf:
_trace(scope, "csrf")
from .csrf import enforce_csrf
await enforce_csrf(request)
_trace(scope, "auth")
auth_injected = await self._route_auth_injections(request)
views = self._views_for(request)
ok, injections = await self._validate_inputs(
scope, receive, send, request, response, path_params, views
)
if not ok:
return
dependencies = scope.get("dependencies") or {}
resolver = _RequestResolver(
dependencies,
scope.get("app_dependencies"),
request,
_HTTP_REQUEST_NAMES,
scope.get("dependency_override_names", frozenset()),
)
try:
try:
_trace(scope, "dependencies")
await self._run_route_dependencies(resolver)
run = self._run_views(
views,
request,
response,
resolver,
path_params,
injections,
auth_injected,
)
timeout = scope.get("request_timeout")
if timeout:
await asyncio.wait_for(run, timeout)
else:
await run
except asyncio.TimeoutError:
# An abandoned sync view (stuck in the threadpool — it cannot
# be cancelled) may keep mutating ``response`` after the
# timeout fires; build the 504 on a fresh Response so its late
# writes can't corrupt what we send. Headers and cookies that
# were legitimately set before the view hung (before_request
# hooks finish before the view starts) still belong on the
# 504, so snapshot them onto the fresh object.
timeout_response = self._exchange(scope, receive)[1]
_copy_response_metadata(response, timeout_response)
await self._send_timeout_response(scope, receive, send, timeout_response)
return
except _MarkerValidationError as exc:
await self._send_validation_error(scope, receive, send, response, exc)
return
await self._run_after_hooks(scope, request, response)
if response.status_code is None:
response.status_code = status_codes.HTTP_200
await self._prepare_typed_stream(scope, response, views[-1])
self._validate_response_model(scope, response, views[-1])
await response(scope, receive, send)
finally:
await self._close_abandoned_typed_streams(
response, include_pending=True
)
await resolver.teardown()
def __eq__(self, other: object) -> bool:
if not isinstance(other, Route):
return NotImplemented
return self.route == other.route and self.endpoint == other.endpoint
def __hash__(self) -> int:
# Mirror __eq__ (route + endpoint) so equal routes hash equal.
return hash(self.route) ^ hash(self.endpoint)
class WebSocketRoute(BaseRoute):
"""A WebSocket route that maps a URL pattern to a WebSocket handler."""
def __init__(
self,
route: str,
endpoint: Callable,
*,
before_request: bool = False,
name: str | None = None,
) -> None:
if not route.startswith("/"):
raise ValueError(f"Route path must start with '/', got {route!r}.")
self.route = route
self.endpoint = endpoint
self.before_request = before_request
self.name = name
self.path_re: re.Pattern
self.param_convertors: dict[str, type]
self.param_convertor_names: dict[str, str]
(
self.path_re,
self.param_convertors,
self.param_convertor_names,
) = compile_path(route)
self._url_template = PARAM_RE.sub(r"{\1}", route)
def __repr__(self) -> str:
return f"<Route {self.route!r}={self.endpoint!r}>"
def url(self, **params: Any) -> str:
"""The route's URL with ``params`` substituted (values URL-quoted;
``{param:path}`` segments keep their slashes)."""
return self._url_template.format(
**_quote_url_params(params, self.param_convertor_names)
)
@property
def path_template(self) -> str:
"""The route with convertor annotations stripped (``/ws/{room}``)."""
return self._url_template
@property
def endpoint_name(self) -> str:
return self.endpoint.__name__
@property
def description(self) -> str | None:
return self.endpoint.__doc__
def matches(self, scope: Scope) -> tuple[bool, dict]:
if scope["type"] != "websocket":
return False, {}
path = scope["path"]
match = self.path_re.match(path)
if match is None:
return False, {}
matched_params = match.groupdict()
for key, value in matched_params.items():
matched_params[key] = self.param_convertors[key](value)
return True, {"path_params": {**matched_params}}
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
ws = WebSocket(scope, receive, send)
idle_timeout = scope.get("ws_idle_timeout")
if idle_timeout is not None:
self._apply_idle_timeout(ws, idle_timeout)
before_requests = scope.get("before_requests", {"http": [], "ws": []})
route_before = getattr(self.endpoint, "_route_before", ())
route_after = getattr(self.endpoint, "_route_after", ())
resolver = None
try:
if not await self._run_before_hooks(
(*before_requests.get("ws", []), *route_before), ws
):
return
auth_injected = await self._route_auth_injections(ws)
if WebSocketState.DISCONNECTED in (ws.client_state, ws.application_state):
return
path_params = scope.get("path_params", {})
dependencies = scope.get("dependencies") or {}
app_deps = scope.get("app_dependencies")
override_names = scope.get("dependency_override_names", frozenset())
resolver = _RequestResolver(
dependencies, app_deps, ws, _WS_REQUEST_NAMES, override_names
)
await self._run_route_dependencies(resolver)
param_names = tuple(_view_param_names(self.endpoint, skip=1))
kwargs = {
name: path_params[name] for name in param_names if name in path_params
}
kwargs.update(
{
name: value
for name, value in _coerce_typed_path_params(
self.endpoint, path_params, self.param_convertor_names
).items()
if name in param_names
}
)
# Query()/Header()/Cookie()/Path() markers resolve from the
# WebSocket handshake (query string, headers, cookies) exactly as
# they do for HTTP views; a validation failure raises
# _MarkerValidationError, closing the socket with 1008 below.
marker_values, drop_keys = await _resolve_markers(
self.endpoint, ws, path_params
)
for key in drop_keys:
kwargs.pop(key, None)
kwargs.update(marker_values)
depends_params = _depends_params(self.endpoint)
for name in param_names:
if name in kwargs:
continue
if name in depends_params:
kwargs[name] = await resolver.resolve_provider(
depends_params[name].provider
)
elif name in dependencies:
kwargs[name] = await resolver.resolve(name)
elif name in auth_injected:
kwargs[name] = auth_injected[name]
await self.endpoint(ws, **kwargs)
await self._run_after_hooks(route_after, ws)
except HTTPException:
await self._close_if_connected(ws, code=1008)
except _MarkerValidationError:
await self._close_if_connected(ws, code=1008)
except TimeoutError:
# No inbound message arrived within ws_idle_timeout; close the
# idle connection with 1001 (going away) instead of hanging.
await self._close_if_connected(ws, code=1001)
except WebSocketDisconnect:
# The client went away mid-handler; nothing to close or report.
raise
except Exception:
# An unhandled handler exception: close with 1011 (internal
# error) so clients can tell a server bug from a network drop,
# then re-raise for the server / test client to surface.
logger.exception("Unhandled exception in WebSocket handler")
await self._close_if_connected(ws, code=1011)
raise
finally:
if resolver is not None:
await resolver.teardown()
@staticmethod
def _apply_idle_timeout(ws: WebSocket, timeout: float) -> None:
"""Shadow ``ws.receive`` so each awaited receive must resolve within
``timeout`` seconds. The deadline resets on every message, so it bounds
idle time between messages, not the total connection lifetime. On expiry
``asyncio.wait_for`` raises ``TimeoutError``, handled in ``__call__``.
"""
original_receive = ws.receive
async def receive_with_timeout() -> Any:
return await asyncio.wait_for(original_receive(), timeout)
ws.receive = receive_with_timeout # type: ignore[method-assign]
async def _run_before_hooks(self, hooks: Iterable[Callable], ws: WebSocket) -> bool:
for hook in hooks:
await self._dispatch_hook(hook, ws)
# If a hook closed the connection, short-circuit the endpoint.
if WebSocketState.DISCONNECTED in (ws.client_state, ws.application_state):
return False
return True
async def _run_after_hooks(self, hooks: Iterable[Callable], ws: WebSocket) -> None:
for hook in hooks:
try:
await self._dispatch_hook(hook, ws)
except Exception:
# A failing after-hook must not escape into the ASGI task and
# crash the (often already-closed) websocket. Log it and move
# on, re-raising only under debug for visibility.
logger.exception("websocket after_request hook failed")
if getattr(ws.scope.get("api"), "debug", False):
raise
async def _close_if_connected(self, ws: WebSocket, *, code: int) -> None:
if WebSocketState.DISCONNECTED not in (
ws.client_state,
ws.application_state,
):
await ws.close(code=code)
def __eq__(self, other: object) -> bool:
if not isinstance(other, WebSocketRoute):
return NotImplemented
return self.route == other.route and self.endpoint == other.endpoint
def __hash__(self) -> int:
# Mirror __eq__ (route + endpoint) so equal routes hash equal.
return hash(self.route) ^ hash(self.endpoint)
class _AppDependencyState:
"""Holds app-scoped dependency values for the lifetime of the application."""
__slots__ = ("cache", "lock", "teardowns")
def __init__(self) -> None:
self.cache: dict[str, Any] = {}
self.lock = asyncio.Lock()
self.teardowns: list[Callable] = []
async def resolve(self, name: str, registry: dict[str, Any]) -> Any:
if name in self.cache:
return self.cache[name]
async with self.lock:
return await self._resolve_locked(name, registry, [])
async def _resolve_locked(
self, name: str, registry: dict[str, Any], stack: list[str]
) -> Any:
# Runs with self.lock held; recurses via this method (never re-acquires
# the non-reentrant lock), so app-dependency graphs can't deadlock.
if name in self.cache:
return self.cache[name]
if name in stack:
path = stack[stack.index(name) :] + [name]
raise DependencyCycleError("App dependency cycle: " + " -> ".join(path))
provider, _scope = registry[name]
stack.append(name)
try:
kwargs: dict[str, Any] = {}
for pname, ann in _dep_param_specs(provider):
if _is_request_param(pname, ann, _WS_REQUEST_NAMES | _HTTP_REQUEST_NAMES):
raise DependencyScopeError(
f"App-scoped dependency {name!r} cannot receive the request."
)
if pname not in registry:
raise DependencyResolutionError(
f"App-scoped dependency {name!r}: unknown parameter {pname!r}."
)
if registry[pname][1] != "app":
raise DependencyScopeError(
f"App-scoped dependency {name!r} cannot depend on "
f"request-scoped dependency {pname!r}."
)
kwargs[pname] = await self._resolve_locked(pname, registry, stack)
finally:
stack.pop()
value, teardown = await _invoke_provider(provider, kwargs)
self.cache[name] = value
if teardown is not None:
self.teardowns.append(teardown)
return value
async def shutdown(self) -> None:
try:
while self.teardowns:
teardown = self.teardowns.pop()
try:
await teardown()
except Exception:
logger.exception("App-scoped dependency teardown failed")
finally:
self.cache.clear()
class Router:
"""The core router that dispatches incoming requests to matching routes.
Handles route matching, before/after request hooks, lifespan events,
and mounted sub-applications.
"""
def __init__(
self,
routes: list[BaseRoute] | None = None,
default_response: Callable | None = None,
before_requests: dict[str, list[Callable]] | None = None,
lifespan: Callable | None = None,
formats: dict[str, Callable] | None = None,
redirect_slashes: bool = True,
max_request_size: int | None = None,
auto_etag: bool = False,
auto_vary: bool = False,
request_timeout: float | None = None,
ws_idle_timeout: float | None = None,
trace_dispatch: bool = False,
problem_details: bool = True,
csrf: bool = False,
) -> None:
self.routes: list[BaseRoute] = [] if routes is None else list(routes)
self.apps: dict[str, Union[ASGIApp, Any]] = {}
self.default_endpoint: Callable = (
self.default_response if default_response is None else default_response
)
self.before_requests: dict[str, list[Callable]] = (
{"http": [], "ws": []} if before_requests is None else before_requests
)
self.after_requests: list[Callable] = []
self.events: defaultdict[str, list[Callable]] = defaultdict(list)
self.dependencies: dict[str, tuple[Callable, str]] = {}
# Test-time overrides: same registry shape, always request-scoped so
# they take precedence over (and bypass the cache of) any real dep.
self.dependency_overrides: dict[str, tuple[Callable, str]] = {}
self.app_dependencies = _AppDependencyState()
self.api: Any = None # Set by API.__init__; reaches views as req.api.
self.redirect_slashes = redirect_slashes
self.max_request_size = max_request_size
self.auto_etag = auto_etag
self.auto_vary = auto_vary
self.request_timeout = request_timeout
self.ws_idle_timeout = ws_idle_timeout
self.trace_dispatch = trace_dispatch
self.problem_details = problem_details
self.csrf = csrf
self._route_cache: dict[tuple[str, str], tuple[BaseRoute, dict]] = {}
# Bumped whenever the route table changes; cheap invalidation key for
# derived artifacts (e.g. the cached OpenAPI document).
self._generation = 0
self.formats: dict[str, Callable] = get_formats() if formats is None else formats
self._lifespan_handler = lifespan
# Route wrapper for a Responder-view default endpoint (built lazily).
self._default_route: Route | None = None
# Prepared (normalized prefix, ASGI app) mounts, rebuilt when
# ``self.apps`` changes — see ``_sorted_mounts``.
self._mounts: list[tuple[str, Any]] = []
self._mounts_snapshot: tuple | None = None
def add_route(
self,
route: str | None = None,
endpoint: Callable | None = None,
*,
default: bool = False,
websocket: bool = False,
before_request: bool = False,
check_existing: bool = False,
methods: list[str] | None = None,
name: str | None = None,
) -> None:
"""Adds a route to the router.
:param route: A string representation of the route
:param endpoint: The endpoint for the route -- can be callable, or class.
:param default: If ``True``, all unknown requests will route to this view.
:param methods: Optional list of HTTP methods (e.g. ["GET", "POST"]).
"""
if endpoint is None:
raise ValueError("An endpoint is required to add a route")
if before_request:
if websocket:
self.before_requests.setdefault("ws", []).append(endpoint)
else:
self.before_requests.setdefault("http", []).append(endpoint)
return
if route is None:
raise ValueError("A route path is required to add a route")
if check_existing:
new_methods = {m.upper() for m in methods} if methods else None
for item in self.routes:
if item.route != route:
continue
# Same path is allowed only for HTTP routes whose methods are
# disjoint — e.g. @api.get and @api.post on one path. A
# method-less route (or a WebSocket route) answers
# unconditionally, so it always conflicts.
existing_methods = getattr(item, "methods", None)
if (
not websocket
and isinstance(item, Route)
and new_methods is not None
and existing_methods is not None
and new_methods.isdisjoint(existing_methods)
):
continue
raise ValueError(f"Route '{route}' already exists")
if default:
self.default_endpoint = endpoint
new_route: BaseRoute
if websocket:
new_route = WebSocketRoute(route, endpoint, name=name)
else:
new_route = Route(route, endpoint, methods=methods, name=name)
# Freeze this registration's per-route CSRF override onto the Route
# itself. Reading it off the endpoint at dispatch time would leak the
# value across re-registrations of a shared function, or inherit it
# through a CBV's MRO. For classes, read the class's own __dict__ so a
# subclass never picks up a base's exemption. ``None`` = inherit the
# app-wide default.
if inspect.isclass(endpoint):
new_route._csrf = endpoint.__dict__.get("_csrf")
route_response_models = endpoint.__dict__.get("_response_models", {})
else:
new_route._csrf = getattr(endpoint, "_csrf", None)
route_response_models = getattr(endpoint, "_response_models", {})
if isinstance(new_route, Route):
new_route._response_models = dict(route_response_models or {})
new_route._stream_mode = getattr(endpoint, "_stream_mode", None)
new_route._stream_model = getattr(endpoint, "_stream_model", None)
new_route._stream_heartbeat = getattr(endpoint, "_stream_heartbeat", None)
self.routes.append(new_route)
self._route_cache.clear()
self._generation += 1
def mount(self, route: str, app: Any) -> None:
"""Mounts ASGI / WSGI applications at a given route.
The prefix is normalized (a trailing slash is stripped, so
``/admin/`` and ``/admin`` are equivalent; ``""`` mounts at the
root), and a WSGI app is wrapped for ASGI dispatch once, here,
rather than per request.
"""
route = route.rstrip("/")
if _is_wsgi_app(app):
from a2wsgi import WSGIMiddleware
app = WSGIMiddleware(app)
self.apps.update({route: app})
def add_event_handler(self, event_type: str, handler: Callable) -> None:
if event_type not in ("startup", "shutdown"):
raise ValueError(
f"Only 'startup' and 'shutdown' events are supported, not {event_type!r}."
)
self.events[event_type].append(handler)
async def trigger_event(self, event_type: str) -> None:
for handler in self.events.get(event_type, []):
if inspect.iscoroutinefunction(handler):
await handler()
else:
# Run blocking startup/shutdown handlers off the event loop.
await run_in_threadpool(handler)
def add_dependency(
self, name: str, provider: Callable, scope: str = "request"
) -> None:
"""Register a dependency provider, injectable into views by parameter name.
:param scope: ``"request"`` (resolved per request, the default) or
``"app"`` (resolved once, torn down at shutdown).
"""
if scope not in ("request", "app"):
raise ValueError(
f"Dependency scope must be 'request' or 'app', not {scope!r}"
)
if name in _RESERVED_DEP_NAMES:
raise ValueError(
f"Dependency name {name!r} is reserved (req/request/resp/response/"
f"ws/websocket)."
)
if scope == "app":
# App-scoped providers may depend on other (app-scoped) providers,
# but never on the request — they outlive any single request.
for pname, ann in _dep_param_specs(provider):
if _is_request_param(pname, ann, _WS_REQUEST_NAMES | _HTTP_REQUEST_NAMES):
raise ValueError(
"App-scoped dependency providers cannot receive the "
"request — they outlive any single request."
)
self.dependencies[name] = (provider, scope)
def before_request(self, endpoint: Callable, websocket: bool = False) -> None:
if websocket:
self.before_requests.setdefault("ws", []).append(endpoint)
else:
self.before_requests.setdefault("http", []).append(endpoint)
def after_request(self, endpoint: Callable) -> None:
self.after_requests.append(endpoint)
def url_for(self, endpoint: Callable | str, **params: Any) -> str:
# An explicit route name wins (decouples reversal from function identity,
# so lambdas and shared function names are addressable).
if isinstance(endpoint, str):
for route in self.routes:
if getattr(route, "name", None) == endpoint:
return route.url(**params)
for route in self.routes:
# Callable-instance endpoints (e.g. class-based views registered as
# objects) have no __name__; never let a missing name match anything.
endpoint_name = getattr(route.endpoint, "__name__", None)
if endpoint == route.endpoint or (
endpoint_name is not None and endpoint == endpoint_name
):
return route.url(**params)
raise RouteNotFoundError(f"No route is registered for {endpoint!r}")
async def default_response(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "websocket":
websocket_close = WebSocketClose()
await websocket_close(scope, receive, send)
return
raise HTTPException(status_code=status_codes.HTTP_404)
def _resolve_route(self, scope: Scope) -> BaseRoute | None:
key = (scope.get("method", "ws"), scope["path"])
cached = self._route_cache.get(key)
if cached is not None:
# LRU: re-insert on hit so hot entries survive eviction (below).
self._route_cache.pop(key, None)
self._route_cache[key] = cached
route, child_scope = cached
# Copy path_params so per-request mutation can't poison the cache.
scope.update(_fresh_child_scope(child_scope))
scope["route_pattern"] = getattr(route, "path_template", route.route)
return route
for route in self.routes:
matches, child_scope = route.matches(scope)
if matches:
scope.update(_fresh_child_scope(child_scope))
scope["route_pattern"] = getattr(route, "path_template", route.route)
if len(self._route_cache) >= 1024:
# Evict the least-recently-used entry instead of clearing
# wholesale, so high-cardinality parameterized paths can't
# thrash the hot static entries.
self._route_cache.pop(next(iter(self._route_cache)), None)
self._route_cache[key] = (route, _fresh_child_scope(child_scope))
return route
return None
async def lifespan(self, scope: Scope, receive: Receive, send: Send) -> None:
message = await receive()
assert message["type"] == "lifespan.startup"
if self._lifespan_handler is not None:
# Modern lifespan context manager pattern. Expose the API as
# scope["app"] so a standard ``async def lifespan(app)`` receives
# the application instead of None. on_event handlers still fire
# (LIFO around the context manager): startup events run after
# ``__aenter__``, shutdown events before ``__aexit__`` — so e.g.
# the API's background-task draining isn't silently skipped.
scope["app"] = self.api
try:
ctx = self._lifespan_handler(scope["app"])
await ctx.__aenter__()
except BaseException:
msg = traceback.format_exc()
await send({"type": "lifespan.startup.failed", "message": msg})
raise
try:
await self.trigger_event("startup")
except BaseException:
msg = traceback.format_exc()
try:
await ctx.__aexit__(*sys.exc_info())
except Exception:
logger.exception("Lifespan exit failed after startup error")
await send({"type": "lifespan.startup.failed", "message": msg})
raise
await send({"type": "lifespan.startup.complete"})
message = await receive()
assert message["type"] == "lifespan.shutdown"
try:
try:
await self.trigger_event("shutdown")
finally:
await ctx.__aexit__(None, None, None)
except BaseException:
# A raising __aexit__ (or shutdown event) must still reach
# app-dependency teardown and report the failure.
msg = traceback.format_exc()
await self.app_dependencies.shutdown()
await send({"type": "lifespan.shutdown.failed", "message": msg})
raise
await self.app_dependencies.shutdown()
else:
# Legacy on_event("startup") / on_event("shutdown") pattern
try:
await self.trigger_event("startup")
except BaseException:
msg = traceback.format_exc()
await send({"type": "lifespan.startup.failed", "message": msg})
raise
await send({"type": "lifespan.startup.complete"})
message = await receive()
assert message["type"] == "lifespan.shutdown"
try:
await self.trigger_event("shutdown")
except BaseException:
# A raising shutdown handler must still reach app-dependency
# teardown and report the failure (mirrors the lifespan= path).
msg = traceback.format_exc()
await self.app_dependencies.shutdown()
await send({"type": "lifespan.shutdown.failed", "message": msg})
raise
await self.app_dependencies.shutdown()
await send({"type": "lifespan.shutdown.complete"})
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
assert scope["type"] in ("http", "websocket", "lifespan")
if scope["type"] == "lifespan":
await self.lifespan(scope, receive, send)
return
await self._dispatch(scope, receive, send)
async def _dispatch(self, scope: Scope, receive: Receive, send: Send) -> None:
path = scope["path"]
root_path = scope.get("root_path", "")
# Check "primary" mounted routes first (before submounted apps)
route = self._resolve_route(scope)
scope["before_requests"] = self.before_requests
scope["after_requests"] = self.after_requests
scope["dependencies"] = (
{**self.dependencies, **self.dependency_overrides}
if self.dependency_overrides
else self.dependencies
)
scope["dependency_override_names"] = frozenset(self.dependency_overrides)
scope["app_dependencies"] = self.app_dependencies
scope["formats"] = self.formats
scope["api"] = self.api
scope["max_request_size"] = self.max_request_size
scope["auto_etag"] = self.auto_etag
scope["auto_vary"] = self.auto_vary
scope["request_timeout"] = self.request_timeout
scope["ws_idle_timeout"] = self.ws_idle_timeout
scope["trace_dispatch"] = self.trace_dispatch
scope["problem_details"] = self.problem_details
scope["csrf_enabled"] = self.csrf
if route is not None:
await route(scope, receive, send)
return
# Call into a submounted app, if one exists. Longer (more specific)
# prefixes win, and a prefix only matches on a path-segment boundary
# so that, e.g., "/subscribe" is not mis-routed into a mount at "/sub"
# (the empty-prefix root catch-all still matches everything).
for path_prefix, app in self._sorted_mounts():
if path == path_prefix or path.startswith(path_prefix + "/"):
scope["path"] = path[len(path_prefix) :] or "/"
scope["root_path"] = root_path + path_prefix
await app(scope, receive, send)
return
# A near-miss on the trailing slash gets redirected to the real route,
# preserving the method and query string (307).
if scope["type"] == "http" and self.redirect_slashes and path != "/":
alternate = path[:-1] if path.endswith("/") else path + "/"
# Match by path only (not method): a POST to the slashed variant
# redirects just like a GET — 307 preserves the method, and the
# follow-up request earns the proper 405 + Allow if needed.
if any(
route.path_re.match(alternate)
for route in self.routes
if isinstance(route, Route)
):
query_string = scope.get("query_string", b"")
# Percent-encode the decoded path so the Location header is
# latin-1 safe (same safe set as Starlette's RedirectResponse).
location = urllib.parse.quote(alternate, safe="/:@&=+$,;~*!')(") + (
f"?{query_string.decode('latin-1')}" if query_string else ""
)
redirect = StarletteResponse(
status_code=307, headers={"Location": location}
)
await redirect(scope, receive, send)
return
# The path exists but no route accepts this method: answer OPTIONS
# with the allowed methods, and everything else with 405.
if scope["type"] == "http":
allowed = self._allowed_methods(path)
if allowed:
headers = {"Allow": ", ".join(sorted(allowed))}
response: StarletteResponse
if scope.get("method", "").upper() == "OPTIONS":
response = StarletteResponse(status_code=200, headers=headers)
elif self.problem_details or _accepts_json(scope):
content = _error_payload(scope, status_codes.HTTP_405)
media_type = (
PROBLEM_JSON if self.problem_details else "application/json"
)
response = JSONResponse(
content,
status_code=status_codes.HTTP_405,
headers=headers,
media_type=media_type,
)
else:
response = StarletteResponse(
content="Method Not Allowed",
status_code=status_codes.HTTP_405,
headers=headers,
)
await response(scope, receive, send)
return
await self._dispatch_default(scope, receive, send)
def _sorted_mounts(self) -> list[tuple[str, Any]]:
"""Mounted apps as ``(normalized prefix, ASGI app)``, longest first.
``self.apps`` may also be populated directly (``API.mount`` does), so
prefix normalization and one-time WSGI wrapping are (re)applied here
whenever the registry changes — never per request, since a fresh
``WSGIMiddleware`` costs a ``ThreadPoolExecutor`` per instance.
"""
snapshot = tuple((prefix, id(app)) for prefix, app in self.apps.items())
if snapshot != self._mounts_snapshot:
mounts: list[tuple[str, Any]] = []
for prefix, app in self.apps.items():
if _is_wsgi_app(app):
from typing import cast
from a2wsgi import WSGIMiddleware
app = WSGIMiddleware(cast(Any, app))
mounts.append((prefix.rstrip("/"), app))
mounts.sort(key=lambda kv: len(kv[0]), reverse=True)
self._mounts = mounts
self._mounts_snapshot = snapshot
return self._mounts
async def _dispatch_default(self, scope: Scope, receive: Receive, send: Send) -> None:
"""Invoke the default endpoint for an unmatched request.
The built-in default (and any user-supplied ASGI-style callable) is
invoked directly. A Responder view — e.g. one registered via
``add_route(default=True)`` — is dispatched through normal
:class:`Route` semantics (hooks, response handling) with empty
``path_params``.
"""
endpoint = self.default_endpoint
if _is_asgi_style(endpoint):
await endpoint(scope, receive, send)
return
if scope["type"] == "websocket":
# A Responder HTTP view can't answer a websocket handshake;
# close it exactly like the built-in default.
await WebSocketClose()(scope, receive, send)
return
route = self._default_route
if route is None or route.endpoint is not endpoint:
route = self._default_route = Route("/", endpoint)
scope["path_params"] = {}
await route(scope, receive, send)
def _allowed_methods(self, path: str) -> set[str]:
"""The union of methods accepted by method-restricted routes matching ``path``."""
allowed: set[str] = set()
for route in self.routes:
if isinstance(route, Route) and route.methods and route.path_re.match(path):
allowed.update(route.methods)
if allowed:
if "GET" in allowed:
allowed.add("HEAD")
allowed.add("OPTIONS")
return allowed