From 65c24c9fbaa7c3e13c4af83c2382a0e23f9046e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Kali=C5=84ski?= Date: Wed, 2 Sep 2026 09:45:10 +0200 Subject: [PATCH] Add Starlette/FastAPI support --- AGENTS.md | 4 +- docs/features/context-presets.md | 4 +- docs/features/fastapi.md | 225 ++++ docs/features/http-client.md | 49 +- docs/features/opentelemetry.md | 14 + docs/features/starlette.md | 571 ++++++++++ docs/getting-started/installation.md | 17 +- llms.txt | 59 +- mkdocs.yml | 2 + pyproject.toml | 4 + src/haiway/context/access.py | 30 +- src/haiway/context/disposables.py | 50 +- src/haiway/context/identifier.py | 15 +- src/haiway/context/observability.py | 95 +- src/haiway/context/presets.py | 88 +- src/haiway/context/scope.py | 235 +++- src/haiway/fastapi/__init__.py | 30 + src/haiway/fastapi/application.py | 115 ++ src/haiway/fastapi/types.py | 13 + src/haiway/helpers/http_client.py | 196 ++-- src/haiway/httpx/client.py | 4 +- src/haiway/opentelemetry/observability.py | 20 + src/haiway/starlette/__init__.py | 24 + src/haiway/starlette/application.py | 98 ++ src/haiway/starlette/context.py | 315 ++++++ src/haiway/starlette/middleware.py | 250 +++++ src/haiway/starlette/streaming.py | 154 +++ src/haiway/starlette/trace.py | 76 ++ src/haiway/starlette/types.py | 38 + src/haiway/utils/__init__.py | 3 + src/haiway/utils/context.py | 63 ++ tests/asgi.py | 179 +++ tests/test_context_presets.py | 24 +- tests/test_fastapi.py | 486 ++++++++ tests/test_http_client.py | 205 +--- tests/test_opentelemetry.py | 21 + tests/test_optional_extras_guard.py | 2 + tests/test_starlette.py | 1242 +++++++++++++++++++++ tests/test_starlette_opentelemetry.py | 436 ++++++++ tests/test_starlette_streaming.py | 449 ++++++++ uv.lock | 132 ++- 41 files changed, 5578 insertions(+), 459 deletions(-) create mode 100644 docs/features/fastapi.md create mode 100644 docs/features/starlette.md create mode 100644 src/haiway/fastapi/__init__.py create mode 100644 src/haiway/fastapi/application.py create mode 100644 src/haiway/fastapi/types.py create mode 100644 src/haiway/starlette/__init__.py create mode 100644 src/haiway/starlette/application.py create mode 100644 src/haiway/starlette/context.py create mode 100644 src/haiway/starlette/middleware.py create mode 100644 src/haiway/starlette/streaming.py create mode 100644 src/haiway/starlette/trace.py create mode 100644 src/haiway/starlette/types.py create mode 100644 src/haiway/utils/context.py create mode 100644 tests/asgi.py create mode 100644 tests/test_fastapi.py create mode 100644 tests/test_starlette.py create mode 100644 tests/test_starlette_opentelemetry.py create mode 100644 tests/test_starlette_streaming.py diff --git a/AGENTS.md b/AGENTS.md index c016847a..0db3a670 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,10 +16,12 @@ Haiway is a Python framework helping to build high-quality codebases. It focuses - `attributes/`: Attribute annotations, state objects, and validation helpers. - `context/`: Structured-concurrency context management, scopes, and lifecycle utilities. - `helpers/`: Cross-cutting helpers for configuration, async orchestration, retries, throttling, HTTP adapters, file access, and message queues. + - `fastapi/`: FastAPI application factory reusing the Starlette integration pieces, built on top of `fastapi`. - `httpx/`: Thin wrappers around `httpx2.AsyncClient` aligned with haiway abstractions. - `opentelemetry/`: OpenTelemetry integration and observability backend wiring. - `postgres/`: Async Postgres client, configuration, and typed row/state helpers built on top of `asyncpg` patterns. - `rabbitmq/`: Async RabbitMQ client/state helpers and typed queue/message abstractions built on top of `pika`. + - `starlette/`: Starlette integration plugging the context into ASGI request handling - application context, middleware, and application factory built on top of `starlette`. - `types/`: Fundamental typed primitives (e.g., `Missing`, immutable containers) shared across modules. - `utils/`: Generic async utilities (queues, streams, env helpers, logging bootstrap, metadata helpers). - `tests/`: Pytest suite mirroring package structure; keep new tests alongside the code they cover. @@ -27,7 +29,7 @@ Haiway is a Python framework helping to build high-quality codebases. It focuses - `Makefile`: Entry point for common dev tasks (`format`, `lint`, `test`, `docs`, `docs-format`, `docs-lint`, `sync`, `update`). - `pyproject.toml`: Project metadata, dependencies, and configuration. -Core public exports are centralized in `src/haiway/__init__.py`. Optional integration packages also expose public APIs from their own subpackages such as `haiway.postgres`, `haiway.rabbitmq`, and `haiway.opentelemetry`. +Core public exports are centralized in `src/haiway/__init__.py`. Optional integration packages also expose public APIs from their own subpackages such as `haiway.postgres`, `haiway.rabbitmq`, `haiway.starlette`, `haiway.fastapi`, and `haiway.opentelemetry`. ## Style & Patterns diff --git a/docs/features/context-presets.md b/docs/features/context-presets.md index fe62b4e7..06589386 100644 --- a/docs/features/context-presets.md +++ b/docs/features/context-presets.md @@ -250,7 +250,9 @@ with ctx.presets(dev_preset, prod_preset, staging_preset): Direct presets passed to `ctx.scope(preset)` take precedence over registry lookup. Registry lookup is scoped through `ctx.presets(...)`, and a nested registry shadows the outer registry for the -duration of that nested `with` block. +duration of that nested `with` block - a name is resolved against the innermost registry only, +without falling back to the outer one. Entering `ctx.presets()` with no presets is a no-op, leaving +whatever registry is already active in place. ## Best Practices diff --git a/docs/features/fastapi.md b/docs/features/fastapi.md new file mode 100644 index 00000000..67607e07 --- /dev/null +++ b/docs/features/fastapi.md @@ -0,0 +1,225 @@ +# FastAPI + +Haiway provides a FastAPI variant of the [Starlette](starlette.md) integration. It is the same +integration with a FastAPI base: the request context is declared through the same `ServerContext`, +requests are handled within scopes by the same `ContextMiddleware`, and `haiway.fastapi.application` +wires both into a `FastAPI` application instead of a plain Starlette one. + +## Overview + +- **FastAPI Base**: returns a regular `FastAPI` application - routers, dependencies, OpenAPI docs + and everything else work as they always do +- **Shared Context**: `ServerContext` and `ContextMiddleware` are the Starlette ones, re-exported + from `haiway.fastapi` so a single import is enough +- **Scope per Request**: state declared by the server context is available to endpoints, + dependencies, background tasks and nested middlewares + +## Installation + +Install the FastAPI extra, which brings Starlette along with it: + +```bash +pip install "haiway[fastapi]" +``` + +## Quick Start + +Declare the application context, then build the application from it: + +```python +from fastapi import APIRouter +from haiway import State, ctx +from haiway.fastapi import ServerContext, application +from haiway.httpx import HTTPXClient + + +class ServiceConfig(State): + upstream: str = "https://example.com" + + +router = APIRouter(prefix="/api/v1", tags=["example"]) + + +@router.get("/status") +async def status() -> dict[str, str]: + # state declared by the server context, resolved from the request scope + config: ServiceConfig = ctx.state(ServiceConfig) + ctx.log_info("Checking %s", config.upstream) + + return {"upstream": config.upstream} + + +app = application( + ServerContext( + ServiceConfig(), + disposables=(HTTPXClient(),), + ), + routers=(router,), + title="Example API", + version="1.0.0", + openapi_url="/openapi.json" if __debug__ else None, + docs_url="/swagger" if __debug__ else None, +) +``` + +Serve it with any ASGI server: + +```bash +uvicorn example:app +``` + +## Differences from the Starlette Factory + +Everything about the request context - state, disposables, presets, observability, scope names and +trace headers - is described on the [Starlette](starlette.md) page and applies here unchanged. Only +the application factory differs: + +- `routers` takes `APIRouter` instances, included in order, instead of `routes` taking Starlette + routes. A router serving under a path prefix carries it itself, as `APIRouter(prefix="/api/v1")`, + and further routers can be added afterwards through `app.include_router(...)` +- everything FastAPI accepts and Starlette does not - `title`, `version`, `description`, + `openapi_url`, `docs_url`, global `dependencies`, `root_path` - is passed through as keyword + arguments +- `exception_handlers` accepts async and sync handlers alike - a sync one is called in a worker + thread - and the type `ExceptionHandling` names their signature. A handler nested below the + middleware - the validation error handler of FastAPI included - answers within the scope of its + request, so its response carries the trace headers, while the `Exception` and `500` slots run + above it and carry none + +`middleware`, `lifespan` and `debug` work exactly as in the Starlette factory, and +`ContextMiddleware` is installed as the outermost application middleware. There is no +`max_body_size` here - FastAPI does not accept one, so passing it through would fail; a body limit +takes a middleware of its own. + +## Distributed Traces + +Requests continue the trace they arrive with, which takes passing the OpenTelemetry backend factory +to the context - the W3C trace context of each request is read from its headers and handed to it: + +```python +from haiway.opentelemetry import OpenTelemetry + +context = ServerContext(observability=OpenTelemetry.observability) +``` + +Responses then report which trace handled them, through the `trace-id`, `traceparent` and +`tracestate` headers, and an outgoing request carries it onwards when it asks to: + +```python +context = ServerContext( + observability=OpenTelemetry.observability, + disposables=(HTTPXClient(base_url=INTERNAL_URL),), +) + +# within an endpoint - the trace continues into the service you own +response = await HTTPClient.get(url="/users", trace_propagation=True) +``` + +See [Observability](starlette.md#observability) for what is resolved before the backend sees it, and +for configuring the OpenTelemetry integration itself. + +## Dependencies and Endpoints + +The context scope is entered before routing, so everything FastAPI runs while handling a request +runs within it: + +```python +from typing import Annotated + +from fastapi import Depends, Header + + +class Caller(State): + identifier: str + + +async def caller(authorization: Annotated[str, Header()]) -> Caller: + # state resolved from the scope of the request being handled + return await Authorization.verify(authorization) + + +@router.get("/profile") +async def profile(caller: Annotated[Caller, Depends(caller)]) -> dict[str, str]: + return {"identifier": caller.identifier} +``` + +That covers dependencies, endpoints, background tasks added through `BackgroundTasks`, and the +generator of a streaming response. Synchronous endpoints are included as well - FastAPI runs them in +a worker thread with the context of the request copied into it, so `ctx.state(...)` resolves there +too. Long blocking work still belongs off the event loop path: prefer async endpoints and Haiway's +`@asynchronous` helper for the calls which cannot be. + +## Streaming Responses + +`StreamResponse` is re-exported here and returned from an endpoint like any other response. The +scope of the request stays entered until the last chunk was sent, so the producer resolves state and +reports the trace of the request it belongs to: + +```python +from collections.abc import AsyncGenerator + +from haiway.fastapi import StreamResponse + + +@router.get("/updates") +async def updates() -> StreamResponse: + async def content() -> AsyncGenerator[bytes]: + async for update in Updates.subscribe(): # state of the request + yield update.payload.encode() + + return StreamResponse(content(), media_type="application/x-ndjson") +``` + +Returning a `Response` from a FastAPI endpoint skips its serialization, so annotate the return type +with the response itself - or with `Response` - rather than with the model of a chunk. See +[Streaming Responses](starlette.md#streaming-responses) for what closes a stream and for the scope a +producer of its own needs. + +## Existing Applications + +An application which is already built is plugged in by installing the same two pieces by hand: + +```python +from fastapi import FastAPI +from haiway.fastapi import ContextMiddleware, ServerContext + +context = ServerContext(disposables=(HTTPXClient(),)) + +app = FastAPI(lifespan=context.lifespan) +app.add_middleware(ContextMiddleware, context=context) +``` + +`add_middleware` puts the middleware in front of the ones added before it, so adding it last keeps +it outermost - which is what makes the context available to the other middlewares as well. When the +application already has a lifespan of its own, compose the two: + +```python +app = FastAPI(lifespan=context.composed_lifespan(existing_lifespan)) +``` + +## Testing + +An application built this way is tested like any other FastAPI application, entering its lifespan +first so the context is prepared. A context backs a single run, so build the application within the +test - through a factory - rather than entering the lifespan of a shared one more than once. +`TestClient` requires `httpx2`, installed with the `httpx` extra: + +```python +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +def build_application() -> FastAPI: # a context per run, so an application per test + return application( + ServerContext(ServiceConfig(), disposables=(HTTPXClient(),)), + routers=(router,), + ) + + +def test_status() -> None: + with TestClient(build_application()) as client: # enters the lifespan + response = client.get("/api/v1/status") + + assert response.status_code == 200 + assert response.headers["trace-id"] +``` diff --git a/docs/features/http-client.md b/docs/features/http-client.md index 94597f28..f80400d8 100644 --- a/docs/features/http-client.md +++ b/docs/features/http-client.md @@ -350,11 +350,23 @@ a `Content-Length`. It can be consumed only once, which means it cannot be repla preserving the method (307, 308) or a retry fails with `HTTPClientError`. Pass `bytes` instead when the request has to survive either. +A streamed payload stays owned by whoever passed it. The request reads it while it is in flight and +never closes it, so a request that fails before the upload finished - or a server that answers early +\- leaves the generator open. Close it at the call site when the generator holds anything worth +releasing: + +```python +from contextlib import aclosing + +async with aclosing(encoded_rows(pending_events())) as body: + response = await HTTPClient.put(url="/uploads/events", body=body) +``` + Buffered payloads are `bytes`, not `str` - `HTTPBody` is `AsyncGenerator[bytes] | bytes`, so text is encoded at the call site and the charset is never guessed for you. It has to be a full async -generator rather than any async iterable, because a streamed body is closed once it is read or -abandoned and only a generator has `aclose`. Wrap a bare async iterator in an `async def` generator -that yields from it. +generator rather than any async iterable, because a streamed response body is closed once it is read +or abandoned and only a generator has `aclose`. Wrap a bare async iterator in an `async def` +generator that yields from it. ### Connection Pooling and Reuse @@ -427,26 +439,30 @@ A few properties worth knowing: ### Trace Propagation A request can carry the current trace position, so the called service continues this trace instead -of starting its own. It is asked for per request, and off by default: +of starting its own. It is asked for per request: ```python async with ctx.scope( "api", observability=OpenTelemetry.observability(), - disposables=(HTTPXClient(base_url="https://internal.example.com"),), + disposables=(HTTPXClient(),), ): - # this request carries `traceparent`, and `tracestate` when present - response = await HTTPClient.get(url="/users", trace_propagation=True) + # carries `traceparent`, and `tracestate` when present + internal = await HTTPClient.get( + url="https://internal.example.com/users", + trace_propagation=True, + ) - # this one does not - other = await HTTPClient.get(url="/public") + # left off by default, so a third party sees no trace identifiers + external = await HTTPClient.get(url="https://api.vendor.com/rates") ``` -`trace_propagation` is per request, and defaults to `False`, because it exposes internal trace -identifiers to whoever is called - ask for it towards services you own, not towards third party -APIs. Since it is decided at the request site, one client can be used for both. Headers passed to -the request are never overridden, so a caller managing trace context itself keeps control, and a -backend with no trace position to hand out - the default logger among them - propagates nothing. +The decision belongs to the request site and defaults to `False` - propagating exposes internal +trace identifiers to whoever is called, which is a choice per callee rather than per client. + +Headers passed to the request are never overridden, so a caller managing trace context itself keeps +control, and a backend with no trace position to hand out - the default logger among them - +propagates nothing, so the same code works whether or not the service is traced. ## Testing @@ -515,8 +531,9 @@ async def test_user_fetching(): 1. **Send replayable bodies where redirects or retries are expected**: A streamed request body is consumed once and cannot be sent again. 1. **Mock the `requesting` callable in tests**: Most unit tests do not need a real transport. -1. **Ask for `trace_propagation` only on requests towards services you own**: It hands internal - trace identifiers to whoever is called. +1. **Propagate the trace only towards services you own**: Ask for `trace_propagation` on requests + towards your own services, and leave it off for third-party APIs. It hands internal trace + identifiers to whoever is called. ## Custom Implementations diff --git a/docs/features/opentelemetry.md b/docs/features/opentelemetry.md index 7d3b7472..30000f90 100644 --- a/docs/features/opentelemetry.md +++ b/docs/features/opentelemetry.md @@ -304,6 +304,20 @@ trace instead. The value is decoded once, when the adapter is created. The remote parent applies to root scopes only. A nested scope entered within a spawned task stays under the span its task inherited, instead of being re-rooted at the remote parent. +Reading the headers and preparing a backend per request is what the Starlette and FastAPI +integrations do, so an ASGI application needs none of the wiring above - `observability` there takes +the callable itself: + +```python +from haiway.starlette import ServerContext + +context = ServerContext(observability=OpenTelemetry.observability) +``` + +See [Starlette](starlette.md#distributed-traces) for what it resolves from a request, and for the +cases a backend handed a single value cannot see - several `traceparent` headers, or a `tracestate` +split across a few. + To propagate the current active span to another system, use: ```python diff --git a/docs/features/starlette.md b/docs/features/starlette.md new file mode 100644 index 00000000..9c473ba6 --- /dev/null +++ b/docs/features/starlette.md @@ -0,0 +1,571 @@ +# Starlette + +Haiway provides a Starlette integration plugging the context system into request handling. It +exposes an application wide declaration of the request context through `ServerContext`, an ASGI +middleware entering a context scope per request through `ContextMiddleware`, and an application +factory wiring both together through `application`. + +## Overview + +- **Context Managed**: application resources are prepared once, on startup, and their state is + available to every request through `ctx` +- **Scope per Request**: each request is handled within its own context scope, recorded as one trace + and described the way the HTTP semantic conventions of OpenTelemetry ask for +- **Traceable Responses**: responses carry the trace identifier of their request, so a client can + report which one failed +- **Plug In Anywhere**: the middleware and the lifespan are plain Starlette pieces, so an existing + application - a FastAPI one included - can be plugged in without being rewritten + +## Installation + +Install the Starlette extra: + +```bash +pip install "haiway[starlette]" +``` + +## Quick Start + +Declare the application context, then build the application from it: + +```python +from haiway import State, ctx +from haiway.httpx import HTTPXClient +from haiway.starlette import ServerContext, application +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route + + +class ServiceConfig(State): + upstream: str = "https://example.com" + + +async def status(request: Request) -> Response: + # state declared by the application context, resolved from the request scope + config: ServiceConfig = ctx.state(ServiceConfig) + ctx.log_info("Checking %s", config.upstream) + + return JSONResponse({"upstream": config.upstream}) + + +app = application( + ServerContext( + ServiceConfig(), + disposables=(HTTPXClient(),), + ), + routes=[Route("/status", status)], +) +``` + +Serve it with any ASGI server: + +```bash +uvicorn example:app +``` + +## Server Context + +`ServerContext` describes what a request context looks like. It is used by two parts of the +application, both wired by `application(...)`: + +- its `lifespan` prepares the declared disposables on startup and releases them on shutdown +- `ContextMiddleware` enters a context scope per request, carrying the prepared state + +State comes from two places. Instances passed positionally are propagated as they are, which fits +configuration and other state owning no resources. Everything requiring setup or cleanup belongs in +`disposables`, prepared on startup and released on shutdown, whose state is propagated the same way: + +```python +context = ServerContext( + ServiceConfig(), # propagated as provided + disposables=( # prepared on startup, released on shutdown + HTTPXClient(), + PostgresConnectionPool(), + ), +) +``` + +Both accept `None` among their elements and ignore it, which keeps a conditionally provided element +from requiring a branch. State declared directly takes precedence over state prepared by the +disposables when both provide the same type. + +The disposables are the instances declared here, prepared once, so a single context backs a single +run of a single application: a lifespan which already ended cannot be entered again, and a test +exercising more than one run declares a context per run. Resources which have to be created within +the running event loop belong inside a disposable's `__aenter__` rather than in its constructor. + +The lifespan is what a request scope is built from, so it is not optional. Requests reaching an +application whose lifespan was not installed - or arriving before its startup completed - fail +rather than being served with a silently incomplete scope. + +## Existing Applications + +An application which is already built is plugged in by installing the same two pieces by hand: + +```python +from haiway.starlette import ContextMiddleware, ServerContext +from starlette.applications import Starlette + +context = ServerContext(disposables=(HTTPXClient(),)) + +app = Starlette(routes=[...], lifespan=context.lifespan) +app.add_middleware(ContextMiddleware, context=context) +``` + +`add_middleware` puts the middleware in front of the ones added before it, so adding it last keeps +it outermost - which is what makes the context available to the other middlewares as well. When the +application already has a lifespan of its own, compose the two with +`context.composed_lifespan(existing_lifespan)`. + +FastAPI applications work the same way, and there is a factory building one directly - see +[FastAPI](fastapi.md). + +## Request Handling + +`ContextMiddleware` affects `http` and `websocket` requests, passing everything else - `lifespan` +included - through untouched. For each request it: + +- enters a context scope named after the request - the method and the requested path, as in + `GET /users/12345`, with `WS` in place of the method for a websocket connection, which carries + none. The middleware runs before routing, so the route template behind that path is not available + where the scope starts; it is recorded as an attribute once it is + +- records the request into its scope as the HTTP semantic conventions of OpenTelemetry describe it, + once it was handled and both the route it matched and the status it was answered with are known: + + | attribute | value | + | --------------------------- | ------------------------------------------------------------------ | + | `http.request.method` | the method as received, for an `http` request only | + | `http.route` | the route template, when the routing left one in the request scope | + | `url.path` | the requested path | + | `url.scheme` | the scheme of the request | + | `network.protocol.version` | the HTTP version | + | `http.response.status_code` | the status of the response, when one was started | + + `http.route` is what makes the requests of a parameterized route findable as one, since the scope + name carries the path which was actually requested rather than the template behind it. FastAPI + leaves the matched route in the request scope, so a FastAPI application records it; Starlette does + not, so a plain Starlette application records none unless it reports one itself with + `ctx.record_info(attributes={"http.route": ...})` from within the request. Resolving it in the + middleware would mean matching the route table a second time for every request. The query string + and the address of the caller are deliberately left out - the first carries credentials often + enough that recording it by default would leak them, the second identifies the caller + +- adds the trace headers to the response: `trace-id` holding the trace identifier of the request + scope, accompanied by `traceparent` and `tracestate` when the observability backend provides them. + An entry which a header cannot hold - a line break, a character outside latin-1 - is left out + rather than written, since the trace context is partly continued from the request. For a websocket + request the response is the one denying its handshake - an accepted connection switches the + protocol rather than answering, so it carries no headers of its own + +- lets an exception no handler answered propagate through the scope of its request, which is what + records it as the failure of that request, then reraises it. Answering it is left to the + application: the server error handling of the framework sits above the middleware, so the `500` it + produces is what the client receives - the plain one, the traceback page of a `debug` application, + or the response of a handler registered for `Exception` or `500`. None of them carry the trace + headers, having been produced outside the scope of the request + +`HTTPException`, `WebSocketException` and `ClientDisconnect` are not a failure of the request they +end - the first two are how an application asks for a specific response, the third is a consumer +which went away. They are withheld while the scope of the request is left, so it does not record +them as its failure, then reraised for whatever handles them upstream. + +Everything handling the request runs within its scope: the middlewares nested below, the endpoint, +its background tasks and the generator of a streaming response. + +## Request Derived State + +State which depends on the request - the identity of its caller, a tenant, a locale - is added by a +middleware nested within the context: + +```python +from starlette.datastructures import Headers +from starlette.middleware import Middleware +from starlette.types import ASGIApp, Receive, Scope, Send + + +class Caller(State): + identifier: str + + +class CallerMiddleware: + def __init__(self, app: ASGIApp, /) -> None: + self.app: ASGIApp = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] not in ("http", "websocket"): + return await self.app(scope, receive, send) + + # extends the state of the current request scope + with ctx.updating(Caller(identifier=Headers(scope=scope).get("x-caller", "anonymous"))): + await self.app(scope, receive, send) + + +app = application( + context, + routes=[...], + middleware=[Middleware(CallerMiddleware)], +) +``` + +## Streaming Responses + +The scope of a request stays entered until its response is finished, so a response streamed from an +async generator resolves state, records observability and reports the trace of the request it +belongs to while it is being produced - the middleware returns only once the last chunk was sent. + +`StreamResponse` streams the chunks of a generator: + +```python +from collections.abc import AsyncGenerator + +from haiway.starlette import StreamResponse + + +async def export(request: Request) -> Response: + async def rows() -> AsyncGenerator[bytes]: + async for row in Postgres.fetch_rows(QUERY): # state of the request + yield row.get_str("payload").encode() + b"\n" + + return StreamResponse(rows(), media_type="application/x-ndjson") +``` + +Any live feed is streamed the same way - a server sent events feed by yielding the event stream +format and declaring the headers such a feed needs: + +```python +async def updates(request: Request) -> Response: + async def events() -> AsyncGenerator[bytes]: + async for update in Updates.subscribe(): + yield f"event: update\ndata: {update.payload}\n\n".encode() + + return StreamResponse( + events(), + media_type="text/event-stream", + headers={ + # a live feed is not cached, and not buffered by a reverse proxy + "cache-control": "no-store", + "x-accel-buffering": "no", + }, + ) +``` + +### Closing a Stream + +The response takes a full async generator, not any async iterable, and closes it where the streaming +ends - when it ran out and when the consumer went away. That is what a generator holding resources +needs: an abandoned generator is otherwise finalized by the garbage collector, in a fresh context, +where a scope it opened can no longer be released. + +Closing is what runs the cleanup of the generator, so it has to be able to await - releasing a +connection or leaving a scope usually does. That requires a server advertising ASGI spec version 2.4 +or newer, the one reporting a gone consumer by failing the send: below that version the framework +ends a streamed response by cancelling it, and the cancellation would be delivered again at the +first await of the cleanup, leaving the generator suspended halfway through it. + +A generator opening a scope of its own has to keep it inside itself, which is what `ctx.stream` +provides: + +```python +async def produce() -> AsyncGenerator[bytes]: + async with ctx.scope("updates", disposables=(Subscription(),)): + async for update in Updates.subscribe(): + yield update.payload.encode() + + +async def updates(request: Request) -> Response: + # the scope lives inside the generator, so it spans the whole response + return StreamResponse(ctx.stream(produce)) +``` + +A scope entered around *building* the generator is already released by the time the streaming +starts: + +```python +async def updates(request: Request) -> Response: + async with ctx.scope("updates", disposables=(Subscription(),)): + content = produce() # nothing was produced yet + + return StreamResponse(content) # the subscription is already disposed +``` + +### A Failing Stream + +A failing stream cannot be answered with an error - its response already started, and its status and +headers are long gone. The status and headers are also sent before the first element is asked for, +so this holds from the very first one: a producer which fails while setting itself up still produces +a `200` with nothing in it. + +The failure is recorded where it happens, within the scope of the request, as +`Response streaming failed` carrying the exception - and only then reraised, which leaves the +response incomplete so the consumer can tell. Recording it there is what makes the actual failure +visible at all: on its way out it passes through the exception handling of the framework, which +replaces it with a `RuntimeError` about a response already started whenever a handler matches its +type, and that replacement is what the request scope would otherwise be recorded as failing with. A +failure to close the stream afterwards is recorded as a warning of its own rather than replacing +what is already on its way out. + +The consumer sees a response which ends early. Where it has to know more than that, do the work +which can fail before returning the response, and send a failure which happens later as part of the +stream - a final event saying so - before letting it propagate. + +A consumer which goes away mid-stream is not a failure of the response it ended, and is recorded as +`Response streaming ended by a disconnected consumer` at debug level rather than as an error. It +ends the request the same way whichever shape it arrives in - as a cancelled response below ASGI +spec version 2.4, and as a `ClientDisconnect` from the send above it - so a feed a consumer +eventually leaves does not turn every connection into a failed request. A gone consumer is the send +failing, which is what tells it apart from a body failing with an `OSError` of its own: the latter +is the failure of the response it was producing and is recorded as one, even though the framework +reports both to the server as a `ClientDisconnect`. + +## Observability + +Request scopes are recorded through the observability backend of the server context. It accepts a +`Logger`, an `Observability` instance, or a callable preparing one per request: + +```python +from logging import getLogger + +context = ServerContext(observability=getLogger("api")) +``` + +A logging backend of its own is built out of that `Logger` for each request, so what one recorded is +released along with it: a single backend shared by the application would keep holding the scopes of +every request which never completed - an abandoned generator among them - for as long as it runs. An +`Observability` instance is used as provided, and a callable is invoked per request - it has to +answer with a backend, which `LoggerObservability` builds out of a logger - which is what continuing +an incoming trace requires. + +### Distributed Traces + +The W3C trace context of every request is read from its headers and handed to that callable, as +`traceparent` and `tracestate` keyword arguments. `OpenTelemetry.observability` takes exactly those, +so continuing the trace of the caller is the whole wiring: + +```python +from haiway.opentelemetry import OpenTelemetry + +context = ServerContext(observability=OpenTelemetry.observability) +``` + +Each request then joins the trace it arrives with, or starts its own when it arrives with none, and +its response reports back which trace handled it - `trace-id`, plus `traceparent` and `tracestate` +identifying the position within it. Those two are a request header format, so nothing consumes them +from a response on its own; they are there for a caller which correlates the two sides itself, next +to the `trace-id` a user reports. + +The values are handed over as received, apart from the whitespace surrounding them, which a header +carries without it being part of the value - an entry left empty by stripping it is reported as the +absent one it is. Validating the rest is the responsibility of the backend, which the specification +requires to reject a malformed value and start a new trace instead - what the OpenTelemetry +integration does, recording a warning. Two cases are resolved before that, because a backend given a +single value cannot see them: + +- a request carrying several `traceparent` headers has no single position to continue, so its trace + context is discarded and a new trace is started +- only the first `tracestate` header is read, so a caller splitting a long trace state across + several of them has to join them itself + +An outgoing HTTP request carries the trace onwards when it asks to, which is what makes a call chain +one trace end to end: + +```python +context = ServerContext( + observability=OpenTelemetry.observability, + disposables=(HTTPXClient(base_url=INTERNAL_URL),), +) + +# within an endpoint - a request towards a service you own continues the trace +# of its caller +response = await HTTPClient.get(url="/users", trace_propagation=True) +``` + +It is off by default, and settable per request, because propagating hands internal trace identifiers +to whoever is called - see [Trace Propagation](http-client.md#trace-propagation). + +`request_trace_context` exposes the inbound reading on its own, for an application which needs the +trace context for something else - propagating it over a protocol Haiway does not handle, for +instance. + +To pass a recording level, apply it ahead of time: + +```python +from functools import partial + +from haiway import ObservabilityLevel + +context = ServerContext( + observability=partial(OpenTelemetry.observability, ObservabilityLevel.DEBUG), +) +``` + +An `Observability` instance provided directly is used as it is, which records every request as its +own trace - continuing incoming traces needs the callable. + +### Configuring OpenTelemetry + +`OpenTelemetry.configure(...)` has to run before the first request, which is what a disposable +expresses: + +```python +from collections.abc import Iterable + +from haiway import State +from haiway.opentelemetry import OpenTelemetry + + +class Telemetry: + async def __aenter__(self) -> Iterable[State]: + if not OpenTelemetry.configured(): # claimed once per process + OpenTelemetry.configure( + service="api", + version="1.0.0", + environment="production", + otlp_endpoint="http://localhost:4317", + insecure=True, + ) + + return () # provides no state, only the configuration + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: object, + ) -> None: + OpenTelemetry.force_flush() # exports what the application recorded + + +context = ServerContext( + observability=OpenTelemetry.observability, + disposables=(Telemetry(), HTTPXClient()), +) +``` + +Requests fail while the integration is unconfigured - preparing an observability backend without it +raises - so the disposable belongs in the server context rather than somewhere later. + +Note what the disposable does *not* do. The OpenTelemetry provider slots are process wide and +claimed once: `configure(...)` refuses a second call and `shutdown()` cannot be undone, while a +process can run more than one application - a test suite exercising several. Guarding on +`configured()` is what keeps the second of them from failing on startup, and flushing rather than +shutting down is what keeps it exporting. Shutting the providers down belongs at process exit, which +the SDK does on its own. + +Startup and shutdown are outside of every request scope, so what the lifespan records - the context +being prepared and released, and a disposable which failed to prepare - goes to the root logger +rather than through this backend. There is no span for the startup of an application, and a failing +disposable is reported where the logging of the application goes. Startup work which has to be +traced belongs in a scope of its own, entered within an additional lifespan - see +[Startup Work](#startup-work). + +When no observability is provided at all, request scopes are recorded through the root logger, so +they land wherever the logging of the application is configured to go. Providing one is still +preferable - `getLogger("api")`, or the OpenTelemetry factory - and gives the records a name to +filter by. Two things the default deliberately avoids: a logger named after the scope, which is what +`ctx.scope` would request and which allocates one per distinct request path, and a logger of our own +created on import, which `setup_logging` disables along with every other logger predating it. + +## Context Presets + +Presets declared by the application context are available within request scopes, which lets an +endpoint enter a nested scope by name: + +```python +from haiway import ContextPresets + +context = ServerContext( + presets=(ContextPresets.of("summary", SummaryConfig(), disposables=(HTTPXClient,)),), +) + + +async def summarize(request: Request) -> Response: + async with ctx.scope("summary"): # resolved from the application presets + ... +``` + +See [Context Presets](context-presets.md) for how presets are composed. + +## Startup Work + +Startup work owning a resource is best expressed as one of the disposables. Anything else - running +migrations, warming a cache - can be passed to `application(...)` as an additional lifespan, entered +within the lifespan of the context, so the disposables are already prepared when it runs: + +```python +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from haiway.postgres import Postgres, PostgresConnectionPool +from starlette.applications import Starlette + + +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncGenerator[None]: + async with ctx.scope("migrations", disposables=(PostgresConnectionPool(),)): + await Postgres.execute_migrations("example.migrations") + + yield # suspend until shutdown + + +app = application(context, routes=[...], lifespan=lifespan) +``` + +Note the scope entered and exited before the `yield`. A scope held open across it would leak its +state into the context the server creates its request tasks in - preparing state for requests is +what the server context is for. + +Startup runs outside of every context scope, the disposables of the context included: they are +prepared, but nothing entered a scope carrying their state, so `ctx.state(...)` resolves nothing +there. Startup work needing state enters a scope of its own, as above. + +## Testing + +An application built this way is tested like any other ASGI application, entering its lifespan first +so the context is prepared. `TestClient` requires `httpx2`, installed with the `httpx` extra: + +```python +from starlette.testclient import TestClient + + +def build_application() -> Starlette: # a context per run, so an application per test + return application( + ServerContext(ServiceConfig(), disposables=(HTTPXClient(),)), + routes=[Route("/status", status)], + ) + + +def test_status() -> None: + with TestClient(build_application()) as client: # enters the lifespan + response = client.get("/status") + + assert response.status_code == 200 + assert response.headers["trace-id"] +``` + +A context backs a single run, which is what the factory is for: each test builds its own context and +application rather than sharing one module level application between tests which each enter its +lifespan. + +The lifespan of a `ServerContext` can also be entered directly, which is what a test exercising +state without an application needs - with a context of its own for the same reason: + +```python +async def test_upstream() -> None: + context = ServerContext(ServiceConfig(), disposables=(HTTPXClient(),)) + + async with context.lifespan(): + ... +``` + +## Best Practices + +- Declare every application resource in `disposables` - shutdown then releases them in the same + place, whatever went wrong +- Declare a context per run - one instance backs one lifespan, so a test suite builds its + application within each test rather than sharing one +- Keep the context middleware outermost so the rest of the application - middlewares included - runs + within a scope +- Report the `trace-id` of a failed response back to your users; it is what correlates their report + with the recorded trace diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 3ccc6127..565cb79a 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -12,8 +12,8 @@ pip install haiway ## Optional Dependencies -You may choose to install haiway including optional support for OpenTelemetry, httpx, Postgres, and -RabbitMQ. +You may choose to install haiway including optional support for OpenTelemetry, httpx, Postgres, +RabbitMQ, Starlette, and FastAPI. ### OpenTelemetry Support @@ -41,4 +41,17 @@ For RabbitMQ (`pika`) support: pip install "haiway[rabbitmq]" ``` +For the Starlette integration - middleware and application helpers plugging the context into request +handling: + +```bash +pip install "haiway[starlette]" +``` + +For the same integration with a FastAPI application factory (installs `fastapi`): + +```bash +pip install "haiway[fastapi]" +``` + Now you're ready to continue with the [Quick Start](quickstart.md) guide! diff --git a/llms.txt b/llms.txt index 1b12d34d..7a6e0262 100644 --- a/llms.txt +++ b/llms.txt @@ -8,7 +8,8 @@ no DI container and no mutable service objects — behavior is `Protocol`-typed `pip install haiway`. Core API lives in the root `haiway` package. Optional integrations require extras and are imported from their own subpackage: `haiway[httpx]` -> `haiway.httpx`, `haiway[postgres]` -> `haiway.postgres`, `haiway[rabbitmq]` -> `haiway.rabbitmq`, -`haiway[opentelemetry]` -> `haiway.opentelemetry`. +`haiway[opentelemetry]` -> `haiway.opentelemetry`, `haiway[starlette]` -> `haiway.starlette`, +`haiway[fastapi]` -> `haiway.fastapi`. ## Mental Model @@ -199,7 +200,11 @@ async with ctx.scope("app", disposables=(HTTPXClient(base_url=...), PostgresConn - **HTTP** (`haiway.httpx`): `HTTPXClient` disposable provides `HTTPClient`. `HTTPResponse` is immutable; a `stream=True` body must be consumed inside the issuing scope, reads once, and - bodies are `bytes` (encode text yourself). Errors: `HTTPClientError` / `HTTPTimeoutError` / + bodies are `bytes` (encode text yourself). A streamed request body stays owned by the caller and + is never closed for you - close it at the call site. `trace_propagation` attaches the current + `traceparent`/`tracestate` to a request - asked for per request and `False` by default, so ask + for it on requests towards services you own and leave it off for third party APIs; headers + passed to the request are never overridden. Errors: `HTTPClientError` / `HTTPTimeoutError` / `HTTPConnectionError` / `HTTPBodyConsumedError` (a body read twice - never retry it). - **Postgres** (`haiway.postgres`): `PostgresConnectionPool` disposable; `Postgres.fetch`, `fetch_one`, and `execute` acquire a connection when needed. Pin one connection with @@ -212,6 +217,56 @@ async with ctx.scope("app", disposables=(HTTPXClient(base_url=...), PostgresConn `async with message as content:` acks on success and rejects on exception. Consumers are re-established after a channel drop, so handlers must be idempotent. Errors: `RabbitMQException` carrying `retryable` to tell a transient broker failure from one a retry cannot fix. +- **Starlette** (`haiway.starlette`): `ServerContext(*state, disposables=(...), presets=..., + observability=...)` declares the request context, and `application(...)` builds a `Starlette` app + installing its `lifespan` plus `ContextMiddleware`. An existing app (FastAPI) instead takes + `lifespan=context.lifespan` - or `context.composed_lifespan(existing)` - and + `add_middleware(ContextMiddleware, context=context)` added last, to stay outermost. The declared + state and `disposables` take the instances themselves and ignore `None` entries; the disposables are + prepared once, so a context backs a single run and its lifespan can not be entered again. The + lifespan is required - a request reaching an app without it fails rather than being served an + incomplete scope. Every request runs in its own scope named `"{method} {path}"` (`"WS {path}"`) - + the requested path, since the middleware runs before routing. Responses carry the `trace-id` + header, plus `traceparent`/`tracestate` when the backend provides them, dropping any entry a + header can not hold. An unhandled exception is recorded as the failure + of its request and reraised - answering it is left to the framework's server error handling, + which sits above the middleware, so the `500` it produces (plain, `debug` traceback page, or a + registered `Exception`/`500` handler) carries no trace headers. `HTTPException`, + `WebSocketException` and `ClientDisconnect` are withheld while the scope is left, so they are + not recorded as request failures. Request derived state is added by a nested middleware through + `ctx.updating(...)`. Startup and shutdown are outside every context scope, so what the lifespan + records goes to the root logger rather than the backend, and startup work needing state enters a + scope of its own. The W3C trace context of each request is read from its headers and passed to an + `observability` callable as `traceparent`/`tracestate`, so + `observability=OpenTelemetry.observability` is the whole distributed tracing wiring - configure + the integration through a disposable guarding on `OpenTelemetry.configured()`, since the provider + slots are claimed once per process, and flushing rather than shutting down on exit. A `Logger` + passed instead of a callable is turned into a logging backend of its own for each request, so + what one recorded is released along with it. Several `traceparent` headers discard the context, + only the first `tracestate` header is read, malformed values are the backend's call. +- **Streaming** (`haiway.starlette`, re-exported from `haiway.fastapi`): + `StreamResponse(generator)` streams the chunks a generator yields - a server sent events feed is + the same response with `media_type="text/event-stream"` and the event stream format yielded by + the generator itself. The request scope stays entered until the last chunk, so a producer + resolves state and reports the request trace mid-stream. It takes a full `AsyncGenerator` and + closes it where the streaming ends - an abandoned one would otherwise + be finalized by the collector, in a fresh context which can not release a scope it opened. A + producer needing a scope of its own keeps it inside the generator (`ctx.stream(produce)`); one + entered around building the generator is already gone. A mid-stream failure is recorded within + the request scope (`Response streaming failed`, with the exception) then reraised - the framework + would otherwise replace it with `RuntimeError: ...response already started` whenever a handler + matches its type; a failing close is only a warning and never replaces it. A consumer which went + away is not a failure: the send failing - reported as a `ClientDisconnect` - is recorded at debug + level and leaves the request unfailed. Requires a server advertising ASGI spec 2.4 or newer, + which is the one reporting a gone consumer that way; below it the framework cancels the response + instead, which cannot close a body whose cleanup awaits anything. +- **FastAPI** (`haiway.fastapi`): the same integration with a FastAPI base - + `application(context, routers=(APIRouter(prefix="/api/v1"),), title=..., **fastapi_kwargs)` + returns a `FastAPI`. `ServerContext` / `ContextMiddleware` are re-exported from here. + Endpoints, dependencies, sync endpoints (context copied into the worker thread) and + `BackgroundTasks` all run within the request scope. `exception_handlers` may be async or sync + (a sync one runs in a worker thread), typed as `ExceptionHandling`. No `max_body_size` - FastAPI + does not accept one. ## Rules diff --git a/mkdocs.yml b/mkdocs.yml index 1444fc6e..6c99d146 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -104,6 +104,8 @@ nav: - Caching Helper: features/caching.md - Postgres: features/postgres.md - RabbitMQ: features/rabbitmq.md + - Starlette: features/starlette.md + - FastAPI: features/fastapi.md - File Access: features/file-access.md - Event Bus: features/event-bus.md - State Methods: features/state-methods.md diff --git a/pyproject.toml b/pyproject.toml index 5e3918f5..f226e42a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,10 @@ opentelemetry = [ httpx = ["httpx2~=2.10"] postgres = ["asyncpg~=0.31.0"] rabbitmq = ["pika~=1.4",] +starlette = ["starlette~=1.6"] +# starlette is constrained explicitly - the FastAPI requirement has no upper bound +# and `haiway.fastapi` builds on top of `haiway.starlette` +fastapi = ["fastapi~=0.141", "starlette~=1.6"] dev = [ "bandit~=1.9", "pyright~=1.1", diff --git a/src/haiway/context/access.py b/src/haiway/context/access.py index d8bbda5f..ecee4b40 100644 --- a/src/haiway/context/access.py +++ b/src/haiway/context/access.py @@ -15,7 +15,7 @@ from typing import Any, NoReturn, final, overload from haiway.attributes import State -from haiway.context.disposables import ContextDisposables, Disposable, Disposables, DisposableState +from haiway.context.disposables import ContextDisposables, Disposable, Disposables from haiway.context.events import ContextEvents, EventsSubscription from haiway.context.observability import ( ContextObservability, @@ -30,6 +30,7 @@ from haiway.context.scope import ContextScope from haiway.context.state import ContextState from haiway.context.tasks import BackgroundTaskGroup, ContextTaskGroup +from haiway.utils.context import NoopAsyncContext, NoopContext __all__ = ("ctx",) @@ -93,8 +94,13 @@ def presets( When entering this context manager, the provided presets become available for use with ctx.scope(). The presets are looked up by their name when - creating scopes. Registry lookup is scoped: nested registries shadow outer - registries for the duration of the nested ``with`` block. + creating scopes. A registry covers everything running within it, and a + registry entered within another one shadows it for the duration of its + block - a scope resolves against the innermost registry only, without + falling back to the outer one. Presets describe an application as a + whole, so declaring them once at its entry point keeps what a scope + resolves from depending on where it was entered from. Entering no presets + at all is a no-op, leaving whatever registry is already active in place. Note: For single preset usage, consider passing the preset directly to ctx.scope() instead of using this registry. @@ -137,6 +143,9 @@ def presets( ... config = ctx.state(ApiConfig) ... assert config.base_url == "https://dev-api.example.com" """ + if not presets: + return NoopContext.instance + return ContextPresetsRegistry(presets=presets) @staticmethod @@ -217,20 +226,16 @@ def scope( context_disposables: Disposables if disposables is None: - context_disposables = Disposables.of( - DisposableState.of(*(element for element in state if element is not None)) - ) + context_disposables = Disposables.of() else: - context_disposables = Disposables.of( - *disposables, - DisposableState.of(*(element for element in state if element is not None)), - ) + context_disposables = Disposables.of(*disposables) return ContextScope( name=name, presets=presets, disposables=context_disposables, + state=tuple(element for element in state if element is not None), observability=observability, isolated=isolated, ) @@ -256,6 +261,8 @@ def updating( AbstractContextManager[None] context manager object intended to enter updated state context with it """ + if not state: + return NoopContext.instance return ContextState.updating(state) @@ -298,6 +305,9 @@ def disposables( ... await conn_state.connection.execute("SELECT 1") """ + if not disposables: + return NoopAsyncContext.instance + return ContextDisposables(disposables) @overload diff --git a/src/haiway/context/disposables.py b/src/haiway/context/disposables.py index 7ec2e694..f9719238 100644 --- a/src/haiway/context/disposables.py +++ b/src/haiway/context/disposables.py @@ -101,45 +101,59 @@ async def __aenter__(self) -> Iterator[State]: # preparation is atomic - either nothing starts or everything gets # prepared, refuse to start when cancellation is already requested - # yield to deliver the pending cancellation - raising a fresh one - # would leave the task cancellation request unhandled - await sleep(0) + await sleep(0) # raise when cancelled - preparation: Future[list[Iterable[State] | State | BaseException]] = gather( + # held on its own instead of being awaited inline - the shield detaches + # the caller from the preparation, it does not stop it, and this is the + # only reference left to reach what it produces after that + preparing: Future[list[Iterable[State] | State | BaseException]] = gather( *(disposable.__aenter__() for disposable in self._disposables), return_exceptions=True, ) results: Sequence[Iterable[State] | State | BaseException] try: - # shield the preparation - cancelling it midway would leave - # already prepared elements without anyone to dispose them - results = await shield(preparation) + results = await shield(preparing) - except BaseException as exc: # cancelled while preparing - # shield the cleanup as well - repeated cancellation - # can't be allowed to abandon prepared elements + except BaseException as exc: + # cancelled midway - the preparation still completes and nothing else + # holds what it prepares, so it is awaited and disposed here rather + # than left to the garbage collector. under a single shield, so a + # cancellation delivered again does not split the cleanup in half await shield( - self._dispose_prepared( - preparation, + self._dispose_abandoned( + preparing, cause=exc, ) ) - raise # reraise cancellation + raise # reraise exception try: return _collect_state(results) # raises on preparation errors except BaseException as exc: - await self._dispose_prepared( - preparation, - cause=exc, + await shield( + self._dispose_prepared( + results, + cause=exc, + ) ) raise # reraise exception + async def _dispose_abandoned( + self, + preparing: Future[list[Iterable[State] | State | BaseException]], + /, + cause: BaseException, + ) -> None: + await self._dispose_prepared( + await preparing, + cause=cause, + ) + async def _dispose_prepared( self, - preparation: Future[list[Iterable[State] | State | BaseException]], + prepared: Sequence[Iterable[State] | State | BaseException], /, cause: BaseException, ) -> None: @@ -149,7 +163,7 @@ async def _dispose_prepared( disposable.__aexit__(type(cause), cause, cause.__traceback__) for disposable, result in zip( self._disposables, - await preparation, + prepared, strict=True, ) if not isinstance(result, BaseException) diff --git a/src/haiway/context/identifier.py b/src/haiway/context/identifier.py index 4dff3c46..09982b30 100644 --- a/src/haiway/context/identifier.py +++ b/src/haiway/context/identifier.py @@ -53,11 +53,11 @@ def scope( __slots__ = ( "_token", + "_unique_name", "name", "parent_id", "path", "scope_id", - "unique_name", ) def __init__( @@ -74,9 +74,20 @@ def __init__( # the name labels every record produced within the scope, so control # characters are escaped here instead of in each observability backend self.name: str = escape_controls(name) - self.unique_name: str = f"[{self.name}] [{scope_id}]" + self._unique_name: str | None = None self._token: Token[ContextIdentifier] | None = None + @property + def unique_name(self) -> str: + # built on demand and kept - only log prefixes and error messages need it, + # so a scope which records nothing never pays for formatting the UUID + unique_name: str | None = self._unique_name + if unique_name is None: + unique_name = f"[{self.name}] [{self.scope_id}]" + self._unique_name = unique_name + + return unique_name + @property def is_root(self) -> bool: return self.scope_id == self.parent_id diff --git a/src/haiway/context/observability.py b/src/haiway/context/observability.py index 48e018c1..1bd9f223 100644 --- a/src/haiway/context/observability.py +++ b/src/haiway/context/observability.py @@ -304,13 +304,14 @@ class ScopeStore: __slots__ = ( "_completed", "_exited", + "_prefix", "entered", "identifier", "logger", "nested", "pending", - "prefix", "store", + "trace_hex", "trace_id", ) @@ -326,9 +327,10 @@ def __init__( # by their root, so concurrent trees are told apart the same way they are # under a tracing backend self.trace_id: UUID = trace_id - # every record produced within the scope carries the same prefix, so it is - # rendered once. unpadded hex is the form trace backends expect - self.prefix: str = f"[{trace_id.hex}] {identifier.unique_name}" + # unpadded hex is the form trace backends expect, and it is what every + # record within the scope is prefixed with - render it once + self.trace_hex: str = trace_id.hex + self._prefix: str | None = None # resolved per tree, so concurrent roots keep their own logger self.logger: Logger = logger # only populated when a summary is going to be rendered - the tree is @@ -344,6 +346,17 @@ def __init__( self.pending: int = 0 self.store: list[str] = [] + @property + def prefix(self) -> str: + # every record produced within the scope carries the same prefix, so it is + # rendered once, and only when something is actually recorded + prefix: str | None = self._prefix + if prefix is None: + prefix = f"[{self.trace_hex}] {self.identifier.unique_name}" + self._prefix = prefix + + return prefix + @property def time(self) -> float: return (self._completed or monotonic()) - self.entered @@ -415,13 +428,15 @@ def LoggerObservability( # noqa: C901, PLR0915 The duration reported is the one of the scope itself, not of its longest descendant. """ - scopes: dict[UUID, ScopeStore] = {} + # keyed by the raw integer of the scope UUID - hashing an int is done in C, + # while hashing a UUID goes through a Python level `__hash__` on every lookup + scopes: dict[int, ScopeStore] = {} def trace_identifying( scope: ContextIdentifier, /, ) -> UUID: - store: ScopeStore | None = scopes.get(scope.scope_id) + store: ScopeStore | None = scopes.get(scope.scope_id.int) if store is None: # an untracked scope belongs to no tree known here - reporting the # zero identifier keeps resolving one from failing, the same way @@ -438,7 +453,7 @@ def log_recording( *args: Any, exception: BaseException | None, ) -> None: - store: ScopeStore | None = scopes.get(scope.scope_id) + store: ScopeStore | None = scopes.get(scope.scope_id.int) if store is None: return # skip without store @@ -461,7 +476,7 @@ def event_recording( event: str, attributes: Mapping[str, ObservabilityAttribute], ) -> None: - store: ScopeStore | None = scopes.get(scope.scope_id) + store: ScopeStore | None = scopes.get(scope.scope_id.int) if store is None: return # skip without store @@ -488,7 +503,7 @@ def metric_recording( kind: ObservabilityMetricKind, attributes: Mapping[str, ObservabilityAttribute], ) -> None: - store: ScopeStore | None = scopes.get(scope.scope_id) + store: ScopeStore | None = scopes.get(scope.scope_id.int) if store is None: return # skip without store @@ -521,7 +536,7 @@ def attributes_recording( if not attributes: return # skip empty - store: ScopeStore | None = scopes.get(scope.scope_id) + store: ScopeStore | None = scopes.get(scope.scope_id.int) if store is None: return # skip without store @@ -541,11 +556,11 @@ def scope_entering( scope: ContextIdentifier, /, ) -> str: - assert scope.scope_id not in scopes # nosec: B101 + assert scope.scope_id.int not in scopes # nosec: B101 # a root scope is its own parent, so the lookup misses and it starts a # tree of its own. it also misses for a nested scope entered after the # scope it belongs to already completed - parent: ScopeStore | None = scopes.get(scope.parent_id) + parent: ScopeStore | None = scopes.get(scope.parent_id.int) store: ScopeStore = ScopeStore( scope, # one trace per tree - a root starts it, everything below inherits it @@ -558,13 +573,38 @@ def scope_entering( parent.pending += 1 - scopes[scope.scope_id] = store - store.logger.log( - ObservabilityLevel.DEBUG, - f"{store.prefix} Entering scope: {scope.name}", - ) + scopes[scope.scope_id.int] = store + if store.logger.isEnabledFor(ObservabilityLevel.DEBUG): + store.logger.log( + ObservabilityLevel.DEBUG, + f"{store.prefix} Entering scope: {scope.name}", + ) + + return store.trace_hex - return store.trace_id.hex + def record_completion( + store: ScopeStore, + /, + ) -> None: + debug_enabled: bool = store.logger.isEnabledFor(ObservabilityLevel.DEBUG) + if not debug_context and not debug_enabled: + return # nothing to summarize and nothing to write - skip formatting + + if debug_enabled: + store.logger.log( + ObservabilityLevel.DEBUG, + f"{store.prefix} Exiting scope: {store.identifier.name}", + ) + + metric_str: str = f"Metric - scope_time:{store.time:.3f}s" + if debug_context: # store only for summary + store.store.append(metric_str) + + if debug_enabled: + store.logger.log( + ObservabilityLevel.DEBUG, + f"{store.prefix} {metric_str}", + ) def scope_exiting( scope: ContextIdentifier, @@ -572,7 +612,7 @@ def scope_exiting( *, exception: BaseException | None, ) -> None: - store: ScopeStore | None = scopes.get(scope.scope_id) + store: ScopeStore | None = scopes.get(scope.scope_id.int) if store is None: return # skip without store @@ -593,24 +633,13 @@ def scope_exiting( # complete the scope and every ancestor which was waiting for it while store.try_complete(): identifier: ContextIdentifier = store.identifier - store.logger.log( - ObservabilityLevel.DEBUG, - f"{store.prefix} Exiting scope: {identifier.name}", - ) - metric_str: str = f"Metric - scope_time:{store.time:.3f}s" - if debug_context: # store only for summary - store.store.append(metric_str) - - store.logger.log( - ObservabilityLevel.DEBUG, - f"{store.prefix} {metric_str}", - ) + record_completion(store) # a root scope is its own parent, so the lookup finds itself - parent: ScopeStore | None = scopes.get(identifier.parent_id) + parent: ScopeStore | None = scopes.get(identifier.parent_id.int) # a completed scope is never recorded into again - unlink it here, # the summary reaches it through the tree its root retained - del scopes[identifier.scope_id] + del scopes[identifier.scope_id.int] if parent is None or parent is store: if debug_context: store.logger.log( diff --git a/src/haiway/context/presets.py b/src/haiway/context/presets.py index 962f2c3e..cc68cf4d 100644 --- a/src/haiway/context/presets.py +++ b/src/haiway/context/presets.py @@ -2,6 +2,7 @@ Collection, Iterable, Mapping, + Sequence, ) from contextvars import ContextVar, Token from types import TracebackType @@ -43,9 +44,11 @@ class ContextPresets: and then wired into a running context. Immutability is enforced via `@final` and attribute guards, so instances are safe to share between scopes and cannot be mutated after creation. Resolution happens per scope entry: disposable - factories are called each time ``resolve()`` is used, while state provided via - ``ContextPresets.of(..., *state)`` is wrapped once in ``DisposableState`` and - reused as provided unless the state itself is produced by an async factory. + factories are called each time ``resolve()`` is used. State provided via + ``ContextPresets.of(..., *state)`` is kept as given when it is plain ``State``, + so a preset carrying only state needs no preparation when a scope is entered + with it. State produced by an async factory is wrapped once in a + ``DisposableState`` and prepared on every resolution. Examples -------- @@ -86,12 +89,24 @@ def of( Notes ----- - When `state` is provided, it is composed into a `DisposableState` and + Plain `State` given without any `disposables` is retained as is - such a + preset has nothing to prepare, so entering a scope with it stays + synchronous. Otherwise `state` is composed into a `DisposableState` and wrapped as a callable factory, so the preset behaves consistently with - other disposable factories. Async state factories inside `state` are run - when the preset is resolved for a scope entry. + other disposable factories, and async state factories inside `state` are + run when the preset is resolved for a scope entry. Either way the state + resolves after the preset disposables, so it keeps precedence over them. """ if state: + # a preset built only out of plain state has nothing to prepare - keep + # it aside so entering a scope with it stays synchronous instead of + # going through the disposables machinery for a no-op + if not disposables and all(isinstance(element, State) for element in state): + return cls( + name=name, + static_state=tuple(element for element in state), # pyright: ignore + ) + disposable_state: DisposableState = DisposableState.of(*state) return cls( name=name, @@ -108,6 +123,7 @@ def of( __slots__ = ( "_disposables", + "_static_state", "name", ) @@ -115,6 +131,7 @@ def __init__( self, name: str, disposables: Collection[ContextPresetsDisposablePreparing] = (), + static_state: Sequence[State] = (), ) -> None: self.name: str object.__setattr__( @@ -128,14 +145,27 @@ def __init__( "_disposables", disposables, ) + self._static_state: Sequence[State] + object.__setattr__( + self, + "_static_state", + static_state, + ) def extended( self, other: Self, ) -> Self: + if not self._disposables and not other._disposables: + return self.__class__( + name=self.name, + static_state=(*self._static_state, *other._static_state), + ) + return self.__class__( name=self.name, - disposables=(*self._disposables, *other._disposables), + disposables=(*self._disposables, *self._state_disposables(), *other._disposables), + static_state=other._static_state, ) def with_state( @@ -145,10 +175,16 @@ def with_state( if not state: return self + if not self._disposables and all(isinstance(element, State) for element in state): + return self.__class__( + name=self.name, + static_state=(*self._static_state, *state), # pyright: ignore + ) + disposable_state: DisposableState = DisposableState.of(*state) return self.__class__( name=self.name, - disposables=(*self._disposables, lambda: disposable_state), + disposables=(*self._disposables, *self._state_disposables(), lambda: disposable_state), ) def with_disposables( @@ -160,10 +196,44 @@ def with_disposables( return self.__class__( name=self.name, - disposables=(*self._disposables, *disposables), + disposables=(*self._disposables, *self._state_disposables(), *disposables), ) + def _state_disposables(self) -> tuple[ContextPresetsDisposablePreparing, ...]: + # fold the static state back into the disposables order it would have + # held, so priority stays the same once a factory is added after it + if not self._static_state: + return () + + disposable_state: DisposableState = DisposableState.of(*self._static_state) + return (lambda: disposable_state,) + + @property + def static_state(self) -> Sequence[State]: + """State this preset carries which needs no preparation.""" + return self._static_state + def resolve(self) -> Disposables: + """ + Prepare every element of this preset as disposables. + + State which needs no preparation is wrapped back into a `DisposableState` + here, so the resulting disposables hold the whole preset. Use + ``resolve_disposables()`` together with ``static_state`` to skip that + wrapping when entering a scope, where plain state can be applied directly. + """ + return Disposables( + factory() for factory in (*self._disposables, *self._state_disposables()) + ) + + def resolve_disposables(self) -> Disposables: + """ + Prepare only the elements of this preset which have to be prepared. + + The state reported by ``static_state`` is not included - it resolves after + these disposables, so applying it right after them keeps the priority it + holds within ``resolve()``. + """ return Disposables(factory() for factory in self._disposables) def __setattr__( diff --git a/src/haiway/context/scope.py b/src/haiway/context/scope.py index 393d5da1..562a4be5 100644 --- a/src/haiway/context/scope.py +++ b/src/haiway/context/scope.py @@ -1,9 +1,11 @@ +import sys from asyncio import AbstractEventLoop, CancelledError, get_running_loop -from contextlib import AsyncExitStack +from collections.abc import Sequence from logging import Logger from types import TracebackType -from typing import final +from typing import Any, final +from haiway.attributes import State from haiway.context.closing import ContextClosing from haiway.context.disposables import Disposables from haiway.context.events import ContextEvents @@ -22,58 +24,58 @@ @final # consider immutable class ContextScope: __slots__ = ( - "_claimed_name", "_disposables", - "_exit_stack", + "_entered", + "_identifier", "_isolated", - "_loop", "_name", "_observability", "_presets", + "_state", ) def __init__( self, name: str, presets: ContextPresets | None, + state: Sequence[State], disposables: Disposables, observability: Observability | Logger | None, isolated: bool, ) -> None: + self._identifier: ContextIdentifier | None = None self._name: str = name self._observability: Observability | Logger | None = observability self._presets: ContextPresets | None = presets + self._state: Sequence[State] = state self._disposables: Disposables = disposables self._isolated: bool = isolated - # the stack is prepared on entering - a scope can be created ahead of - # its use, like `ctx.stream` does, and entered on a different loop - self._exit_stack: AsyncExitStack | None = None - self._loop: AbstractEventLoop | None = None - # the escaped, scope id qualified name - available only from entering, - # when the identifier of this very scope is created - self._claimed_name: str | None = None + self._entered: list[tuple[bool, Any]] | None = None async def __aenter__(self) -> str: - assert self._claimed_name is None, "Context reentrance is not allowed" # nosec: B101 + assert self._identifier is None, "Context reentrance is not allowed" # nosec: B101 loop: AbstractEventLoop = get_running_loop() # claimed before the first await - the scope has to be rejected for the # whole setup, not only after it became fully prepared identifier: ContextIdentifier = ContextIdentifier.scope(self._name) - self._claimed_name = identifier.unique_name - # start scope exit stack - exit_stack = AsyncExitStack() - await exit_stack.__aenter__() + self._identifier = identifier + # elements which were entered, paired with whether they exit asynchronously. + # a plain list instead of an `AsyncExitStack` - the elements are known here, + # so there is nothing to gain from the stack building a closure per element + entered: list[tuple[bool, Any]] = [] try: # propagate new scope identifier - exit_stack.enter_context(identifier) + identifier.__enter__() + entered.append((False, identifier)) + # ensure associated observability and obtain trace identifier - trace_id: str = exit_stack.enter_context( - ContextObservability.scope( - identifier, - observability=self._observability, - ) + observability: ContextObservability = ContextObservability.scope( + identifier, + observability=self._observability, ) + trace_id: str = observability.__enter__() + entered.append((False, observability)) # resolve presets if self._presets is not None: @@ -82,49 +84,42 @@ async def __aenter__(self) -> str: else: presets = ContextPresetsRegistry.select(self._name) - # resolve combined state - state: ContextState - if presets is None: - state = ContextState.updating( - await exit_stack.enter_async_context(self._disposables) - ) - - else: - state = ContextState.updating( - ( - *await exit_stack.enter_async_context(presets.resolve()), - *await exit_stack.enter_async_context(self._disposables), - ) - ) - - # and ensure state is used - exit_stack.enter_context(state) + # resolve combined state and ensure it is used + state: ContextState = await self._resolve_state(presets, entered) + state.__enter__() + entered.append((False, state)) # enter the task group after everything its tasks are given to work # with - it is joined on exit before the state and the disposables # are released, so a task spawned within the scope can't keep running # against a connection pool or a client which was already closed - await exit_stack.enter_async_context(ContextTaskGroup()) + task_group: ContextTaskGroup = ContextTaskGroup() + await task_group.__aenter__() + entered.append((True, task_group)) # provide events after the task group so they exit before it - closing # the event bus releases all pending subscribers so it can join them if self._isolated or identifier.is_root: - await exit_stack.enter_async_context(ContextEvents(loop=loop)) + events: ContextEvents = ContextEvents(loop=loop) + await events.__aenter__() + entered.append((True, events)) # provide the closing future last so it completes first - everything # waiting for the scope to end is released before its tasks are joined - exit_stack.enter_context(ContextClosing(loop)) + closing: ContextClosing = ContextClosing(loop) + closing.__enter__() + entered.append((False, closing)) - # claim the stack only when the scope is fully prepared - a failed - # enter unwinds it here and leaves nothing behind to exit later - self._exit_stack = exit_stack - self._loop = loop + # claim the entered elements only when the scope is fully prepared - a + # failed enter unwinds them here and leaves nothing behind to exit later + self._entered = entered return trace_id except BaseException as exc: - try: # ensure stack exiting on error - await exit_stack.__aexit__( + try: # ensure unwinding on error + await _unwind( + entered, type(exc), exc, exc.__traceback__, @@ -134,28 +129,80 @@ async def __aenter__(self) -> str: # released only after the unwinding is complete - a failed enter # leaves nothing behind, yet until it finishes cleaning up there # is still a partially prepared scope which can't be entered - self._claimed_name = None + self._identifier = None raise # reraise original + async def _resolve_state( + self, + presets: ContextPresets | None, + entered: list[tuple[bool, Any]], + /, + ) -> ContextState: + """ + Combine the state of every source, lowest priority first. + + The disposables of each source are entered only when there is something + to prepare - entering an empty set would cost a few event loop round + trips to prepare nothing. State given to the scope directly never needs + preparation, so it is applied last without going through them at all. + """ + presets_state: tuple[State, ...] = () + if presets is not None: + presets_disposables: Disposables = presets.resolve_disposables() + if presets_disposables: + presets_state = ( + *await self._enter_disposables(presets_disposables, entered), + # the state a preset carries directly needs no preparation, + # it keeps the priority it would have as the last disposable + *presets.static_state, + ) + + else: + presets_state = tuple(presets.static_state) + + disposables_state: tuple[State, ...] = () + if self._disposables: + disposables_state = tuple(await self._enter_disposables(self._disposables, entered)) + + return ContextState.updating( + ( + *presets_state, + *disposables_state, + *self._state, + ) + ) + + @staticmethod + async def _enter_disposables( + disposables: Disposables, + entered: list[tuple[bool, Any]], + /, + ) -> Any: + prepared: Any = await disposables.__aenter__() + entered.append((True, disposables)) + return prepared + async def __aexit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> None: - exit_stack: AsyncExitStack | None = self._exit_stack - if exit_stack is None: + entered: list[tuple[bool, Any]] | None = self._entered + if entered is None: raise ContextMissing("Context scope requested but not defined!") - assert self._loop is get_running_loop() # nosec: B101 + # a claimed identifier always comes with the entered elements + claimed: ContextIdentifier | None = self._identifier + assert claimed is not None # nosec: B101 # released before unwinding - the scope is spent either way, so a failing # exit can't leave it looking like it could be exited again - self._exit_stack = None - self._loop = None + self._entered = None - try: # exit stack - await exit_stack.__aexit__( + try: # unwind entered elements + await _unwind( + entered, exc_type, exc_val, exc_tb, @@ -170,7 +217,7 @@ async def __aexit__( except BaseException as exc: ContextObservability.record_log( ObservabilityLevel.ERROR, - f"Context scope {self._claimed_name} exit failed", + f"Context scope {claimed.unique_name} exit failed", exception=exc, ) raise # record and reraise other errors @@ -178,4 +225,74 @@ async def __aexit__( finally: # released last - the scope stays claimed for the whole teardown so # that nothing can enter it while it is still unwinding - self._claimed_name = None + self._identifier = None + + +async def _unwind( + entered: list[tuple[bool, Any]], + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + /, +) -> None: + """ + Exit entered scope elements in reverse order, as nested context managers. + + Mirrors what an ``AsyncExitStack`` does for the same elements - every element + is exited even when an earlier one failed, and an error raised while exiting + replaces the one in flight while keeping it as its context. None of the scope + elements suppress exceptions, so the suppression handling of the stack has no + counterpart here. + + An element reraising the exception it was given counts as raising, exactly as + it does within the stack - the reraised error propagates from here rather than + from the `async with`, so a scope reports its exit as failed either way. + """ + frame_exception: BaseException | None = sys.exception() + + def fix_exception_context( + new_exception: BaseException, + old_exception: BaseException | None, + ) -> None: + # the context of the newly raised error may point anywhere - walk to the + # end of its chain and link it to the error it is replacing, the same way + # nested `with` statements would have chained them + while True: + exception_context: BaseException | None = new_exception.__context__ + if exception_context is None or exception_context is old_exception: + return # already set correctly + + if exception_context is frame_exception: + break + + new_exception = exception_context + + new_exception.__context__ = old_exception + + pending_raise: bool = False + while entered: + is_async, element = entered.pop() + try: + if is_async: + await element.__aexit__(exc_type, exc_val, exc_tb) + + else: + element.__exit__(exc_type, exc_val, exc_tb) + + except BaseException as exc: + fix_exception_context(exc, exc_val) + pending_raise = True + exc_type = type(exc) + exc_val = exc + exc_tb = exc.__traceback__ + + if pending_raise: + assert exc_val is not None # nosec: B101 + # raising replaces the carefully prepared context - keep it to restore + fixed_context: BaseException | None = exc_val.__context__ + try: + raise exc_val + + except BaseException: + exc_val.__context__ = fixed_context + raise diff --git a/src/haiway/fastapi/__init__.py b/src/haiway/fastapi/__init__.py new file mode 100644 index 00000000..a6bbca96 --- /dev/null +++ b/src/haiway/fastapi/__init__.py @@ -0,0 +1,30 @@ +try: + import fastapi # pyright: ignore[reportUnusedImport] + +except ImportError as exc: # pragma: no cover + raise ImportError( + "haiway.fastapi requires the 'fastapi' extra. Install via `pip install haiway[fastapi]`." + ) from exc + +from haiway.fastapi.application import application +from haiway.fastapi.types import ExceptionHandling + +# the request context declaration and the middleware are shared with the +# Starlette integration - FastAPI applications are Starlette applications +from haiway.starlette import ( + ContextMiddleware, + ObservabilityPreparing, + ServerContext, + StreamResponse, + request_trace_context, +) + +__all__ = ( + "ContextMiddleware", + "ExceptionHandling", + "ObservabilityPreparing", + "ServerContext", + "StreamResponse", + "application", + "request_trace_context", +) diff --git a/src/haiway/fastapi/application.py b/src/haiway/fastapi/application.py new file mode 100644 index 00000000..25bdfe85 --- /dev/null +++ b/src/haiway/fastapi/application.py @@ -0,0 +1,115 @@ +from collections.abc import Callable, Coroutine, Iterable, Mapping +from typing import Any, cast + +from fastapi import APIRouter, FastAPI, Request, Response +from starlette.middleware import Middleware +from starlette.types import StatelessLifespan + +from haiway.fastapi.types import ExceptionHandling +from haiway.starlette import ContextMiddleware, ServerContext + +__all__ = ("application",) + + +def application( + context: ServerContext | None = None, + /, + *, + routers: Iterable[APIRouter] = (), + middleware: Iterable[Middleware] = (), + exception_handlers: Mapping[int | type[Exception], ExceptionHandling] | None = None, + lifespan: StatelessLifespan[FastAPI] | None = None, + **extra: Any, +) -> FastAPI: + """Prepare a FastAPI application handling requests within Haiway contexts. + + The FastAPI counterpart of ``haiway.starlette.application``, wiring the same + two pieces of the integration into a regular FastAPI application: the + lifespan of the given context, preparing the application resources, and + ``ContextMiddleware``, entering a context scope for each request. Everything + else is passed through to ``FastAPI`` unchanged, so an application prepared + this way is served, tested, documented and extended like any other. + + Parameters + ---------- + context : ServerContext | None + Declaration of the request context. Defaults to an empty context, which + provides scopes and trace headers without any application state. + routers : Iterable[APIRouter] + Routers to include, in order. A router serving under a path prefix + carries it itself - ``APIRouter(prefix="/api/v1")`` - and further + routers can be added afterwards through ``app.include_router(...)``. + middleware : Iterable[Middleware] + Additional middlewares, nested below ``ContextMiddleware`` - each one + runs within the context scope of the request and can extend its state + through ``ctx.updating(...)``. + exception_handlers : Mapping[int | type[Exception], ExceptionHandling] | None + Handlers producing responses for exceptions and status codes, + asynchronous or synchronous - a synchronous one is called in a worker + thread. A handler nested below ``ContextMiddleware`` - anything but + ``500`` or ``Exception``, the validation error handler of FastAPI + included - answers within the scope of its request, so its response + carries the trace headers. The two server error slots run above every other + middleware, which is where Starlette places them, so what they answer + with is outside of the request scope and carries none. + lifespan : StatelessLifespan[FastAPI] | None + Additional startup and shutdown steps, entered within the lifespan of + the context, so the application state is prepared before they run. + Startup work which owns a resource is better expressed as one of the + context disposables. + **extra : Any + Additional keyword arguments passed directly to ``FastAPI`` - the + OpenAPI metadata (``title``, ``version``, ``description``), the + documentation urls, global ``dependencies`` and everything else it + accepts. + + Returns + ------- + FastAPI + The prepared application. + + Examples + -------- + >>> app = application( + ... ServerContext( + ... ExampleConfig(), + ... disposables=(HTTPXClient(),), + ... ), + ... routers=(example_router,), + ... title="Example API", + ... version="1.0.0", + ... openapi_url="/openapi.json" if __debug__ else None, + ... ) + + Notes + ----- + A provided ``lifespan`` must not hold a context scope open across its + ``yield`` - a scope entered on startup and left open would leak its state + into the context the server creates its request tasks in. Preparing state + for requests is what ``ServerContext`` is for, while startup work which + needs a context of its own - running migrations, for instance - belongs in a + scope entered and exited before the ``yield``. + """ + resolved_context: ServerContext = context if context is not None else ServerContext() + + app: FastAPI = FastAPI( + middleware=( + Middleware( + ContextMiddleware, + context=resolved_context, + ), + *middleware, + ), + exception_handlers=cast( + dict[int | type[Exception], Callable[[Request, Any], Coroutine[Any, Any, Response]]] + | None, + dict(exception_handlers) if exception_handlers else None, + ), + lifespan=resolved_context.composed_lifespan(lifespan), + **extra, + ) + + for router in routers: + app.include_router(router) + + return app diff --git a/src/haiway/fastapi/types.py b/src/haiway/fastapi/types.py new file mode 100644 index 00000000..6bdb13b4 --- /dev/null +++ b/src/haiway/fastapi/types.py @@ -0,0 +1,13 @@ +from collections.abc import Awaitable, Callable +from typing import Any + +from fastapi import Request, Response + +__all__ = ("ExceptionHandling",) + + +# matches what Starlette dispatches at runtime, which FastAPI hands its handlers +# over to - a synchronous handler is called in a worker thread. FastAPI declares +# `exception_handlers` as async only, narrower than that, which is why the +# mapping is cast on the way in. +type ExceptionHandling = Callable[[Request, Any], Response | Awaitable[Response]] diff --git a/src/haiway/helpers/http_client.py b/src/haiway/helpers/http_client.py index 5a32d159..dda88ac0 100644 --- a/src/haiway/helpers/http_client.py +++ b/src/haiway/helpers/http_client.py @@ -37,8 +37,9 @@ """Payload of a request or a response - buffered as ``bytes``, or streamed as an async byte generator. -A full generator is required, not any async iterable: a streamed body is closed -once it is read or abandoned, which needs ``aclose``. Wrap a bare async iterator +A full generator is required, not any async iterable: a streamed response body +is closed once it is read or abandoned, which needs ``aclose``. A streamed +request payload stays owned by the caller. Wrap a bare async iterator in a generator (``async def gen(): ...`` yielding from it) to pass one. A streamed request payload has no known length, so it is sent with chunked @@ -328,7 +329,9 @@ class HTTPRequesting(Protocol): HTTP headers to include in the request. body : HTTPBody | None Request body content - buffered `bytes`, or an async byte generator - streamed with chunked transfer encoding. + streamed with chunked transfer encoding. A streamed payload stays owned + by the caller, so an implementation reads it while the request is in + flight and never closes it. timeout : float | None Request timeout in seconds. None uses client default. follow_redirects : bool | None @@ -550,65 +553,6 @@ def _recorded_host( return host.partition(":")[0] -async def _release_body( - body: HTTPBody | None, - /, -) -> None: - """Release a streamed request payload the backend may have left open. - - A backend releases a streamed payload only when it began reading it - a - request failing before that, or a server answering before the upload - finished, would leave the caller's generator open. Closing an exhausted - generator does nothing, so the usual path is unaffected. Done here rather - than per backend, so the promise of ``HTTPBody`` holds for every - ``HTTPRequesting`` implementation. - - A failure closing it is logged rather than raised: it would displace the - in-flight error, hiding the timeout or connection failure retries are keyed - on, or discard an already obtained response. Cancellation is not a failure - and does propagate - what it takes with it is handled at the call site. - """ - if not isinstance(body, AsyncGenerator): - return # buffered, or absent - nothing to release - - try: - await body.aclose() - - except Exception as exc: - ctx.log_warning( - "HTTP request body failed to close", - exception=exc, - ) - - -async def _release_response( - response: HTTPResponse, - /, -) -> None: - """Discard a response which has to be dropped after it was obtained. - - Cancellation reaching the release of a streamed request payload is the one - place that happens. Without this a streamed body would keep holding its - connection until the pool is closed. - - Claiming the body through the same accessor a reader would use is what keeps - this from drifting from it: a buffered payload holds nothing and stays - readable, while a streamed one is claimed, so a later read fails with - ``HTTPBodyConsumedError`` rather than reading as empty. - - A failure closing it is logged rather than raised - this runs while another - failure unwinds, and must not replace it. - """ - try: - await response.stream_body().aclose() - - except Exception as exc: - ctx.log_warning( - "HTTP response body failed to close", - exception=exc, - ) - - def _record_failure( exception: Exception, /, @@ -682,15 +626,16 @@ class HTTPClient(State): the method, the status code or the error type, and `server.address` when the request URL names a host. A relative URL resolves against a base URL held by the backend, which the facade does not see, so it carries no host. - - Requesting `trace_propagation` on a request adds the current trace - context to its headers - the W3C `traceparent`, and `tracestate` when - present - so the called service continues this trace instead of starting - its own. It is asked for per request, and defaults to `False`, because it - exposes internal trace identifiers to whoever is called: ask for it - towards services you own, not towards third party APIs. Headers passed to - the request are never overridden, and an observability backend with no - trace context to hand out - the default logger among them - propagates - nothing. + - `trace_propagation` adds the current trace context to the headers of a + request - the W3C `traceparent`, and `tracestate` when present - so the + called service continues this trace instead of starting its own. It is + asked for per request and defaults to `False`, because propagating + exposes internal trace identifiers to whoever is called: ask for it on + requests towards services you own, and leave it off for third party APIs. + Headers passed to the request are never overridden, and an observability + backend with no trace context to hand out - the default logger among + them - propagates nothing, so a service records the same requests whether + or not it is traced. - HTTP status codes such as 4xx and 5xx are returned as normal `HTTPResponse` values. `HTTPClientError` is reserved for transport or adapter failures, with `HTTPTimeoutError` and `HTTPConnectionError` @@ -703,9 +648,9 @@ class HTTPClient(State): - Request bodies stream too: pass an async byte generator as `body` to send a payload without holding it in memory. Such a payload is sent with chunked transfer encoding and cannot be replayed, so it does not survive - a redirect or a retry. It is closed once the request is done with it, - however it ended, so a payload abandoned by a failed request does not - outlive the call. Buffered payloads are `bytes` - encode text yourself + a redirect or a retry. It stays owned by the caller and is never closed + here, so close it at the call site when it holds anything worth + releasing. Buffered payloads are `bytes` - encode text yourself rather than relying on a guessed charset. Examples @@ -723,6 +668,11 @@ class HTTPClient(State): ... headers={"Content-Type": "application/json"} ... ) ... + >>> # Propagating the trace to a service you own + >>> async with ctx.scope("internal", disposables=(HTTPXClient(base_url=INTERNAL_URL),)): + ... # carries `traceparent`, and `tracestate` when present + ... response = await HTTPClient.get(url="/users", trace_propagation=True) + ... >>> # Streaming an upload without buffering it >>> async def chunks() -> AsyncGenerator[bytes]: ... async for chunk in source.read(): @@ -864,7 +814,9 @@ async def put( body : HTTPBody | None, optional Request body content - buffered `bytes`, or an async byte generator to stream the payload instead of holding it in memory. - A streamed payload is closed once the request is done with it. + A streamed payload stays owned by the caller and is never closed + here - close it at the call site when it holds anything worth + releasing. timeout : float | None, optional Request timeout in seconds. follow_redirects : bool | None, optional @@ -951,7 +903,9 @@ async def post( body : HTTPBody | None, optional Request body content - buffered `bytes`, or an async byte generator to stream the payload instead of holding it in memory. - A streamed payload is closed once the request is done with it. + A streamed payload stays owned by the caller and is never closed + here - close it at the call site when it holds anything worth + releasing. timeout : float | None, optional Request timeout in seconds. follow_redirects : bool | None, optional @@ -1048,7 +1002,9 @@ async def request( body : HTTPBody | None, optional Request body content - buffered `bytes`, or an async byte generator to stream the payload instead of holding it in memory. - A streamed payload is closed once the request is done with it. + A streamed payload stays owned by the caller and is never closed + here - close it at the call site when it holds anything worth + releasing. timeout : float | None, optional Request timeout in seconds. None uses client default. follow_redirects : bool | None, optional @@ -1127,34 +1083,16 @@ async def _request( started: float = monotonic() response: HTTPResponse try: - try: - response = await self.requesting( - method, - url=url, - query=query, - headers=self._propagated_headers(headers) if trace_propagation else headers, - body=body, - timeout=timeout, - follow_redirects=follow_redirects, - stream=stream, - ) - - except BaseException: - # nothing was obtained, so there is no response to protect - release - # the payload and let the failure through unchanged - await _release_body(body) - raise - - try: - await _release_body(body) - - except BaseException: - # a failure closing the payload is swallowed there, so only - # cancellation reaches here - and it takes the response with it. - # release it rather than leaving a streamed body holding its - # connection until the pool is closed - await _release_response(response) - raise + response = await self.requesting( + method, + url=url, + query=query, + headers=_with_trace_headers(headers) if trace_propagation else headers, + body=body, + timeout=timeout, + follow_redirects=follow_redirects, + stream=stream, + ) except HTTPClientError as exc: _record_failure( @@ -1211,28 +1149,28 @@ async def _request( ) return response - def _propagated_headers( - self, - headers: HTTPHeaders | None, - /, - ) -> HTTPHeaders | None: - """Extend request headers with the current trace context, when there is one.""" - trace_context: Mapping[str, str] = ctx.trace_context() - if not trace_context: - return headers # no trace position to propagate - - if headers is None: - return trace_context - - # explicit headers win - a caller managing trace context itself, or - # deliberately suppressing it for one request, is not overridden here - provided: set[str] = {name.lower() for name in headers} - additional: Mapping[str, str] = { - name: value for name, value in trace_context.items() if name.lower() not in provided - } - if not additional: - return headers - - return {**headers, **additional} - requesting: HTTPRequesting + + +def _with_trace_headers( + headers: HTTPHeaders | None, + /, +) -> HTTPHeaders | None: + """Extend request headers with the current trace context, when there is one.""" + trace_context: Mapping[str, str] = ctx.trace_context() + if not trace_context: + return headers # no trace position to propagate + + if headers is None: + return trace_context + + # explicit headers win - a caller managing trace context itself, or + # deliberately suppressing it for one request, is not overridden here + provided: set[str] = {name.lower() for name in headers} + additional: Mapping[str, str] = { + name: value for name, value in trace_context.items() if name.lower() not in provided + } + if not additional: + return headers + + return {**headers, **additional} diff --git a/src/haiway/httpx/client.py b/src/haiway/httpx/client.py index 438fba90..20c836c2 100644 --- a/src/haiway/httpx/client.py +++ b/src/haiway/httpx/client.py @@ -253,8 +253,8 @@ async def request( body : HTTPBody | None, optional Request body content. ``bytes`` are sent with a ``Content-Length``; an async byte generator is streamed with chunked transfer encoding - instead of being buffered. The `HTTPClient` facade closes it once - the request is done with it, so this method does not. + instead of being buffered. It stays owned by the caller and is + never closed here. timeout : float | None, optional Request timeout. Overrides default timeout if specified. follow_redirects : bool | None, optional diff --git a/src/haiway/opentelemetry/observability.py b/src/haiway/opentelemetry/observability.py index bf2d7e05..c0e92510 100644 --- a/src/haiway/opentelemetry/observability.py +++ b/src/haiway/opentelemetry/observability.py @@ -1021,6 +1021,26 @@ def configure( return cls + @classmethod + def configured(cls) -> bool: + """ + Check whether the integration can prepare observability. + + Returns + ------- + bool + ``True`` once ``configure()`` or ``autoconfigure()`` succeeded, and + until ``shutdown()`` was called. + + Notes + ----- + The provider slots are claimed once per process and can neither be + replaced nor restored after a shutdown, so this is what a resource + entered on each application startup guards its configuration with - + ``configure()`` refuses a second call. + """ + return cls._logger is not None + @classmethod def force_flush( cls, diff --git a/src/haiway/starlette/__init__.py b/src/haiway/starlette/__init__.py new file mode 100644 index 00000000..12552e1d --- /dev/null +++ b/src/haiway/starlette/__init__.py @@ -0,0 +1,24 @@ +try: + import starlette # pyright: ignore[reportUnusedImport] + +except ImportError as exc: # pragma: no cover + raise ImportError( + "haiway.starlette requires the 'starlette' extra. " + "Install via `pip install haiway[starlette]`." + ) from exc + +from haiway.starlette.application import application +from haiway.starlette.context import ServerContext +from haiway.starlette.middleware import ContextMiddleware +from haiway.starlette.streaming import StreamResponse +from haiway.starlette.trace import request_trace_context +from haiway.starlette.types import ObservabilityPreparing + +__all__ = ( + "ContextMiddleware", + "ObservabilityPreparing", + "ServerContext", + "StreamResponse", + "application", + "request_trace_context", +) diff --git a/src/haiway/starlette/application.py b/src/haiway/starlette/application.py new file mode 100644 index 00000000..3a8d33e6 --- /dev/null +++ b/src/haiway/starlette/application.py @@ -0,0 +1,98 @@ +from collections.abc import Iterable, Mapping, Sequence +from typing import Any + +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.routing import BaseRoute +from starlette.types import ExceptionHandler, StatelessLifespan + +from haiway.starlette.context import ServerContext +from haiway.starlette.middleware import ContextMiddleware + +__all__ = ("application",) + + +def application( + context: ServerContext | None = None, + /, + *, + routes: Sequence[BaseRoute] = (), + middleware: Iterable[Middleware] = (), + exception_handlers: Mapping[Any, ExceptionHandler] | None = None, + lifespan: StatelessLifespan[Starlette] | None = None, + **extra: Any, +) -> Starlette: + """Prepare a Starlette application handling requests within Haiway contexts. + + Wires the two parts of the integration into a regular Starlette + application: the lifespan of the given context, preparing the application + resources, and ``ContextMiddleware``, entering a context scope for each + request. Everything else is passed through to ``Starlette`` unchanged, so an + application prepared this way is served, tested and extended like any other. + + Parameters + ---------- + context : ServerContext | None + Declaration of the request context. Defaults to an empty context, which + provides scopes and trace headers without any application state. + routes : Sequence[BaseRoute] + Routes serving the requests. + middleware : Iterable[Middleware] + Additional middlewares, nested below ``ContextMiddleware`` - each one + runs within the context scope of the request and can extend its state + through ``ctx.updating(...)``. + exception_handlers : Mapping[Any, ExceptionHandler] | None + Handlers producing responses for exceptions and status codes. A handler + nested below ``ContextMiddleware`` - anything but ``500`` or + ``Exception`` - answers within the scope of its request, so its response + carries the trace headers. The two server error slots run above every + other middleware, which is where Starlette places them, so what they + answer with is outside of the request scope and carries none. + lifespan : StatelessLifespan[Starlette] | None + Additional startup and shutdown steps, entered within the lifespan of + the context, so the application state is prepared before they run. + Startup work which owns a resource is better expressed as one of the + context disposables. + **extra : Any + Additional keyword arguments passed directly to ``Starlette`` - ``debug`` + and ``max_body_size`` among them. + + Returns + ------- + Starlette + The prepared application. + + Examples + -------- + >>> app = application( + ... ServerContext( + ... ExampleConfig(), + ... disposables=(HTTPXClient(),), + ... ), + ... routes=[Route("/example", example_endpoint)], + ... ) + + Notes + ----- + A provided ``lifespan`` must not hold a context scope open across its + ``yield`` - a scope entered on startup and left open would leak its state + into the context the server creates its request tasks in. Preparing state + for requests is what ``ServerContext`` is for, while startup work which + needs a context of its own - running migrations, for instance - belongs in a + scope entered and exited before the ``yield``. + """ + resolved_context: ServerContext = context if context is not None else ServerContext() + + return Starlette( + routes=routes, + middleware=( + Middleware( + ContextMiddleware, + context=resolved_context, + ), + *middleware, + ), + exception_handlers=exception_handlers, + lifespan=resolved_context.composed_lifespan(lifespan), + **extra, + ) diff --git a/src/haiway/starlette/context.py b/src/haiway/starlette/context.py new file mode 100644 index 00000000..aef60603 --- /dev/null +++ b/src/haiway/starlette/context.py @@ -0,0 +1,315 @@ +from collections.abc import AsyncGenerator, Collection, Iterable, Mapping, Sequence +from contextlib import ( + AbstractAsyncContextManager, + asynccontextmanager, +) +from logging import Logger, getLogger +from typing import Final, final + +from starlette.applications import Starlette +from starlette.types import Scope, StatelessLifespan + +from haiway.attributes import State +from haiway.context import ( + ContextMissing, + ContextPresets, + Disposable, + Disposables, + Observability, +) +from haiway.context.observability import LoggerObservability +from haiway.starlette.trace import request_trace_context +from haiway.starlette.types import ObservabilityPreparing + +__all__ = ("ServerContext",) + + +# the root logger rather than the scope name based default of `ctx.scope` - scope +# names carry the request path, and requesting a logger per distinct path leaks one +# into the logging registry for each of them. Named rather than root it would be a +# logger created on import, which `setup_logging` disables along with every other +# logger predating it - silently dropping everything recorded through it +DEFAULT_LOGGER: Final[Logger] = getLogger() + + +@final +class ServerContext: + """Application wide context of a Starlette application. + + Declares what the context of a request looks like - which state it carries + and how it is observed - and owns the resources that state is built from. + Two parts of an application use it: + + - ``lifespan`` prepares the application disposables on startup and releases + them on shutdown, so it has to be installed as the application lifespan. + - ``ContextMiddleware`` enters a context scope for each request, using the + state prepared by that lifespan. + + Both are wired automatically by ``application()``; installing them by hand + is what allows an existing application - a FastAPI one, for instance - to be + plugged into Haiway. + + Parameters + ---------- + *state : State | None + State propagated into every request scope. Suitable for state which is + immutable and needs no cleanup - a client bound to a resource belongs in + ``disposables`` instead. ``None`` values are ignored, which keeps a + conditionally provided element from requiring a branch. Takes precedence + over the state prepared by ``disposables`` on conflict. + disposables : Iterable[Disposable | None] + Disposables owned by the application, prepared on startup. State they + produce is propagated into every request scope and the resources behind + it are released on shutdown. ``None`` values are ignored the same way + the declared state is. Requires ``lifespan`` to be installed as the + application lifespan. + presets : Iterable[ContextPresets] + Context presets made available within request scopes, allowing a request + handler to enter a nested scope by preset name. + observability : Observability | Logger | ObservabilityPreparing + Observability backend recording request scopes. A callable is invoked + per request with the W3C trace context the request carries, which is + what continues the trace of the caller instead of starting a new one - + ``OpenTelemetry.observability`` implements it as it is. A single backend + instance is used as provided, which records requests as their own traces. + A ``Logger`` is turned into a logging backend of its own for each + request, so what one recorded is released along with it - a backend + shared by every request retains the scopes of those which never + completed, an abandoned generator among them, for as long as the + application runs. + Defaults to logging through the root logger, which is where a logging + setup always applies - a named one of ours would be silenced by a + ``setup_logging`` call made after this module was imported. + + + Examples + -------- + >>> context = ServerContext( + ... ExampleConfig(), + ... disposables=(HTTPXClient(),), + ... observability=getLogger("api"), + ... ) + >>> app = application(context, routes=[...]) + + Notes + ----- + A single instance backs a single run of a single application. The declared + disposables are the instances prepared on startup, not a factory producing + fresh ones, so a lifespan which already ended can not be entered again - a + test exercising more than one run declares a context per run. + """ + + __slots__ = ( + "_disposables", + "_observability_preparing", + "_state", + "presets", + "state", + ) + + def __init__( + self, + *state: State | None, + disposables: Iterable[Disposable | None] = (), + presets: Iterable[ContextPresets] = (), + observability: Observability | Logger | ObservabilityPreparing = DEFAULT_LOGGER, + ) -> None: + self._state: Sequence[State] = tuple(element for element in state if element is not None) + self.state: Sequence[State] + self._disposables: Disposables = Disposables.of(*disposables) + self.presets: Collection[ContextPresets] = tuple(presets) + self._observability_preparing: ObservabilityPreparing + if isinstance(observability, Observability): + + def _observability( + *, + traceparent: str | None, + tracestate: str | None, + ) -> Observability: + return observability + + self._observability_preparing = _observability + + elif isinstance(observability, Logger): + + def _observability( + *, + traceparent: str | None, + tracestate: str | None, + ) -> Observability: + return LoggerObservability(observability) + + self._observability_preparing = _observability + + else: + self._observability_preparing = observability + + def lifespan( + self, + application: Starlette | None = None, + /, + ) -> AbstractAsyncContextManager[None]: + """Prepare the application resources for as long as it runs. + + Intended to be installed as the application lifespan, which is what + makes the state of the prepared disposables available to requests:: + + app = Starlette(lifespan=context.lifespan, middleware=[...]) + + Parameters + ---------- + application : Starlette | None + The application being started, as passed by Starlette. Unused - the + context is not bound to a single application - and optional, so the + lifespan can also be entered directly, like in a test. + + Returns + ------- + AbstractAsyncContextManager[None] + Context manager preparing the application disposables when entered + and releasing them on exit. + + Raises + ------ + AssertionError + When entered a second time - the disposables of the context are the + instances it was declared with, so a lifespan which already ended + has nothing left to prepare. Checked in debug builds only, where a + wiring mistake is worth reporting rather than paying for at runtime. + """ + return self._lifespan() + + def composed_lifespan[Application: Starlette]( + self, + lifespan: StatelessLifespan[Application] | None, + /, + ) -> StatelessLifespan[Application]: + """Compose the lifespan of the context with an additional one. + + The additional lifespan is entered within the one of the context, so the + application state is already prepared when its startup runs and is + released only after its shutdown completed. + + Parameters + ---------- + lifespan : StatelessLifespan[Application] | None + The additional lifespan. ``None`` results in the lifespan of the + context alone. + + Returns + ------- + StatelessLifespan[Application] + Lifespan to install in the application. + + Notes + ----- + The additional lifespan must not hold a context scope open across its + ``yield`` - a scope entered on startup and left open would leak its + state into the context the server creates its request tasks in. + """ + if lifespan is None: + return self.lifespan + + additional: StatelessLifespan[Application] = lifespan + + @asynccontextmanager + async def composed( + application: Application, + /, + ) -> AsyncGenerator[None]: + async with self.lifespan(application), additional(application): + yield # suspend until shutdown + + return composed + + @asynccontextmanager + async def _lifespan(self) -> AsyncGenerator[None]: + assert not hasattr(self, "state"), "Server context reentrance is not allowed" # nosec: B101 + + if __debug__: + DEFAULT_LOGGER.warning("Starting DEBUG server...") + + else: + DEFAULT_LOGGER.info("Starting server...") + + try: + DEFAULT_LOGGER.info("...initializing server state...") + async with self._disposables as disposable_state: + # explicitly declared state goes last to take precedence + # over the state prepared by the disposables + self.state = (*disposable_state, *self._state) + DEFAULT_LOGGER.info("...server state initialized...") + + try: + yield # suspend until shutdown + + finally: + DEFAULT_LOGGER.info("...closing server...") + + finally: + DEFAULT_LOGGER.info("...server closed!") + + def request_state(self) -> Sequence[State]: + """Resolve the state propagated into a request scope. + + Returns + ------- + Sequence[State] + The state prepared by the disposables of the context, followed by + the state it was declared with, which takes precedence over it. + + Raises + ------ + ContextMissing + When the lifespan of the context did not prepare the state - either + it was not installed as the application lifespan, or the startup it + belongs to has not completed yet. + + Notes + ----- + Only meaningful for as long as the application runs. The state prepared + on startup is reported as it is once shutdown released the resources + behind it, which is not a usage a server produces - it stops serving + before it shuts the application down - and is not guarded against. + """ + try: + return self.state + + except AttributeError: + raise ContextMissing( + "Server context state requested but not prepared -" + " `ServerContext.lifespan` has to be installed as the application" + " lifespan and its startup has to complete before requests" + " are served" + ) from None + + def request_observability( + self, + request_scope: Scope, + /, + ) -> Observability: + """Resolve the observability backend recording a request scope. + + Parameters + ---------- + request_scope : Scope + ASGI scope of the incoming request, which is where the trace context + handed to a backend factory is read from. + + Returns + ------- + Observability + The backend recording the request. A backend declared by the context + is used as it is, while a ``Logger`` becomes a logging backend built + here for each request. A callable is invoked per request with the + W3C trace context the request carries, which is what continues the + trace of its caller. + """ + # the trace context of every request is resolved here, so a backend which + # continues the trace of its caller needs no wiring of its own + trace_context: Mapping[str, str] = request_trace_context(request_scope) + + return self._observability_preparing( + traceparent=trace_context.get("traceparent"), + tracestate=trace_context.get("tracestate"), + ) diff --git a/src/haiway/starlette/middleware.py b/src/haiway/starlette/middleware.py new file mode 100644 index 00000000..3d5c7b59 --- /dev/null +++ b/src/haiway/starlette/middleware.py @@ -0,0 +1,250 @@ +from collections.abc import Mapping, MutableMapping +from typing import Any, final + +from starlette.datastructures import MutableHeaders +from starlette.exceptions import HTTPException, WebSocketException +from starlette.requests import ClientDisconnect +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from haiway.context import ObservabilityAttribute +from haiway.context.access import ctx +from haiway.starlette.context import ServerContext + +__all__ = ("ContextMiddleware",) + + +@final +class ContextMiddleware: + """ASGI middleware handling each request within a Haiway context scope. + + Enters a context scope around the rest of the application, which makes the + state declared by a ``ServerContext`` - and the state prepared by its + disposables - available to everything handling the request, including the + middlewares nested below it, the endpoint, its background tasks and the + generator of a streaming response. + + Requests are affected in four ways: + + - a scope is entered for each ``http`` and ``websocket`` request, recording + it as one trace, and named after the request: the method and the requested + path - ``"GET /users/12345"`` - with ``"WS"`` in place of the method for a + websocket connection, which carries none. Other request types, ``lifespan`` + included, are passed through untouched. + - the request is recorded into its scope as the HTTP semantic conventions of + OpenTelemetry describe it - ``http.route``, ``url.path``, + ``http.request.method`` and ``http.response.status_code`` among the + attributes - once it was handled, which is when the route it matched and + the status it was answered with are known. ``http.route`` is what a + parameterized route is findable by, since the scope name carries the path + which was actually requested rather than the template behind it. + - a response carries the trace headers of its request scope, whether it was + produced by an endpoint or by an exception handler nested below this + middleware. For a websocket request that is the response denying its + handshake - an accepted connection switches the protocol rather than + answering, so it carries none. An entry a header can not hold is left out + rather than failing the response it belongs to. + - an exception which no handler answered propagates through the scope of its + request, which is what records it as the failure of that request, and is + reraised afterwards. Answering it is left to the application: the server + error handling of the framework sits above this middleware, so the ``500`` + it produces - the plain one, the traceback page of a ``debug`` application, + or a registered handler of ``Exception`` or ``500`` - is what the client + receives, and carries no trace headers of its own. + + ``HTTPException``, ``WebSocketException`` and ``ClientDisconnect`` are not a + failure of the request they end - the first two are how an application asks + for a specific response, the third is a consumer which went away. They are + withheld while the scope is left, so it does not record the request as + failed, and reraised afterwards for whatever handles them upstream. + + Parameters + ---------- + app : ASGIApp + The application handling requests within the prepared scope. + + Examples + -------- + >>> context = ServerContext(disposables=(HTTPXClient(),)) + >>> app = Starlette( + ... routes=[...], + ... middleware=[Middleware(ContextMiddleware, context=context)], + ... lifespan=context.lifespan, + ... ) + + Notes + ----- + Placing it as the outermost middleware is what makes the context available + to the other middlewares of the application - which is where + ``application()`` puts it. State derived from a request, like the identity of + its caller, can be added by a middleware nested below it through + ``ctx.updating(...)``. + """ + + __slots__ = ( + "_app", + "_context", + ) + + def __init__( + self, + app: ASGIApp, + /, + context: ServerContext, + ) -> None: + self._app: ASGIApp = app + self._context: ServerContext = context + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + match scope["type"]: + case "http": + method: str = scope["method"] + await self._handle( + scope=scope, + receive=receive, + send=send, + name=f"{method} {scope['path']}", + method=method, + response_start="http.response.start", + ) + + case "websocket": + await self._handle( + scope=scope, + receive=receive, + send=send, + name=f"WS {scope['path']}", + # a websocket connection carries no method - the name of its + # scope says what it is instead, and nothing is recorded as + # the method it does not have + method=None, + response_start="websocket.http.response.start", + ) + + case _: + await self._app(scope, receive, send) + + async def _handle( + self, + *, + scope: Scope, + receive: Receive, + send: Send, + name: str, + method: str | None, + response_start: str, + ) -> None: + with ctx.presets(*self._context.presets): + async with ctx.scope( + name, + *self._context.request_state(), + observability=self._context.request_observability(scope), + ) as trace_id: + # the status of the response, which only the message carrying it + # reports - an aborted request is answered with none at all + status: int | None = None + + async def traced_send(message: Message) -> None: + nonlocal status + if message["type"] == response_start: + status = message["status"] + # headers are optional in the message - a response without + # any is what an application sending raw messages can do + message.setdefault("headers", []) + MutableHeaders(scope=message).update( + { + "trace-id": trace_id, + **ctx.trace_context(), + } + ) + + await send(message) + + # an exception which does not fail the request is held here rather than + # raised, so it does not travel through the scope - and is reraised once + # the scope was left, for whatever handles it upstream + withheld: BaseException | None = None + try: + # errors are left to propagate through the scope, which is what + # records them as the failure of the request they belong to + await self._app(scope, receive, traced_send) + + except (HTTPException, WebSocketException, ClientDisconnect) as exc: + withheld = exc + + finally: + # recorded here rather than before the request - the route it + # matched is resolved by the routing below this middleware, + # and the status only by the response. Recorded even for a + # request which failed, which is where it is needed most + ctx.record_info( + attributes=_request_attributes( + scope, + method=method, + status=status, + ) + ) + + if withheld is not None: + raise withheld + + +def _request_attributes( + scope: Scope, + /, + *, + method: str | None, + status: int | None, +) -> Mapping[str, ObservabilityAttribute]: + """Describe a request the way the HTTP semantic conventions of OpenTelemetry do. + + Recorded once the request was handled, which is when the route it matched + and the status it was answered with are both available. ``http.route`` is + the template behind the requested path, so it is what makes the requests of + a parameterized route findable as one, while ``url.path`` keeps the path + which was actually requested. + + The route is read from the request scope, which is where the routing leaves + the route it matched - FastAPI puts it there, Starlette does not, so a plain + Starlette application records no ``http.route`` unless it reports one itself + through ``ctx.record_info(attributes={"http.route": ...})`` from within the + request. Resolving it here instead would mean matching the route table a + second time for every request, which is the cost the routing already paid. + + The method is recorded as received, and only for an ``http`` request - a + websocket connection carries none, so reporting one would be inventing it. + + The query string is left out - it carries credentials often enough that + recording it by default would leak them - and so is the address of the + caller, which identifies it. + """ + attributes: MutableMapping[str, ObservabilityAttribute] = { + "url.path": scope["path"], + } + + if method is not None: + attributes["http.request.method"] = method + + scheme: Any | None = scope.get("scheme") + if scheme: + attributes["url.scheme"] = scheme + + # `path_format` is the template of the path a route matched, which is what a + # FastAPI `APIRoute` left in the scope. Read defensively - the scope of a + # request which matched nothing holds no route at all + route_path: Any | None = getattr(scope.get("route"), "path_format", None) + if isinstance(route_path, str): + attributes["http.route"] = route_path + + protocol_version: Any | None = scope.get("http_version") + if protocol_version: + attributes["network.protocol.version"] = protocol_version + + if status is not None: + attributes["http.response.status_code"] = status + + return attributes diff --git a/src/haiway/starlette/streaming.py b/src/haiway/starlette/streaming.py new file mode 100644 index 00000000..04d07c2d --- /dev/null +++ b/src/haiway/starlette/streaming.py @@ -0,0 +1,154 @@ +from collections.abc import AsyncGenerator, Mapping +from typing import final + +from starlette.background import BackgroundTask +from starlette.requests import ClientDisconnect +from starlette.responses import StreamingResponse +from starlette.types import Message, Send + +from haiway.context import ctx + +__all__ = ("StreamResponse",) + + +@final +class StreamResponse(StreamingResponse): + """Response streaming the elements of an async generator. + + The Haiway counterpart of ``starlette.responses.StreamingResponse``: it + streams the same way and adds closing the generator where the streaming + ends - when it ran out of chunks and when the consumer went away - which is + what a generator holding a context scope requires. + + The scope of the request stays entered for the whole response, so the + generator resolves state, records observability and reports the trace of the + request it belongs to - the middleware returns only once the last chunk was + sent. + + Parameters + ---------- + content : AsyncGenerator[bytes | str] + Source of the response body. A full generator is required, not any + async iterable, because an abandoned stream has to be closable - wrap an + iterator in a generator to stream it. + status_code : int + Status code of the response. + headers : Mapping[str, str] | None + Headers of the response. + media_type : str | None + Media type of the response body. + background : BackgroundTask | None + Task to run once the body was streamed, still within the scope of the + request. + + Examples + -------- + >>> async def endpoint(request: Request) -> Response: + ... async def content() -> AsyncGenerator[bytes]: + ... async for row in Postgres.fetch_rows(QUERY): # request state + ... yield row.get_str("payload").encode() + ... + ... return StreamResponse(content(), media_type="application/x-ndjson") + + Raises + ------ + AssertionError + When ``content`` is not an async generator - a response of this kind is + returned from a handler rather than declared as its response class, + which would be handed a serialized value instead. Checked in debug + builds only, where a wiring mistake is worth reporting rather than + paying for on every response. + + Notes + ----- + A server advertising ASGI spec version 2.4 or newer is required, which is + the one reporting a gone consumer by failing the send. Below that version + the framework ends a streamed response by cancelling it, which can not close + a body whose cleanup awaits anything - the cancellation is delivered again + at the first await of that cleanup, leaving the body suspended halfway + through it. + + A generator opening a scope of its own has to keep it inside itself, which + is what ``ctx.stream`` provides - a scope entered around building the + generator is already released by the time the streaming starts. + """ + + def __init__( + self, + content: AsyncGenerator[bytes | str], + status_code: int = 200, + headers: Mapping[str, str] | None = None, + media_type: str | None = None, + background: BackgroundTask | None = None, + ) -> None: + assert isinstance(content, AsyncGenerator) # nosec: B101 + super().__init__( + content=content, + status_code=status_code, + headers=headers, + media_type=media_type, + background=background, + ) + # kept typed, unlike the `body_iterator` of the framework, which is + # allowed to be an async iterable with nothing to close + self._stream: AsyncGenerator[bytes | str] = content + + async def stream_response( + self, + send: Send, + ) -> None: + # a gone consumer is reported by the send failing, which is what tells it + # apart from a body failing with an `OSError` of its own - the framework + # collapses the two into a `ClientDisconnect` above this, where the + # failure of a body would be lost with nothing recording it + disconnected: bool = False + + async def tracked_send(message: Message) -> None: + nonlocal disconnected + try: + await send(message) + + except OSError: + disconnected = True + raise + + try: + await super().stream_response(tracked_send) + + except Exception as exc: + if disconnected or isinstance(exc, ClientDisconnect): + # a consumer which went away is not a failure of the response it + # ended - recorded as what happened rather than as an error + ctx.log_debug("Response streaming ended by a disconnected consumer") + + else: + # recorded here, where the failure actually is. A response which + # already started can not be answered with an error, so this one + # travels out through the exception handling of the framework, + # which replaces it with a `RuntimeError` about a response + # already started whenever a handler matches its type - what the + # scope of the request would then record instead of the failure + # which happened + ctx.log_error( + "Response streaming failed", + exception=exc, + ) + + raise # the transport still has to end the response as incomplete + + finally: + # closed where the streaming ended, whether the body ran out or the + # connection went away. Leaving it to the garbage collector would + # finalize it in a fresh context, where a scope it opened - what + # `ctx.stream` provides - can no longer be released + try: + await self._stream.aclose() + + except Exception as exc: + # a failure to close can not fix the response and must not + # replace what is already on its way out - cancellation is not + # caught here, it has to keep unwinding + ctx.log_warning( + "Response stream failed to close", + exception=exc, + ) diff --git a/src/haiway/starlette/trace.py b/src/haiway/starlette/trace.py new file mode 100644 index 00000000..a180a84a --- /dev/null +++ b/src/haiway/starlette/trace.py @@ -0,0 +1,76 @@ +from collections.abc import Mapping + +from starlette.types import Scope + +__all__ = ("request_trace_context",) + + +def request_trace_context( + scope: Scope, + /, +) -> Mapping[str, str]: + """Read the W3C trace context carried by an incoming request. + + Resolved for every request by ``ServerContext``, which hands the result + to the observability backend it prepares - continuing the trace of the caller + requires no wiring of its own. Available separately for an application + reading the trace context for something else, like propagating it onwards. + + Parameters + ---------- + scope : Scope + ASGI scope of the incoming request. A scope without headers - anything + other than an ``http`` or ``websocket`` scope - carries no trace. Header + names are matched case insensitively, so a server which does not + lowercase them as the ASGI specification requires is tolerated. + + Returns + ------- + Mapping[str, str] + The ``traceparent`` entry, accompanied by ``tracestate`` when present. + Empty when the request carries no usable ``traceparent`` - ``tracestate`` + alone identifies no trace position, so it is never reported by itself. + + Notes + ----- + A request carrying more than one ``traceparent`` header has no single trace + position to continue, so the whole trace context is discarded - the + specification requires such a request to start a new trace rather than to + join an arbitrary one of them. Only the first ``tracestate`` header is read, + which is what a caller splitting a long trace state across several of them + has to account for. + + Surrounding whitespace is stripped, since a header value carries it without + it being a part of the value, and an entry left empty by that is reported as + the absent one it is. Values are otherwise passed on as received - deciding + what to make of a malformed one belongs to the observability backend, which + validates it as the specification requires. The OpenTelemetry integration + continues a valid trace and starts its own for anything else. + """ + traceparent: str | None = None + tracestate: str | None = None + conflicting: bool = False + + # read from the raw headers rather than through `Headers`, which reads the + # scope of a request without any and rewrites the one it is given + for raw_name, raw_value in scope.get("headers", ()): + name: bytes = raw_name.lower() + if name == b"traceparent": + if traceparent is not None: + conflicting = True # no single position to continue + + traceparent = raw_value.decode("latin-1").strip() + + elif name == b"tracestate" and tracestate is None: + tracestate = raw_value.decode("latin-1").strip() + + if conflicting or not traceparent: + return {} + + if tracestate: + return { + "traceparent": traceparent, + "tracestate": tracestate, + } + + return {"traceparent": traceparent} diff --git a/src/haiway/starlette/types.py b/src/haiway/starlette/types.py new file mode 100644 index 00000000..47b1f4af --- /dev/null +++ b/src/haiway/starlette/types.py @@ -0,0 +1,38 @@ +from typing import Protocol, runtime_checkable + +from haiway.context import Observability + +__all__ = ("ObservabilityPreparing",) + + +@runtime_checkable +class ObservabilityPreparing(Protocol): + """Prepare the observability backend recording a single request. + + Called for each request, before its scope context is entered, with the W3C + trace context the request carries. A fresh backend per request is what + allows the trace of the caller to be continued instead of a new one being + started, which is why the trace context is resolved for every request and + handed over here. + + ``OpenTelemetry.observability`` implements this protocol as it is, so + continuing incoming traces takes no wiring beyond passing it:: + + ServerContext(observability=OpenTelemetry.observability) + + Both values are ``None`` when the request carries no usable trace context, + which is what a backend answers with a trace of its own. They are passed on + as received - validating them is the responsibility of the backend, which + the specification requires to reject a malformed one and start over. + + A backend has to be returned for every request - to record through logging, + ``LoggerObservability`` builds one out of a ``Logger``, which is what the + context does with a logger passed to it directly. + """ + + def __call__( + self, + *, + traceparent: str | None, + tracestate: str | None, + ) -> Observability: ... diff --git a/src/haiway/utils/__init__.py b/src/haiway/utils/__init__.py index 241afd45..3554053e 100644 --- a/src/haiway/utils/__init__.py +++ b/src/haiway/utils/__init__.py @@ -6,6 +6,7 @@ as_tuple, without_missing, ) +from haiway.utils.context import NoopAsyncContext, NoopContext from haiway.utils.env import ( getenv, getenv_base64, @@ -27,6 +28,8 @@ "AsyncQueueEmpty", "AsyncStream", "JSONLogFormatter", + "NoopAsyncContext", + "NoopContext", "Paginated", "Pagination", "PaginationToken", diff --git a/src/haiway/utils/context.py b/src/haiway/utils/context.py new file mode 100644 index 00000000..e72eef36 --- /dev/null +++ b/src/haiway/utils/context.py @@ -0,0 +1,63 @@ +from types import TracebackType +from typing import ClassVar, Self, final + +__all__ = ( + "NoopAsyncContext", + "NoopContext", +) + + +@final +class NoopContext: + """Context manager doing nothing when entered or exited. + + Stands in where a context manager has to be returned but there is nothing to + do - ``ctx.updating()`` with no state, or ``ctx.presets()`` with no presets - + so an empty call neither costs a context variable update nor replaces what is + already in place. Stateless, which is why ``instance`` is shared instead of a + fresh one being created per use. + """ + + instance: ClassVar[Self] # defined after the class + + def __enter__(self) -> None: + pass + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + pass + + +NoopContext.instance = NoopContext() + + +@final +class NoopAsyncContext: + """Async context manager doing nothing when entered or exited. + + The asynchronous counterpart of ``NoopContext``, standing in where an async + context manager has to be returned with nothing to prepare or release - + ``ctx.disposables()`` with no disposables. Entering it awaits nothing, so it + does not even yield to the event loop. Stateless, which is why ``instance`` + is shared instead of a fresh one being created per use. + """ + + instance: ClassVar[Self] # defined after the class + + async def __aenter__(self) -> None: + pass + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + pass + + +NoopAsyncContext.instance = NoopAsyncContext() diff --git a/tests/asgi.py b/tests/asgi.py new file mode 100644 index 00000000..19ce1b9f --- /dev/null +++ b/tests/asgi.py @@ -0,0 +1,179 @@ +from asyncio import Queue, Task, create_task +from collections.abc import AsyncGenerator, MutableMapping, MutableSequence, Sequence +from contextlib import asynccontextmanager +from logging import Handler, Logger, LogRecord +from types import TracebackType +from typing import Any, Final + +from starlette.types import ASGIApp, Message, Send + +__all__ = ( + "TRACE_ID_HEADER", + "LogCapture", + "Result", + "http_scope", + "running", + "send_request", + "websocket_scope", +) + + +# the header `ServerContext.response_headers` reports the trace identifier through +TRACE_ID_HEADER: Final[str] = "trace-id" + + +def http_scope( + *, + method: str = "GET", + path: str = "/example", + query: str = "", + headers: Sequence[tuple[bytes, bytes]] = (), +) -> MutableMapping[str, Any]: + return { + "type": "http", + # 2.4 is what reports a gone consumer by failing the send, which is what + # a streamed response requires + "asgi": {"version": "3.0", "spec_version": "2.4"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": query.encode(), + "root_path": "", + "headers": list(headers), + "client": ("127.0.0.1", 54321), + "server": ("testserver", 80), + "state": {}, + } + + +def websocket_scope( + *, + path: str = "/example", + headers: Sequence[tuple[bytes, bytes]] = (), +) -> MutableMapping[str, Any]: + return { + "type": "websocket", + "asgi": {"version": "3.0", "spec_version": "2.4"}, + "path": path, + "raw_path": path.encode(), + "query_string": b"", + "root_path": "", + "scheme": "ws", + "headers": list(headers), + "client": ("127.0.0.1", 54321), + "server": ("testserver", 80), + "subprotocols": [], + "state": {}, + } + + +class Result: + """Collector of the messages of a single response.""" + + def __init__(self) -> None: + self.status: int = 0 + self.headers: MutableMapping[str, str] = {} + self.body: bytes = b"" + self.chunks: MutableSequence[bytes] = [] + + def collecting(self) -> Send: + async def send(message: Message) -> None: + if message["type"] == "http.response.start": + self.status = message["status"] + for name, value in message.get("headers", ()): + self.headers[name.decode()] = value.decode() + + elif message["type"] == "http.response.body": + chunk: bytes = message.get("body", b"") + self.body += chunk + if chunk: + self.chunks.append(chunk) + + return send + + +async def receive_request() -> Message: + return {"type": "http.request", "body": b"", "more_body": False} + + +async def send_request( + app: ASGIApp, + /, + *, + method: str = "GET", + path: str = "/example", + query: str = "", + headers: Sequence[tuple[bytes, bytes]] = (), +) -> Result: + result = Result() + await app( + http_scope(method=method, path=path, query=query, headers=headers), + receive_request, + result.collecting(), + ) + + return result + + +@asynccontextmanager +async def running( + app: ASGIApp, + /, +) -> AsyncGenerator[None]: + """Drive the ASGI lifespan of an application for the duration of the block.""" + incoming: Queue[Message] = Queue() + outgoing: Queue[Message] = Queue() + task: Task[None] = create_task( + app( + {"type": "lifespan", "asgi": {"version": "3.0"}, "state": {}}, + incoming.get, + outgoing.put, + ) + ) + + await incoming.put({"type": "lifespan.startup"}) + startup: Message = await outgoing.get() + if startup["type"] != "lifespan.startup.complete": + await task + raise AssertionError(startup) + + try: + yield + + finally: + await incoming.put({"type": "lifespan.shutdown"}) + await outgoing.get() + await task + + +class LogCapture(Handler): + def __init__( + self, + logger: Logger, + /, + ) -> None: + super().__init__() + self.logger: Logger = logger + self.records: MutableSequence[str] = [] + + def emit( + self, + record: LogRecord, + /, + ) -> None: + self.records.append(record.getMessage()) + + def __enter__(self) -> MutableSequence[str]: + self.logger.addHandler(self) + self.logger.setLevel(1) + return self.records + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.logger.removeHandler(self) diff --git a/tests/test_context_presets.py b/tests/test_context_presets.py index 149cf0fd..8bae04e1 100644 --- a/tests/test_context_presets.py +++ b/tests/test_context_presets.py @@ -400,7 +400,7 @@ async def disposable(): @mark.asyncio -async def test_nested_preset_registries(): +async def test_nested_preset_registries_are_allowed(): preset1 = ContextPresets.of( "outer", ConfigState(api_url="https://outer.com"), @@ -411,31 +411,13 @@ async def test_nested_preset_registries(): ConfigState(api_url="https://inner.com"), ) - preset3 = ContextPresets.of( - "outer", # Same name as preset1 - ConfigState(api_url="https://inner-override.com"), - ) - with ctx.presets(preset1): async with ctx.scope("outer"): config = ctx.state(ConfigState) assert config.api_url == "https://outer.com" - # Nested registry - with ctx.presets(preset2, preset3): - # Inner registry shadows outer - async with ctx.scope("outer"): - config = ctx.state(ConfigState) - assert config.api_url == "https://inner-override.com" - - async with ctx.scope("inner"): - config = ctx.state(ConfigState) - assert config.api_url == "https://inner.com" - - # Back to outer registry - async with ctx.scope("outer"): - config = ctx.state(ConfigState) - assert config.api_url == "https://outer.com" + with ctx.presets(preset2): + pass # pragma: no cover @mark.asyncio diff --git a/tests/test_fastapi.py b/tests/test_fastapi.py new file mode 100644 index 00000000..e2f982dc --- /dev/null +++ b/tests/test_fastapi.py @@ -0,0 +1,486 @@ +from collections.abc import AsyncGenerator, Iterable, MutableSequence +from contextlib import asynccontextmanager +from logging import Logger, getLogger +from typing import Annotated, Any + +import pytest + +pytest.importorskip("fastapi") + +from fastapi import APIRouter, BackgroundTasks, Depends, FastAPI, HTTPException, Request, Response +from fastapi.responses import JSONResponse +from pytest import mark, raises +from starlette.middleware import Middleware +from starlette.types import ASGIApp, Receive, Scope, Send + +from haiway import ContextMissing, State, ctx +from haiway.fastapi import ( + ServerContext, + StreamResponse, + application, +) +from tests.asgi import ( + TRACE_ID_HEADER, + LogCapture, + Result, + http_scope, + receive_request, + running, + send_request, +) + + +class ExampleState(State): + value: str = "example" + + +class DisposableState(State): + value: str = "disposable" + + +class ExampleDisposable: + def __init__( + self, + log: MutableSequence[str], + /, + ) -> None: + self.log: MutableSequence[str] = log + + async def __aenter__(self) -> Iterable[State]: + self.log.append("enter") + return (DisposableState(),) + + async def __aexit__( + self, + exc_type: Any, + exc_val: Any, + exc_tb: Any, + ) -> None: + self.log.append("exit") + + +@mark.asyncio +async def test_state_is_available_within_endpoint() -> None: + router = APIRouter(prefix="/api/v1") + + @router.get("/example") + async def example() -> dict[str, str]: + return { + "declared": ctx.state(ExampleState).value, + "prepared": ctx.state(DisposableState).value, + } + + log: MutableSequence[str] = [] + app: FastAPI = application( + ServerContext( + ExampleState(), + disposables=(ExampleDisposable(log),), + ), + routers=(router,), + ) + + async with running(app): + assert log == ["enter"] + result: Result = await send_request(app, path="/api/v1/example") + + assert result.status == 200 + assert result.body == b'{"declared":"example","prepared":"disposable"}' + assert log == ["enter", "exit"] + + +@mark.asyncio +async def test_response_carries_trace_headers() -> None: + router = APIRouter() + + @router.get("/example") + async def example() -> dict[str, str]: + return {"trace": ctx.trace_id()} + + app: FastAPI = application(routers=(router,)) + + async with running(app): + result: Result = await send_request(app) + + assert result.status == 200 + assert result.headers[TRACE_ID_HEADER] in result.body.decode() + + +@mark.asyncio +async def test_handled_exception_response_carries_trace_headers() -> None: + router = APIRouter() + + @router.get("/example") + async def example() -> dict[str, str]: + raise HTTPException(status_code=404, detail="missing") + + app: FastAPI = application(routers=(router,)) + + async with running(app): + result: Result = await send_request(app) + + assert result.status == 404 + assert result.body == b'{"detail":"missing"}' + assert TRACE_ID_HEADER in result.headers + + +@mark.asyncio +async def test_validation_error_response_carries_trace_headers() -> None: + router = APIRouter() + + @router.get("/example") + async def example(value: int) -> dict[str, int]: + return {"value": value} + + app: FastAPI = application(routers=(router,)) + + async with running(app): + result: Result = await send_request(app, query="value=invalid") + + # the response of the FastAPI validation handler, nested within the context + assert result.status == 422 + assert TRACE_ID_HEADER in result.headers + + +@mark.asyncio +async def test_unhandled_exception_is_answered_by_the_framework() -> None: + router = APIRouter() + + @router.get("/example") + async def example() -> dict[str, str]: + raise ValueError("broken") + + app: FastAPI = application(routers=(router,)) + result = Result() + + async with running(app): + with raises(ValueError): # reraised for the server to report + await app(http_scope(), receive_request, result.collecting()) + + # answered by the server error handling of the framework, which sits above + # the middleware - so outside of the scope of the request, without its headers + assert result.status == 500 + assert result.body == b"Internal Server Error" + assert TRACE_ID_HEADER not in result.headers + + +@mark.asyncio +async def test_request_without_lifespan_is_refused() -> None: + router = APIRouter() + + @router.get("/example") + async def example() -> dict[str, str]: + return {"status": "done"} + + app: FastAPI = application( + ServerContext(disposables=(ExampleDisposable([]),)), + routers=(router,), + ) + result = Result() + + # the state of a request scope is what the lifespan prepares, so there is + # nothing to serve a request with before it ran + with raises(ContextMissing): + await app(http_scope(), receive_request, result.collecting()) + + +@mark.asyncio +async def test_additional_lifespan_runs_within_prepared_context() -> None: + log: MutableSequence[str] = [] + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncGenerator[None]: + log.append("startup") + try: + yield + + finally: + log.append("shutdown") + + app: FastAPI = application( + ServerContext(disposables=(ExampleDisposable(log),)), + lifespan=lifespan, + ) + + async with running(app): + pass + + assert log == ["enter", "startup", "shutdown", "exit"] + + +@mark.asyncio +async def test_nested_middleware_runs_within_context() -> None: + class NestedMiddleware: + def __init__( + self, + app: ASGIApp, + /, + ) -> None: + self.app: ASGIApp = app + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + with ctx.updating(ExampleState(value="middleware")): + await self.app(scope, receive, send) + + router = APIRouter() + + @router.get("/example") + async def example() -> dict[str, str]: + return {"value": ctx.state(ExampleState).value} + + app: FastAPI = application( + ServerContext(ExampleState()), + routers=(router,), + middleware=[Middleware(NestedMiddleware)], + ) + + async with running(app): + result: Result = await send_request(app) + + assert result.body == b'{"value":"middleware"}' + + +@mark.asyncio +async def test_extra_arguments_are_passed_through() -> None: + router = APIRouter() + + @router.get("/example", summary="Example") + async def example() -> dict[str, str]: + return {"status": "done"} + + app: FastAPI = application( + routers=(router,), + title="Example API", + version="2.1.0", + openapi_url="/schema.json", + ) + + assert app.title == "Example API" + assert app.version == "2.1.0" + + async with running(app): + result: Result = await send_request(app, path="/schema.json") + + assert result.status == 200 + assert b'"title":"Example API"' in result.body + assert b'"/example"' in result.body + + +@mark.asyncio +async def test_exception_handlers_are_installed() -> None: + class ExampleError(Exception): + pass + + router = APIRouter() + + @router.get("/example") + async def example() -> dict[str, str]: + raise ExampleError + + async def handle_example_error( + request: Request, + exc: Any, + ) -> Response: + return JSONResponse( + {"detail": "handled"}, + status_code=418, + ) + + app: FastAPI = application( + routers=(router,), + exception_handlers={ExampleError: handle_example_error}, + ) + + async with running(app): + result: Result = await send_request(app) + + assert result.status == 418 + assert TRACE_ID_HEADER in result.headers + + +@mark.asyncio +async def test_registered_server_error_handler_answers_the_request() -> None: + router = APIRouter() + + @router.get("/example") + async def example() -> dict[str, str]: + raise ValueError("broken") + + async def handle_server_error( + request: Request, + exception: Any, + ) -> Response: + return JSONResponse({"detail": "handled"}, status_code=503) + + app: FastAPI = application( + routers=(router,), + # a server error handler answers above the middleware, in place of the + # plain `500` the framework would produce + exception_handlers={Exception: handle_server_error}, + ) + result = Result() + + async with running(app): + with raises(ValueError): # reraised for the server to report + await app(http_scope(), receive_request, result.collecting()) + + assert result.status == 503 + assert result.body == b'{"detail":"handled"}' + + +@mark.asyncio +async def test_dependency_teardown_runs_within_context() -> None: + recorded: MutableSequence[str] = [] + + async def scoped_value() -> AsyncGenerator[str]: + yield ctx.state(ExampleState).value + # the exit stack of the dependency is nested below the middleware, so the + # teardown of a dependency still resolves the state of its request + recorded.append(ctx.state(ExampleState).value) + + router = APIRouter() + + @router.get("/example") + async def example(value: Annotated[str, Depends(scoped_value)]) -> dict[str, str]: + return {"value": value} + + app: FastAPI = application( + ServerContext(ExampleState(value="teardown")), + routers=(router,), + ) + + async with running(app): + result: Result = await send_request(app) + + assert result.body == b'{"value":"teardown"}' + assert recorded == ["teardown"] + + +@mark.asyncio +async def test_dependencies_resolve_context_state() -> None: + async def resolved_value() -> str: + # dependencies are resolved within the scope of the request + return ctx.state(ExampleState).value + + router = APIRouter() + + @router.get("/example") + async def example(value: Annotated[str, Depends(resolved_value)]) -> dict[str, str]: + return {"value": value} + + app: FastAPI = application( + ServerContext(ExampleState(value="dependency")), + routers=(router,), + ) + + async with running(app): + result: Result = await send_request(app) + + assert result.body == b'{"value":"dependency"}' + + +@mark.asyncio +async def test_synchronous_endpoint_resolves_context_state() -> None: + router = APIRouter() + + @router.get("/example") + def example() -> dict[str, str]: # runs in a worker thread + return {"value": ctx.state(ExampleState).value} + + app: FastAPI = application( + ServerContext(ExampleState(value="threaded")), + routers=(router,), + ) + + async with running(app): + result: Result = await send_request(app) + + assert result.status == 200 + assert result.body == b'{"value":"threaded"}' + + +@mark.asyncio +async def test_background_task_runs_within_context() -> None: + recorded: MutableSequence[str] = [] + + router = APIRouter() + + @router.get("/example") + async def example(background: BackgroundTasks) -> dict[str, str]: + async def record() -> None: + recorded.append(ctx.state(ExampleState).value) + + background.add_task(record) + return {"status": "accepted"} + + app: FastAPI = application( + ServerContext(ExampleState(value="background")), + routers=(router,), + ) + + async with running(app): + result: Result = await send_request(app) + + assert result.status == 200 + assert recorded == ["background"] + + +@mark.asyncio +async def test_stream_endpoint_streams_within_the_request_scope() -> None: + router = APIRouter() + + @router.get("/stream") + async def stream() -> StreamResponse: + async def produce() -> AsyncGenerator[bytes]: + # the state and the trace of the request, resolved mid-stream + yield ctx.state(ExampleState).value.encode() + yield ctx.trace_id().encode() + + return StreamResponse(produce(), media_type="application/x-ndjson") + + app: FastAPI = application( + ServerContext(ExampleState(value="streamed")), + routers=(router,), + ) + + async with running(app): + result: Result = await send_request(app, path="/stream") + + assert result.headers["content-type"] == "application/x-ndjson" + assert result.chunks == [b"streamed", result.headers[TRACE_ID_HEADER].encode()] + + +@mark.asyncio +async def test_request_is_recorded_with_its_route() -> None: + logger: Logger = getLogger("test-fastapi-route") + router = APIRouter() + + @router.get("/users/{identifier}/orders/{order}") + async def endpoint(identifier: str, order: str) -> Response: + return JSONResponse({"identifier": identifier, "order": order}) + + app: FastAPI = application( + ServerContext(observability=logger), + routers=(router,), + ) + + with LogCapture(logger) as records: + async with running(app): + result: Result = await send_request(app, path="/users/12345/orders/7") + + assert result.status == 200 + # the requested path names the scope, the route template it matched does not - + # the middleware runs before routing, which is what resolves the template + assert any("Entering scope: GET /users/12345/orders/7" in record for record in records) + + recorded: str = "\n".join(record for record in records if "Attributes:" in record) + # the routing of FastAPI leaves the route it matched in the scope, so the + # template is what identifies the request rather than the path it carried + assert '"http.route"]: "/users/{identifier}/orders/{order}"' in recorded + assert '"url.path"]: "/users/12345/orders/7"' in recorded + assert '"http.request.method"]: "GET"' in recorded + assert '"http.response.status_code"]: 200' in recorded diff --git a/tests/test_http_client.py b/tests/test_http_client.py index 953779f4..6ccd229f 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -1,7 +1,7 @@ import asyncio from collections.abc import AsyncGenerator, Mapping from types import TracebackType -from typing import Any, NamedTuple, cast +from typing import Any, NamedTuple from uuid import UUID, uuid4 from pytest import mark, raises @@ -572,6 +572,12 @@ async def capturing(method: str, /, **kwargs: Any) -> HTTPResponse: headers={"Traceparent": "provided"}, trace_propagation=True, ) + # headers covering the whole trace context are handed over untouched + await client.get( + url="/resource", + headers={"Traceparent": "provided", "TraceState": "managed"}, + trace_propagation=True, + ) assert captured == [ {"traceparent": traceparent, "tracestate": "vendor=value"}, @@ -581,9 +587,57 @@ async def capturing(method: str, /, **kwargs: Any) -> HTTPResponse: "tracestate": "vendor=value", }, {"Traceparent": "provided", "tracestate": "vendor=value"}, + {"Traceparent": "provided", "TraceState": "managed"}, ] +@mark.asyncio +async def test_http_client_propagates_trace_context_when_the_request_asks_for_it() -> None: + captured: list[HTTPHeaders | None] = [] + + async def capturing(method: str, /, **kwargs: Any) -> HTTPResponse: + captured.append(kwargs["headers"]) + return HTTPResponse(status_code=200, headers={}, body=b"ok") + + traceparent: str = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" + events: list[tuple[ObservabilityLevel, str, Mapping[str, Any]]] = [] + observability = _recording_observability( + events, + trace_context={"traceparent": traceparent}, + ) + client = HTTPClient(requesting=capturing) + async with ctx.scope("test", observability=observability): + await client.get(url="/resource", trace_propagation=True) # asks for it + await client.get(url="/resource", trace_propagation=False) # opts out + await client.get(url="/resource") # propagation is not the default + + assert captured == [{"traceparent": traceparent}, None, None] + + +@mark.asyncio +async def test_http_client_propagates_trace_context_from_every_method() -> None: + captured: list[HTTPHeaders | None] = [] + + async def capturing(method: str, /, **kwargs: Any) -> HTTPResponse: + captured.append(kwargs["headers"]) + return HTTPResponse(status_code=200, headers={}, body=b"ok") + + traceparent: str = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" + events: list[tuple[ObservabilityLevel, str, Mapping[str, Any]]] = [] + observability = _recording_observability( + events, + trace_context={"traceparent": traceparent}, + ) + client = HTTPClient(requesting=capturing) + async with ctx.scope("test", observability=observability): + await client.get(url="/resource", trace_propagation=True) + await client.post(url="/resource", body=b"payload", trace_propagation=True) + await client.put(url="/resource", body=b"payload", trace_propagation=True) + await client.request("DELETE", url="/resource", trace_propagation=True) + + assert captured == [{"traceparent": traceparent}] * 4 + + @mark.asyncio async def test_http_client_propagation_tolerates_a_backend_without_trace_context() -> None: captured: list[HTTPHeaders | None] = [] @@ -762,57 +816,6 @@ async def failing(method: str, /, **kwargs: Any) -> HTTPResponse: assert "leaked" not in str(exc_info.value) -@mark.asyncio -async def test_http_client_closes_streamed_body_when_the_request_fails() -> None: - closed: list[str] = [] - - async def tracked_body() -> AsyncGenerator[bytes]: - try: - yield b"chunk" - - finally: - closed.append("body") - - async def failing_request(method: str, /, **kwargs: Any) -> HTTPResponse: - raise HTTPConnectionError(message="boom", method=method, url="/upload") - - client = HTTPClient(requesting=failing_request) - body = tracked_body() - await anext(body) # started, so it holds live state to release - - with raises(HTTPConnectionError): - await client.post(url="/upload", body=body) - - # a backend releases a streamed payload only once it began reading it, so a - # request failing before that would otherwise leave the caller's body open - assert closed == ["body"] - - -@mark.asyncio -async def test_http_client_closes_streamed_body_left_unread() -> None: - closed: list[str] = [] - - async def tracked_body() -> AsyncGenerator[bytes]: - try: - yield b"first" - yield b"second" - - finally: - closed.append("body") - - async def partial_request(method: str, /, **kwargs: Any) -> HTTPResponse: - # answers after one chunk, as a server rejecting an upload early would - await anext(kwargs["body"]) - return HTTPResponse(status_code=413, headers={}, body=b"too large") - - client = HTTPClient(requesting=partial_request) - - response = await client.post(url="/upload", body=tracked_body()) - - assert response.status_code == 413 - assert closed == ["body"] - - @mark.asyncio async def test_http_response_stream_body_of_an_empty_payload_yields_no_chunks() -> None: buffered = HTTPResponse(status_code=204, headers={}, body=b"") @@ -831,41 +834,6 @@ async def empty() -> AsyncGenerator[bytes]: assert [chunk async for chunk in streamed.stream_body()] == [] -@mark.asyncio -async def test_http_client_releases_a_response_when_closing_the_body_is_cancelled() -> None: - # a generator never read has no frame to run on close, so the release has to - # be observed on the body itself - response_body = _CloseTrackingBody([b"payload"]) - - async def cancelling_body() -> AsyncGenerator[bytes]: - try: - yield b"first" - yield b"second" - - finally: - # the shape an outer timeout produces: cancellation landing on the - # release of the payload, after the response was already obtained - raise asyncio.CancelledError - - async def partial_request(method: str, /, **kwargs: Any) -> HTTPResponse: - await anext(kwargs["body"]) # answers early, leaving the payload open - return HTTPResponse( - status_code=200, - headers={}, - body=cast(AsyncGenerator[bytes], response_body), - ) - - client = HTTPClient(requesting=partial_request) - - with raises(asyncio.CancelledError): - await client.post(url="/upload", body=cancelling_body(), stream=True) - - # cancellation cannot be swallowed the way a failure closing the payload - # can, and it takes the response with it - so the response has to be - # released here rather than holding its connection until the pool closes - assert response_body.closed is True - - @mark.asyncio async def test_http_response_releasing_a_buffered_body_leaves_it_readable() -> None: response = HTTPResponse(status_code=200, headers={}, body=b"buffered") @@ -877,66 +845,3 @@ async def test_http_response_releasing_a_buffered_body_leaves_it_readable() -> N assert await response.body() == b"buffered" assert [chunk async for chunk in response.stream_body()] == [b"buffered"] - - -@mark.asyncio -async def test_http_client_closes_a_streamed_body_when_cancelled_in_flight() -> None: - closed: list[str] = [] - - async def tracked_body() -> AsyncGenerator[bytes]: - try: - yield b"first" - yield b"second" - - finally: - closed.append("body") - - async def hanging_request(method: str, /, **kwargs: Any) -> HTTPResponse: - await anext(kwargs["body"]) # started, so it holds live state to release - await asyncio.Event().wait() - raise AssertionError("unreachable") # pragma: no cover - - client = HTTPClient(requesting=hanging_request) - task = asyncio.ensure_future(client.post(url="/upload", body=tracked_body())) - for _ in range(5): - await asyncio.sleep(0) - - task.cancel() - with raises(asyncio.CancelledError): - await task - - # cancellation is routine control flow, not a failure - the payload is - # still released, and no response existed to protect - assert closed == ["body"] - - -@mark.asyncio -async def test_http_client_releases_a_body_whose_cleanup_awaits_under_cancellation() -> None: - closed: list[str] = [] - - async def tracked_body() -> AsyncGenerator[bytes]: - try: - yield b"first" - yield b"second" - - finally: - await asyncio.sleep(0) # cleanup which yields to the loop - closed.append("body") - - async def hanging_request(method: str, /, **kwargs: Any) -> HTTPResponse: - await anext(kwargs["body"]) - await asyncio.Event().wait() - raise AssertionError("unreachable") # pragma: no cover - - client = HTTPClient(requesting=hanging_request) - task = asyncio.ensure_future(client.post(url="/upload", body=tracked_body())) - for _ in range(5): - await asyncio.sleep(0) - - task.cancel() - with raises(asyncio.CancelledError): - await task - - # a pending cancellation must not cut the release short - a payload whose - # cleanup has to await would otherwise stay open - assert closed == ["body"] diff --git a/tests/test_opentelemetry.py b/tests/test_opentelemetry.py index b554b2a1..4f0ccddd 100644 --- a/tests/test_opentelemetry.py +++ b/tests/test_opentelemetry.py @@ -910,6 +910,27 @@ def test_configure_after_shutdown_is_rejected(monkeypatch: MonkeyPatch) -> None: OpenTelemetry.configure(service="restarting", version="1", environment="test") +def test_configured_reports_whether_observability_can_be_prepared( + monkeypatch: MonkeyPatch, +) -> None: + monkeypatch.setattr(OpenTelemetry, "_tracer_provider", None) + monkeypatch.setattr(OpenTelemetry, "_meter_provider", None) + monkeypatch.setattr(OpenTelemetry, "_logger_provider", None) + monkeypatch.setattr(OpenTelemetry, "_logger", None) + + assert OpenTelemetry.configured() is False + + OpenTelemetry.autoconfigure(service="checking") + + # what a resource entered on each application startup guards its own + # configuration with - the provider slots are claimed once per process + assert OpenTelemetry.configured() is True + + OpenTelemetry.shutdown() + + assert OpenTelemetry.configured() is False + + def test_configure_twice_is_rejected(monkeypatch: MonkeyPatch) -> None: monkeypatch.setattr(OpenTelemetry, "_meter_provider", MeterProvider(shutdown_on_exit=False)) diff --git a/tests/test_optional_extras_guard.py b/tests/test_optional_extras_guard.py index 90d6c40e..ac3d850f 100644 --- a/tests/test_optional_extras_guard.py +++ b/tests/test_optional_extras_guard.py @@ -8,10 +8,12 @@ @pytest.mark.parametrize( ("haiway_module", "dependency", "extra_name"), [ + ("haiway.fastapi", "fastapi", "fastapi"), ("haiway.httpx", "httpx2", "httpx"), ("haiway.opentelemetry", "opentelemetry", "opentelemetry"), ("haiway.postgres", "asyncpg", "postgres"), ("haiway.rabbitmq", "pika", "rabbitmq"), + ("haiway.starlette", "starlette", "starlette"), ], ) def test_optional_extra_guard_message( diff --git a/tests/test_starlette.py b/tests/test_starlette.py new file mode 100644 index 00000000..5c838443 --- /dev/null +++ b/tests/test_starlette.py @@ -0,0 +1,1242 @@ +from asyncio import CancelledError, Event, Queue, Task, create_task +from collections.abc import AsyncGenerator, Iterable, Mapping, MutableSequence +from contextlib import asynccontextmanager +from logging import Logger, getLogger +from typing import Any +from uuid import UUID + +import pytest + +pytest.importorskip("starlette") + +from pytest import MonkeyPatch, mark, raises +from starlette.applications import Starlette +from starlette.exceptions import HTTPException, WebSocketException +from starlette.middleware import Middleware +from starlette.requests import ClientDisconnect, Request +from starlette.responses import PlainTextResponse, Response, StreamingResponse +from starlette.routing import Route, WebSocketRoute +from starlette.types import ASGIApp, Message, Receive, Scope, Send +from starlette.websockets import WebSocket + +from haiway import ContextMissing, ContextPresets, LoggerObservability, Observability, State, ctx +from haiway.starlette import ( + ContextMiddleware, + ServerContext, + application, + request_trace_context, +) + +# where the backend recording a request is built out of a `Logger` - patched to +# count how many of them a run of an application builds +from haiway.starlette import context as server_context_module +from tests.asgi import ( + TRACE_ID_HEADER, + LogCapture, + Result, + http_scope, + receive_request, + running, + send_request, + websocket_scope, +) + +_TRACE_ID: UUID = UUID("0af7651916cd43dd8448eb211c80319c") + + +class ExampleState(State): + value: str = "example" + + +class DisposableState(State): + value: str = "disposable" + + +class PresetState(State): + value: str = "preset" + + +class ExampleDisposable: + def __init__( + self, + log: MutableSequence[str], + /, + ) -> None: + self.log: MutableSequence[str] = log + + async def __aenter__(self) -> Iterable[State]: + self.log.append("enter") + return (DisposableState(),) + + async def __aexit__( + self, + exc_type: Any, + exc_val: Any, + exc_tb: Any, + ) -> None: + self.log.append("exit") + + +@mark.asyncio +async def test_state_is_available_within_endpoint() -> None: + async def endpoint(request: Request) -> Response: + return PlainTextResponse( + f"{ctx.state(ExampleState).value}/{ctx.state(DisposableState).value}" + ) + + log: MutableSequence[str] = [] + app: Starlette = application( + ServerContext( + ExampleState(), + disposables=(ExampleDisposable(log),), + ), + routes=[Route("/example", endpoint)], + ) + + async with running(app): + assert log == ["enter"] + result: Result = await send_request(app) + + assert result.status == 200 + assert result.body == b"example/disposable" + assert log == ["enter", "exit"] + + +@mark.asyncio +async def test_declared_state_takes_precedence_over_disposables() -> None: + class Conflicting: + async def __aenter__(self) -> Iterable[State]: + return (ExampleState(value="disposable"),) + + async def __aexit__( + self, + exc_type: Any, + exc_val: Any, + exc_tb: Any, + ) -> None: + pass + + async def endpoint(request: Request) -> Response: + return PlainTextResponse(ctx.state(ExampleState).value) + + app: Starlette = application( + ServerContext( + ExampleState(value="declared"), + disposables=(Conflicting(),), + ), + routes=[Route("/example", endpoint)], + ) + + async with running(app): + result: Result = await send_request(app) + + assert result.body == b"declared" + + +@mark.asyncio +async def test_response_carries_trace_headers() -> None: + async def endpoint(request: Request) -> Response: + return PlainTextResponse(ctx.trace_id()) + + app: Starlette = application(routes=[Route("/example", endpoint)]) + + async with running(app): + result: Result = await send_request(app) + + assert result.status == 200 + assert result.headers[TRACE_ID_HEADER] == result.body.decode() + + +@mark.asyncio +async def test_handled_exception_response_carries_trace_headers() -> None: + async def endpoint(request: Request) -> Response: + raise HTTPException(status_code=404, detail="missing") + + app: Starlette = application(routes=[Route("/example", endpoint)]) + + async with running(app): + result: Result = await send_request(app) + + assert result.status == 404 + assert result.body == b"missing" + assert TRACE_ID_HEADER in result.headers + + +@mark.asyncio +async def test_unhandled_exception_is_answered_by_the_framework() -> None: + async def endpoint(request: Request) -> Response: + raise ValueError("broken") + + app: Starlette = application(routes=[Route("/example", endpoint)]) + result = Result() + + async with running(app): + with raises(ValueError): # reraised for the server to report + await app(http_scope(), receive_request, result.collecting()) + + # answered by the server error handling of the framework, which sits above + # the middleware - so outside of the scope of the request, without its headers + assert result.status == 500 + assert result.body == b"Internal Server Error" + assert TRACE_ID_HEADER not in result.headers + + +@mark.asyncio +async def test_debug_application_keeps_its_error_response() -> None: + async def endpoint(request: Request) -> Response: + raise ValueError("broken") + + app: Starlette = application( + routes=[Route("/example", endpoint)], + debug=True, + ) + result = Result() + + async with running(app): + with raises(ValueError): + await app(http_scope(), receive_request, result.collecting()) + + assert result.status == 500 + assert b"ValueError" in result.body # the traceback rendered by Starlette + assert TRACE_ID_HEADER not in result.headers + + +@mark.asyncio +async def test_error_within_started_response_is_not_replaced() -> None: + async def endpoint(request: Request) -> Response: + async def streaming() -> AsyncGenerator[bytes]: + yield b"partial" + raise ValueError("broken") + + return StreamingResponse(streaming()) + + app: Starlette = application(routes=[Route("/example", endpoint)]) + result = Result() + + async with running(app): + with raises(ValueError): + await app(http_scope(), receive_request, result.collecting()) + + assert result.status == 200 + assert result.body == b"partial" + + +@mark.asyncio +async def test_request_without_lifespan_is_refused() -> None: + async def endpoint(request: Request) -> Response: + return PlainTextResponse("done") + + app: Starlette = application( + ServerContext(disposables=(ExampleDisposable([]),)), + routes=[Route("/example", endpoint)], + ) + result = Result() + + # the state of a request scope is what the lifespan prepares, so there is + # nothing to serve a request with before it ran + with raises(ContextMissing): + await app(http_scope(), receive_request, result.collecting()) + + +@mark.asyncio +async def test_lifespan_can_not_be_entered_twice() -> None: + log: MutableSequence[str] = [] + # the declared disposables are the instances prepared on startup, not a + # factory producing fresh ones, so a single context backs a single run + context = ServerContext(disposables=(ExampleDisposable(log),)) + + async with context.lifespan(): + pass + + assert log == ["enter", "exit"] + + with raises(AssertionError): + async with context.lifespan(): + pass + + assert log == ["enter", "exit"] + + +@mark.asyncio +async def test_failing_disposable_fails_startup() -> None: + attempts: MutableSequence[str] = [] + + class Failing: + async def __aenter__(self) -> Iterable[State]: + attempts.append("enter") + if len(attempts) == 1: + raise ValueError("broken") + + return () + + async def __aexit__( + self, + exc_type: Any, + exc_val: Any, + exc_tb: Any, + ) -> None: + pass + + context = ServerContext(disposables=(Failing(),)) + + with raises(ValueError): + async with context.lifespan(): + pass + + # a failed startup prepared no state, so the next one is not refused + async with context.lifespan(): + pass + + assert len(attempts) == 2 + + +@mark.asyncio +async def test_additional_lifespan_runs_within_prepared_context() -> None: + log: MutableSequence[str] = [] + + @asynccontextmanager + async def lifespan(app: Starlette) -> AsyncGenerator[None]: + log.append("startup") + try: + yield + + finally: + log.append("shutdown") + + app: Starlette = application( + ServerContext(disposables=(ExampleDisposable(log),)), + lifespan=lifespan, + ) + + async with running(app): + pass + + assert log == ["enter", "startup", "shutdown", "exit"] + + +@mark.asyncio +async def test_nested_middleware_runs_within_context() -> None: + class NestedMiddleware: + def __init__( + self, + app: ASGIApp, + /, + ) -> None: + self.app: ASGIApp = app + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + with ctx.updating(ExampleState(value="middleware")): + await self.app(scope, receive, send) + + async def endpoint(request: Request) -> Response: + return PlainTextResponse(ctx.state(ExampleState).value) + + app: Starlette = application( + ServerContext(ExampleState()), + routes=[Route("/example", endpoint)], + middleware=[Middleware(NestedMiddleware)], + ) + + async with running(app): + result: Result = await send_request(app) + + assert result.body == b"middleware" + + +@mark.asyncio +async def test_presets_are_available_within_endpoint() -> None: + async def endpoint(request: Request) -> Response: + async with ctx.scope("example-preset"): + return PlainTextResponse(ctx.state(PresetState).value) + + app: Starlette = application( + ServerContext( + presets=(ContextPresets.of("example-preset", PresetState(value="from-preset")),), + ), + routes=[Route("/example", endpoint)], + ) + + async with running(app): + result: Result = await send_request(app) + + assert result.body == b"from-preset" + + +@mark.asyncio +async def test_observability_preparing_receives_trace_context() -> None: + recorded: MutableSequence[tuple[str | None, str | None]] = [] + + def observability( + *, + traceparent: str | None, + tracestate: str | None, + ) -> None: + recorded.append((traceparent, tracestate)) + + async def endpoint(request: Request) -> Response: + return PlainTextResponse("done") + + app: Starlette = application( + ServerContext(observability=observability), + routes=[Route("/example", endpoint)], + ) + + traceparent: str = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + async with running(app): + await send_request( + app, + headers=( + (b"traceparent", f" {traceparent} ".encode()), + (b"tracestate", b"vendor=value"), + ), + ) + await send_request(app) # nothing to continue + + assert recorded == [(traceparent, "vendor=value"), (None, None)] + + +@mark.asyncio +async def test_conflicting_trace_context_is_discarded() -> None: + recorded: MutableSequence[tuple[str | None, str | None]] = [] + + def observability( + *, + traceparent: str | None, + tracestate: str | None, + ) -> None: + recorded.append((traceparent, tracestate)) + + async def endpoint(request: Request) -> Response: + return PlainTextResponse("done") + + app: Starlette = application( + ServerContext(observability=observability), + routes=[Route("/example", endpoint)], + ) + + async with running(app): + await send_request( + app, + headers=( + (b"traceparent", b"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"), + (b"traceparent", b"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"), + (b"tracestate", b"vendor=value"), + ), + ) + + assert recorded == [(None, None)] + + +@mark.asyncio +async def test_websocket_is_handled_within_context() -> None: + async def endpoint(websocket: WebSocket) -> None: + await websocket.accept() + await websocket.send_text(ctx.state(ExampleState).value) + await websocket.close() + + app: Starlette = application( + ServerContext(ExampleState(value="websocket")), + routes=[WebSocketRoute("/example", endpoint)], + ) + + incoming: Queue[Message] = Queue() + outgoing: MutableSequence[Message] = [] + + async def send(message: Message) -> None: + outgoing.append(message) + + await incoming.put({"type": "websocket.connect"}) + async with running(app): + await app( + websocket_scope(), + incoming.get, + send, + ) + + assert [message["type"] for message in outgoing] == [ + "websocket.accept", + "websocket.send", + "websocket.close", + ] + assert outgoing[1]["text"] == "websocket" + + +@mark.asyncio +async def test_denied_websocket_handshake_carries_trace_headers() -> None: + class Rejected(Exception): + pass + + async def socket(websocket: WebSocket) -> None: + raise Rejected # before the connection was accepted + + async def rejected( + websocket: WebSocket, + exception: Exception, + ) -> Response: + return PlainTextResponse("rejected", status_code=403) + + app: Starlette = application( + ServerContext(), + routes=[WebSocketRoute("/socket", socket)], + exception_handlers={Rejected: rejected}, + ) + + incoming: Queue[Message] = Queue() + await incoming.put({"type": "websocket.connect"}) + outgoing: MutableSequence[Message] = [] + + async def send(message: Message) -> None: + outgoing.append(message) + + async with running(app): + await app(websocket_scope(path="/socket"), incoming.get, send) + + # the denial of a handshake is the one response a websocket connection + # carries - sent renamed under the websocket prefix, yet a response + assert outgoing[0]["type"] == "websocket.http.response.start" + assert outgoing[0]["status"] == 403 + headers: Mapping[str, str] = { + name.decode(): value.decode() for name, value in outgoing[0]["headers"] + } + assert headers[TRACE_ID_HEADER] + + +@mark.asyncio +async def test_failing_websocket_error_is_reraised() -> None: + async def endpoint(websocket: WebSocket) -> None: + await websocket.accept() + raise ValueError("broken") + + app: Starlette = application(routes=[WebSocketRoute("/example", endpoint)]) + + incoming: Queue[Message] = Queue() + + async def send(message: Message) -> None: + pass + + await incoming.put({"type": "websocket.connect"}) + async with running(app): + with raises(ValueError): + await app( + { + "type": "websocket", + "asgi": {"version": "3.0", "spec_version": "2.4"}, + "path": "/example", + "raw_path": b"/example", + "query_string": b"", + "root_path": "", + "scheme": "ws", + "headers": [], + "client": ("127.0.0.1", 54321), + "server": ("testserver", 80), + "subprotocols": [], + "state": {}, + }, + incoming.get, + send, + ) + + +@mark.asyncio +async def test_middleware_installed_by_hand_provides_scopes() -> None: + async def endpoint(request: Request) -> Response: + return PlainTextResponse(ctx.trace_id()) + + # what plugging an existing application in looks like - the two pieces + # installed separately rather than through the factory + context = ServerContext() + app: Starlette = Starlette( + routes=[Route("/example", endpoint)], + lifespan=context.lifespan, + middleware=[Middleware(ContextMiddleware, context=context)], + ) + + async with running(app): + result: Result = await send_request(app) + + assert result.status == 200 + assert result.headers[TRACE_ID_HEADER] == result.body.decode() + + +def test_request_trace_context_reads_headers() -> None: + traceparent: str = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + assert request_trace_context( + http_scope( + headers=( + (b"traceparent", f" {traceparent} ".encode()), + (b"tracestate", b"vendor=value"), + ) + ) + ) == {"traceparent": traceparent, "tracestate": "vendor=value"} + + +def test_request_trace_context_ignores_incomplete_headers() -> None: + assert request_trace_context(http_scope(headers=((b"tracestate", b"vendor=value"),))) == {} + assert request_trace_context(http_scope(headers=((b"traceparent", b" "),))) == {} + assert request_trace_context(http_scope()) == {} + assert request_trace_context({"type": "lifespan"}) == {} + + +def test_request_trace_context_discards_conflicting_traceparents() -> None: + # no single position to continue - the trace has to be restarted instead + assert ( + request_trace_context( + http_scope( + headers=( + (b"traceparent", b"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"), + (b"traceparent", b"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"), + (b"tracestate", b"vendor=value"), + ) + ) + ) + == {} + ) + + +def test_request_trace_context_reads_the_first_tracestate() -> None: + # a caller splitting a long trace state across several headers has to join + # them itself - only the first one is read + traceparent: str = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + assert request_trace_context( + http_scope( + headers=( + (b"traceparent", traceparent.encode()), + (b"tracestate", b" vendor=value "), + (b"tracestate", b"other=state"), + ) + ) + ) == {"traceparent": traceparent, "tracestate": "vendor=value"} + + +def test_request_trace_context_passes_malformed_traceparent_on() -> None: + # validation belongs to the observability backend, which the specification + # requires to reject a malformed value and start its own trace + assert request_trace_context(http_scope(headers=((b"traceparent", b"broken"),))) == { + "traceparent": "broken" + } + + +@mark.asyncio +async def test_registered_server_error_handler_answers_the_request() -> None: + async def endpoint(request: Request) -> Response: + raise ValueError("broken") + + async def handle_server_error( + request: Request, + exception: Exception, + ) -> Response: + return PlainTextResponse("handled", status_code=503) + + app: Starlette = application( + routes=[Route("/example", endpoint)], + # a server error handler answers above the middleware, in place of the + # plain `500` the framework would produce + exception_handlers={Exception: handle_server_error}, + ) + result = Result() + + async with running(app): + with raises(ValueError): # reraised for the server to report + await app(http_scope(), receive_request, result.collecting()) + + assert result.status == 503 + assert result.body == b"handled" + # the handler runs above every middleware, so outside of the request scope + assert TRACE_ID_HEADER not in result.headers + + +@mark.asyncio +async def test_registered_status_error_handler_answers_the_request() -> None: + async def endpoint(request: Request) -> Response: + raise ValueError("broken") + + async def handle_server_error( + request: Request, + exception: Exception, + ) -> Response: + return PlainTextResponse("handled", status_code=503) + + app: Starlette = application( + routes=[Route("/example", endpoint)], + # `500` resolves the same handler slot as `Exception` does + exception_handlers={500: handle_server_error}, + ) + result = Result() + + async with running(app): + with raises(ValueError): + await app(http_scope(), receive_request, result.collecting()) + + assert result.status == 503 + assert result.body == b"handled" + + +@mark.asyncio +async def test_handler_of_another_exception_keeps_the_error_response() -> None: + class ExampleError(Exception): + pass + + async def endpoint(request: Request) -> Response: + raise ValueError("broken") + + async def handle_example_error( + request: Request, + exception: Exception, + ) -> Response: + return PlainTextResponse("handled", status_code=418) + + app: Starlette = application( + routes=[Route("/example", endpoint)], + # not a server error handler - the plain `500` of the framework is what + # answers the failure of the request + exception_handlers={ExampleError: handle_example_error}, + ) + result = Result() + + async with running(app): + with raises(ValueError): + await app(http_scope(), receive_request, result.collecting()) + + assert result.status == 500 + assert result.body == b"Internal Server Error" + assert TRACE_ID_HEADER not in result.headers + + +@mark.asyncio +async def test_http_exception_is_not_recorded_as_a_failure() -> None: + recorded: MutableSequence[str | None] = [] + + class Records: + def observability(self) -> Observability: + def scope_exiting( + scope: Any, + /, + *, + exception: BaseException | None, + ) -> None: + recorded.append(None if exception is None else type(exception).__name__) + + return Observability( + trace_identifying=lambda scope, /: _TRACE_ID, + log_recording=lambda scope, /, level, message, *args, exception: None, + metric_recording=lambda scope, /, level, **kwargs: None, + event_recording=lambda scope, /, level, **kwargs: None, + attributes_recording=lambda scope, /, level, attributes: None, + scope_entering=lambda scope, /: _TRACE_ID.hex, + scope_exiting=scope_exiting, + trace_context_encoding=lambda scope, /: {}, + ) + + async def raising( + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + raise HTTPException(status_code=409, detail="conflict") + + context = ServerContext(observability=Records().observability()) + app: ASGIApp = ContextMiddleware(raising, context=context) + result = Result() + + async with context.lifespan(): + with raises(HTTPException): + await app(http_scope(), receive_request, result.collecting()) + + # an intended response is not the failure of the request asking for it + assert recorded == [None] + + +@mark.asyncio +async def test_client_disconnect_is_not_recorded_as_a_failure() -> None: + recorded: MutableSequence[str | None] = [] + + def observability() -> Observability: + def scope_exiting( + scope: Any, + /, + *, + exception: BaseException | None, + ) -> None: + recorded.append(None if exception is None else type(exception).__name__) + + return Observability( + trace_identifying=lambda scope, /: _TRACE_ID, + log_recording=lambda scope, /, level, message, *args, exception: None, + metric_recording=lambda scope, /, level, **kwargs: None, + event_recording=lambda scope, /, level, **kwargs: None, + attributes_recording=lambda scope, /, level, attributes: None, + scope_entering=lambda scope, /: _TRACE_ID.hex, + scope_exiting=scope_exiting, + trace_context_encoding=lambda scope, /: {}, + ) + + async def endpoint(request: Request) -> Response: + await request.body() # the consumer went away instead of sending one + return PlainTextResponse("unreachable") + + app: Starlette = application( + ServerContext(observability=observability()), + routes=[Route("/example", endpoint, methods=["POST"])], + ) + result = Result() + + async def receive() -> Message: + return {"type": "http.disconnect"} + + async with running(app): + with raises(ClientDisconnect): + await app(http_scope(method="POST"), receive, result.collecting()) + + # a consumer which went away is not a failure of the request it abandoned + assert recorded == [None] + # nothing is answered here either - what reaches the connection which is + # already gone is the response of the outer error handling of the framework + assert TRACE_ID_HEADER not in result.headers + + +@mark.asyncio +async def test_http_exception_is_left_unanswered_without_handlers() -> None: + async def raising( + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + raise HTTPException(status_code=409, detail="conflict") + + context = ServerContext() + app: ASGIApp = ContextMiddleware(raising, context=context) + result = Result() + + async with context.lifespan(): + # an intended response, left to whatever handles it - not turned into a 500 + with raises(HTTPException): + await app(http_scope(), receive_request, result.collecting()) + + assert result.status == 0 + + +@mark.asyncio +async def test_websocket_exception_is_left_unanswered_without_handlers() -> None: + async def raising( + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + raise WebSocketException(code=1008, reason="rejected") + + context = ServerContext() + app: ASGIApp = ContextMiddleware(raising, context=context) + sent: MutableSequence[Message] = [] + + async def receive() -> Message: + return {"type": "websocket.connect"} + + async def send(message: Message) -> None: + sent.append(message) + + async with context.lifespan(): + with raises(WebSocketException): + await app( + websocket_scope(), + receive, + send, + ) + + assert sent == [] + + +@mark.asyncio +async def test_provided_logger_records_request_scopes() -> None: + logger: Logger = getLogger("test-application") + + async def endpoint(request: Request) -> Response: + ctx.log_info("recorded") + return PlainTextResponse("done") + + app: Starlette = application( + ServerContext(observability=logger), + routes=[Route("/example", endpoint)], + ) + + with LogCapture(logger) as records: + async with running(app): + assert (await send_request(app)).status == 200 + + assert any("recorded" in record for record in records) + assert any("Entering scope: GET" in record for record in records) + + +@mark.asyncio +async def test_provided_logger_gets_a_backend_per_request( + monkeypatch: MonkeyPatch, +) -> None: + logger: Logger = getLogger("test-backend-per-request") + prepared: MutableSequence[Logger | None] = [] + + def counted( + logger: Logger | None = None, + /, + *, + debug_context: bool = False, + ) -> Observability: + prepared.append(logger) + return LoggerObservability(logger, debug_context=debug_context) + + # the backend recording a request is built out of the logger here + monkeypatch.setattr(server_context_module, "LoggerObservability", counted) + + async def endpoint(request: Request) -> Response: + ctx.log_info("recorded") + return PlainTextResponse("done") + + context = ServerContext(observability=logger) + # a backend built out of the logger for the request being handled, rather than + # the logger itself or a single backend wrapping it when the context is declared + first: Observability | Logger = context.request_observability(http_scope()) + second: Observability | Logger = context.request_observability(http_scope()) + assert first is not logger + assert second is not first + prepared.clear() + + app: Starlette = application( + context, + routes=[Route("/example", endpoint)], + ) + + with LogCapture(logger) as records: + async with running(app): + for _ in range(4): + assert (await send_request(app)).status == 200 + + # so each request records into a backend of its own, which is released along + # with it - one backend shared by the application would instead retain the + # scopes of every request which never completed, an abandoned generator + # among them, for as long as it runs + assert prepared == [logger, logger, logger, logger] + assert len([record for record in records if "recorded" in record]) == 4 + + +@mark.asyncio +async def test_prepared_logger_records_request_scopes() -> None: + logger: Logger = getLogger("test-prepared-application") + + async def endpoint(request: Request) -> Response: + ctx.log_info("recorded") + return PlainTextResponse("done") + + app: Starlette = application( + ServerContext(observability=lambda **_: LoggerObservability(logger)), + routes=[Route("/example", endpoint)], + ) + + with LogCapture(logger) as records: + async with running(app): + assert (await send_request(app)).status == 200 + + assert any("recorded" in record for record in records) + assert any("Entering scope: GET" in record for record in records) + + +@mark.asyncio +async def test_cancelled_lifespan_is_not_a_failure() -> None: + log: MutableSequence[str] = [] + context = ServerContext(disposables=(ExampleDisposable(log),)) + + with raises(CancelledError): + async with context.lifespan(): + raise CancelledError + + assert log == ["enter", "exit"] + + +@mark.asyncio +async def test_trace_headers_are_added_to_headerless_response() -> None: + async def responding( + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + await send({"type": "http.response.start", "status": 201}) + await send({"type": "http.response.body", "body": b"raw"}) + + context = ServerContext() + app: ASGIApp = ContextMiddleware(responding, context=context) + result = Result() + + async with context.lifespan(): + await app(http_scope(), receive_request, result.collecting()) + + assert result.status == 201 + assert result.body == b"raw" + assert TRACE_ID_HEADER in result.headers + + +@mark.asyncio +async def test_trace_context_headers_are_matched_case_insensitively() -> None: + recorded: MutableSequence[tuple[str | None, str | None]] = [] + + def observability( + *, + traceparent: str | None, + tracestate: str | None, + ) -> None: + recorded.append((traceparent, tracestate)) + + async def endpoint(request: Request) -> Response: + return PlainTextResponse("done") + + app: Starlette = application( + ServerContext(observability=observability), + routes=[Route("/example", endpoint)], + ) + + traceparent: str = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + async with running(app): + # a server which does not lowercase header names, as ASGI requires it to, + # must not silently cost the trace of the caller + await send_request( + app, + headers=( + (b"TraceParent", traceparent.encode()), + (b"TraceState", b"vendor=value"), + ), + ) + + assert recorded == [(traceparent, "vendor=value")] + + +@mark.asyncio +async def test_request_during_startup_is_refused() -> None: + preparing: Event = Event() + holding: Event = Event() + + class Slow: + async def __aenter__(self) -> Iterable[State]: + preparing.set() + await holding.wait() # startup is still running + return () + + async def __aexit__( + self, + exc_type: Any, + exc_val: Any, + exc_tb: Any, + ) -> None: + pass + + async def endpoint(request: Request) -> Response: + return PlainTextResponse("done") + + context = ServerContext(disposables=(Slow(),)) + app: Starlette = application(context, routes=[Route("/example", endpoint)]) + startup: Task[None] = create_task(_entering(context)) + await preparing.wait() + + result = Result() + # requests are not served with state which is not prepared yet - a server + # holds them until startup completed, reaching one here fails naming what + # is missing + with raises(ContextMissing): + await app(http_scope(), receive_request, result.collecting()) + + holding.set() + await startup + + +async def _entering( + context: ServerContext, + /, +) -> None: + async with context.lifespan(): + pass + + +@mark.asyncio +async def test_default_observability_survives_reconfigured_logging() -> None: + async def endpoint(request: Request) -> Response: + ctx.log_info("recorded") + return PlainTextResponse("done") + + app: Starlette = application(routes=[Route("/example", endpoint)]) + # `setup_logging` disables every logger predating it, so a default of our own + # making would drop request records without a trace of having done so + silenced: Logger = getLogger("haiway.starlette") + silenced.disabled = True + + try: + with LogCapture(getLogger()) as records: + async with running(app): + assert (await send_request(app)).status == 200 + + finally: + silenced.disabled = False + + assert any("recorded" in record for record in records) + + +@mark.asyncio +async def test_conditionally_provided_elements_are_ignored() -> None: + async def endpoint(request: Request) -> Response: + return PlainTextResponse(ctx.state(ExampleState).value) + + log: MutableSequence[str] = [] + app: Starlette = application( + # `None` keeps a conditionally provided element from requiring a branch + ServerContext( + ExampleState(value="declared"), + None, + disposables=(ExampleDisposable(log), None), + ), + routes=[Route("/example", endpoint)], + ) + + async with running(app): + result: Result = await send_request(app) + + assert result.body == b"declared" + assert log == ["enter", "exit"] + + +@mark.asyncio +async def test_composed_lifespan_prepares_the_context_first() -> None: + log: MutableSequence[str] = [] + + async def endpoint(request: Request) -> Response: + return PlainTextResponse("done") + + @asynccontextmanager + async def existing(app: Starlette) -> AsyncGenerator[None]: + log.append("startup") + try: + yield + + finally: + log.append("shutdown") + + context = ServerContext(disposables=(ExampleDisposable(log),)) + # what an application which already has a lifespan of its own installs + app: Starlette = Starlette( + routes=[Route("/example", endpoint)], + lifespan=context.composed_lifespan(existing), + middleware=[Middleware(ContextMiddleware, context=context)], + ) + + async with running(app): + assert (await send_request(app)).status == 200 + + # the disposables of the context are prepared around the additional lifespan + assert log == ["enter", "startup", "shutdown", "exit"] + + +@mark.asyncio +async def test_startup_runs_outside_of_a_context_scope() -> None: + @asynccontextmanager + async def existing(app: Starlette) -> AsyncGenerator[None]: + # the disposables are prepared, but nothing entered a scope with their + # state - startup work which needs one enters it, and leaves it, itself + with raises(ContextMissing): + ctx.state(DisposableState) + + yield + + context = ServerContext(disposables=(ExampleDisposable([]),)) + app: Starlette = application(context, lifespan=existing) + + async with running(app): + pass + + +@mark.asyncio +async def test_scopes_are_named_after_their_request() -> None: + logger: Logger = getLogger("test-scope-naming") + + async def endpoint(request: Request) -> Response: + return PlainTextResponse("done") + + async def socket(websocket: WebSocket) -> None: + await websocket.accept() + await websocket.close() + + app: Starlette = application( + ServerContext(observability=logger), + routes=[ + Route("/example", endpoint, methods=("POST",)), + Route("/users/{identifier}", endpoint), + WebSocketRoute("/socket", socket), + ], + ) + + incoming: Queue[Message] = Queue() + await incoming.put({"type": "websocket.connect"}) + + async def send(message: Message) -> None: + pass + + with LogCapture(logger) as records: + async with running(app): + await send_request(app, method="POST") + await send_request(app, path="/users/12345") + await app(websocket_scope(path="/socket"), incoming.get, send) + + # the method and the requested path - the middleware runs before routing, so + # the template behind that path is recorded as an attribute instead + assert any("Entering scope: POST /example" in record for record in records) + assert any("Entering scope: GET /users/12345" in record for record in records) + # a websocket connection carries no method, so the name says what it is + assert any("Entering scope: WS /socket" in record for record in records) + + +@mark.asyncio +async def test_requests_are_recorded_as_the_conventions_describe_them() -> None: + logger: Logger = getLogger("test-request-attributes") + + async def endpoint(request: Request) -> Response: + return PlainTextResponse("done", status_code=201) + + app: Starlette = application( + ServerContext(observability=logger), + routes=[Route("/users/{identifier}", endpoint)], + ) + + with LogCapture(logger) as records: + async with running(app): + assert (await send_request(app, path="/users/12345")).status == 201 + + recorded: str = "\n".join(record for record in records if "Attributes:" in record) + # the path the scope name can not carry is recorded instead, as the attributes + # the HTTP semantic conventions of OpenTelemetry define for it + assert '"url.path"]: "/users/12345"' in recorded + assert '"http.request.method"]: "GET"' in recorded + assert '"http.response.status_code"]: 201' in recorded + assert '"url.scheme"]: "http"' in recorded + # the routing of Starlette leaves no route in the scope, so there is no route + # template to report - a FastAPI application is where one is available + assert "http.route" not in recorded + + +@mark.asyncio +async def test_unknown_request_method_is_reported_as_received() -> None: + logger: Logger = getLogger("test-unknown-method") + + async def endpoint(request: Request) -> Response: + return PlainTextResponse("done") + + app: Starlette = application( + ServerContext(observability=logger), + routes=[Route("/example", endpoint, methods=("BREW",))], + ) + + with LogCapture(logger) as records: + async with running(app): + await send_request(app, method="BREW") + + # a method the conventions do not know is neither replaced nor dropped - what + # the request carried is what names its scope and what is recorded for it + assert any("Entering scope: BREW /example" in record for record in records) + recorded: str = "\n".join(record for record in records if "Attributes:" in record) + assert '"http.request.method"]: "BREW"' in recorded diff --git a/tests/test_starlette_opentelemetry.py b/tests/test_starlette_opentelemetry.py new file mode 100644 index 00000000..303860dc --- /dev/null +++ b/tests/test_starlette_opentelemetry.py @@ -0,0 +1,436 @@ +from asyncio import Queue, gather +from collections.abc import AsyncGenerator, Iterator, MutableSequence, Sequence + +import pytest + +pytest.importorskip("starlette") +pytest.importorskip("opentelemetry") +pytest.importorskip("httpx2") + +from httpx2 import MockTransport +from httpx2 import Request as HTTPXRequest +from httpx2 import Response as HTTPXResponse +from opentelemetry import trace +from opentelemetry.sdk.trace import ReadableSpan, TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.sdk.trace.sampling import ALWAYS_ON +from opentelemetry.trace import Tracer +from pytest import MonkeyPatch, fixture, mark, raises +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import PlainTextResponse, Response +from starlette.routing import Route, WebSocketRoute +from starlette.types import Message +from starlette.websockets import WebSocket + +from haiway import HTTPClient, ctx +from haiway.httpx import HTTPXClient +from haiway.opentelemetry import OpenTelemetry, OpenTelemetryException +from haiway.starlette import ( + ServerContext, + StreamResponse, + application, +) +from tests.asgi import ( + TRACE_ID_HEADER, + Result, + http_scope, + receive_request, + running, + send_request, + websocket_scope, +) + +_CONFIGURATION_ATTRIBUTES: Sequence[str] = ( + "service", + "version", + "environment", + "_logger", + "_logger_provider", + "_meter_provider", + "_tracer_provider", +) + +REMOTE_TRACE_ID: str = "4bf92f3577b34da6a3ce929d0e0e4736" +REMOTE_SPAN_ID: str = "00f067aa0ba902b7" +REMOTE_TRACEPARENT: str = f"00-{REMOTE_TRACE_ID}-{REMOTE_SPAN_ID}-01" +# every application here serves `/example`, which is what names its request scope +REQUEST_SPAN_NAME: str = "GET /example" + + +@fixture(autouse=True) +def isolated_configuration() -> Iterator[None]: + """Keep `OpenTelemetry` process wide configuration from leaking between tests.""" + snapshot = {name: getattr(OpenTelemetry, name) for name in _CONFIGURATION_ATTRIBUTES} + yield + for name, value in snapshot.items(): + setattr(OpenTelemetry, name, value) + + +@fixture +def spans(monkeypatch: MonkeyPatch) -> Iterator[InMemorySpanExporter]: + """Install an in-memory tracer provider for the duration of one test.""" + monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False) + monkeypatch.delenv("OTEL_TRACES_SAMPLER", raising=False) + monkeypatch.delenv("OTEL_TRACES_SAMPLER_ARG", raising=False) + + exporter = InMemorySpanExporter() + provider = TracerProvider(sampler=ALWAYS_ON, shutdown_on_exit=False) + provider.add_span_processor(SimpleSpanProcessor(exporter)) + + def get_tracer_provider() -> TracerProvider: + return provider + + def get_tracer(*args: object, **kwargs: object) -> Tracer: + return provider.get_tracer("test") + + monkeypatch.setattr(trace, "get_tracer_provider", get_tracer_provider) + monkeypatch.setattr(trace, "get_tracer", get_tracer) + + OpenTelemetry.autoconfigure( + service="test-service", + version="1.2.3", + environment="test", + ) + + yield exporter + + exporter.clear() + + +def _request_span( + spans: InMemorySpanExporter, + /, +) -> ReadableSpan: + matching: Sequence[ReadableSpan] = [ + # the method and the requested path, which is what the scope of a request + # is named after - the middleware runs before routing, so the template + # behind that path is carried by `http.route` rather than by the name + span + for span in spans.get_finished_spans() + if span.name == REQUEST_SPAN_NAME + ] + assert len(matching) == 1, f"expected one request span, got {len(matching)}" + return matching[0] + + +def _application() -> Starlette: + async def endpoint(request: Request) -> Response: + return PlainTextResponse("done") + + return application( + # the whole wiring - the trace context of a request is resolved and + # handed over by the application context itself + ServerContext(observability=OpenTelemetry.observability), + routes=[Route("/example", endpoint)], + ) + + +@mark.asyncio +async def test_incoming_traceparent_is_continued(spans: InMemorySpanExporter) -> None: + app: Starlette = _application() + + async with running(app): + result: Result = await send_request( + app, + headers=( + (b"traceparent", REMOTE_TRACEPARENT.encode()), + (b"tracestate", b"vendor=value"), + ), + ) + + span: ReadableSpan = _request_span(spans) + assert span.parent is not None + assert f"{span.parent.trace_id:032x}" == REMOTE_TRACE_ID + assert f"{span.parent.span_id:016x}" == REMOTE_SPAN_ID + assert f"{span.context.trace_id:032x}" == REMOTE_TRACE_ID # pyright: ignore[reportOptionalMemberAccess] + assert span.parent.trace_state.get("vendor") == "value" + + # the response reports the very same trace, in both forms + assert result.headers[TRACE_ID_HEADER] == REMOTE_TRACE_ID + assert result.headers["traceparent"].startswith(f"00-{REMOTE_TRACE_ID}-") + assert result.headers["tracestate"] == "vendor=value" + + +@mark.asyncio +async def test_request_without_traceparent_starts_its_own_trace( + spans: InMemorySpanExporter, +) -> None: + app: Starlette = _application() + + async with running(app): + result: Result = await send_request(app) + + span: ReadableSpan = _request_span(spans) + assert span.parent is None + assert result.headers[TRACE_ID_HEADER] != REMOTE_TRACE_ID + assert result.headers["traceparent"].startswith(f"00-{result.headers[TRACE_ID_HEADER]}-") + + +@mark.asyncio +async def test_malformed_traceparent_starts_its_own_trace(spans: InMemorySpanExporter) -> None: + app: Starlette = _application() + + async with running(app): + result: Result = await send_request( + app, + headers=((b"traceparent", b"00-not-a-trace-01"),), + ) + + # rejected by the backend, as the specification requires + span: ReadableSpan = _request_span(spans) + assert span.parent is None + assert result.status == 200 + assert result.headers["traceparent"].startswith(f"00-{result.headers[TRACE_ID_HEADER]}-") + + +@mark.asyncio +async def test_conflicting_traceparents_start_a_new_trace(spans: InMemorySpanExporter) -> None: + app: Starlette = _application() + + async with running(app): + result: Result = await send_request( + app, + headers=( + (b"traceparent", REMOTE_TRACEPARENT.encode()), + (b"traceparent", b"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"), + ), + ) + + span: ReadableSpan = _request_span(spans) + assert span.parent is None + assert result.headers[TRACE_ID_HEADER] != REMOTE_TRACE_ID + + +@mark.asyncio +async def test_concurrent_requests_keep_their_traces_apart(spans: InMemorySpanExporter) -> None: + app: Starlette = _application() + other_trace_id: str = "0af7651916cd43dd8448eb211c80319c" + + async with running(app): + first, second = await gather( + send_request( + app, + headers=((b"traceparent", REMOTE_TRACEPARENT.encode()),), + ), + send_request( + app, + headers=((b"traceparent", f"00-{other_trace_id}-b7ad6b7169203331-01".encode()),), + ), + ) + + assert first.headers[TRACE_ID_HEADER] == REMOTE_TRACE_ID + assert second.headers[TRACE_ID_HEADER] == other_trace_id + assert { + f"{span.context.trace_id:032x}" # pyright: ignore[reportOptionalMemberAccess] + for span in spans.get_finished_spans() + if span.name == REQUEST_SPAN_NAME + } == {REMOTE_TRACE_ID, other_trace_id} + + +@mark.asyncio +async def test_unconfigured_integration_fails_requests() -> None: + # without the `spans` fixture the integration was never configured + app: Starlette = _application() + result = Result() + + async with running(app): + with raises(OpenTelemetryException): + await app(http_scope(), receive_request, result.collecting()) + + assert result.status == 500 + assert TRACE_ID_HEADER not in result.headers # there was no scope to report + + +@mark.asyncio +async def test_outgoing_request_continues_the_incoming_trace(spans: InMemorySpanExporter) -> None: + captured: MutableSequence[HTTPXRequest] = [] + + def downstream(request: HTTPXRequest) -> HTTPXResponse: + captured.append(request) + return HTTPXResponse(204) + + async def endpoint(request: Request) -> Response: + # the request carries the trace of its caller onwards + response = await HTTPClient.get(url="/downstream", trace_propagation=True) + return PlainTextResponse(str(response.status_code)) + + app: Starlette = application( + ServerContext( + observability=OpenTelemetry.observability, + disposables=( + HTTPXClient( + base_url="https://downstream.test", + transport=MockTransport(downstream), + ), + ), + ), + routes=[Route("/example", endpoint)], + ) + + async with running(app): + result: Result = await send_request( + app, + headers=( + (b"traceparent", REMOTE_TRACEPARENT.encode()), + (b"tracestate", b"vendor=value"), + ), + ) + + assert result.status == 200 + assert result.body == b"204" + + span: ReadableSpan = _request_span(spans) + assert span.context is not None + # the downstream service is called within the very trace which arrived, and + # continues from the span which handled the request + assert captured[0].headers["traceparent"] == ( + f"00-{span.context.trace_id:032x}-{span.context.span_id:016x}-01" + ) + assert captured[0].headers["traceparent"].startswith(f"00-{REMOTE_TRACE_ID}-") + assert captured[0].headers["tracestate"] == "vendor=value" + + +@mark.asyncio +async def test_websocket_continues_the_incoming_trace(spans: InMemorySpanExporter) -> None: + async def endpoint(websocket: WebSocket) -> None: + await websocket.accept() + await websocket.send_text(ctx.trace_id()) + await websocket.close() + + app: Starlette = application( + ServerContext(observability=OpenTelemetry.observability), + routes=[WebSocketRoute("/example", endpoint)], + ) + + incoming: Queue[Message] = Queue() + sent: MutableSequence[Message] = [] + + async def send(message: Message) -> None: + sent.append(message) + + await incoming.put({"type": "websocket.connect"}) + async with running(app): + await app( + websocket_scope(headers=((b"traceparent", REMOTE_TRACEPARENT.encode()),)), + incoming.get, + send, + ) + + matching: Sequence[ReadableSpan] = [ + span for span in spans.get_finished_spans() if span.name == "WS /example" + ] + assert len(matching) == 1 + assert matching[0].parent is not None + assert f"{matching[0].parent.trace_id:032x}" == REMOTE_TRACE_ID + assert sent[1]["text"] == REMOTE_TRACE_ID + + +@mark.asyncio +async def test_streamed_response_records_within_the_request_trace( + spans: InMemorySpanExporter, +) -> None: + captured: MutableSequence[HTTPXRequest] = [] + + def downstream(request: HTTPXRequest) -> HTTPXResponse: + captured.append(request) + return HTTPXResponse(204) + + async def endpoint(request: Request) -> Response: + async def content() -> AsyncGenerator[bytes]: + for index in range(2): + # a scope of its own, entered while the response is streaming + async with ctx.scope("chunk"): + await HTTPClient.get(url="/downstream", trace_propagation=True) + yield str(index).encode() + + return StreamResponse(content()) + + app: Starlette = application( + ServerContext( + observability=OpenTelemetry.observability, + disposables=( + HTTPXClient( + base_url="https://downstream.test", + transport=MockTransport(downstream), + ), + ), + ), + routes=[Route("/example", endpoint)], + ) + + async with running(app): + result: Result = await send_request( + app, + headers=((b"traceparent", REMOTE_TRACEPARENT.encode()),), + ) + + assert result.chunks == [b"0", b"1"] + + request_span: ReadableSpan = _request_span(spans) + assert request_span.context is not None + chunk_spans: Sequence[ReadableSpan] = [ + span for span in spans.get_finished_spans() if span.name == "chunk" + ] + # the scope of the request was still entered while the body was produced, so + # what the producer recorded belongs to the trace of the request + assert len(chunk_spans) == 2 + for span in chunk_spans: + assert span.parent is not None + assert span.parent.span_id == request_span.context.span_id + assert f"{span.context.trace_id:032x}" == REMOTE_TRACE_ID # pyright: ignore[reportOptionalMemberAccess] + + # and the requests it made carry that same trace onwards + assert [request.headers["traceparent"] for request in captured] == [ + f"00-{REMOTE_TRACE_ID}-{span.context.span_id:016x}-01" # pyright: ignore[reportOptionalMemberAccess] + for span in chunk_spans + ] + + +@mark.asyncio +async def test_request_span_carries_the_conventional_attributes( + spans: InMemorySpanExporter, +) -> None: + app: Starlette = _application() + + async with running(app): + assert (await send_request(app)).status == 200 + + span: ReadableSpan = _request_span(spans) + assert span.attributes is not None + # the path the span name can not carry reaches the span as the attributes the + # HTTP semantic conventions of OpenTelemetry define for it + assert span.attributes["http.request.method"] == "GET" + assert span.attributes["url.path"] == "/example" + assert span.attributes["url.scheme"] == "http" + assert span.attributes["network.protocol.version"] == "1.1" + assert span.attributes["http.response.status_code"] == 200 + # the query string is left out - it carries credentials often enough that + # recording it by default would leak them + assert "url.query" not in span.attributes + + +@mark.asyncio +async def test_failed_request_span_still_carries_its_attributes( + spans: InMemorySpanExporter, +) -> None: + async def endpoint(request: Request) -> Response: + raise ValueError("broken") + + app: Starlette = application( + ServerContext(observability=OpenTelemetry.observability), + routes=[Route("/example", endpoint)], + ) + + async with running(app): + with raises(ValueError): + await send_request(app) + + span: ReadableSpan = _request_span(spans) + assert span.attributes is not None + # recorded for a request which failed as well, which is where it is needed most + assert span.attributes["url.path"] == "/example" + assert span.attributes["http.request.method"] == "GET" + # nothing answered it, so there is no status to report + assert "http.response.status_code" not in span.attributes diff --git a/tests/test_starlette_streaming.py b/tests/test_starlette_streaming.py new file mode 100644 index 00000000..11df47df --- /dev/null +++ b/tests/test_starlette_streaming.py @@ -0,0 +1,449 @@ +from asyncio import sleep +from collections.abc import AsyncGenerator, Iterable, MutableSequence +from typing import Any +from uuid import UUID + +import pytest + +pytest.importorskip("starlette") + +from pytest import mark, raises +from starlette.applications import Starlette +from starlette.requests import ClientDisconnect, Request +from starlette.responses import Response, StreamingResponse +from starlette.routing import Route +from starlette.types import Message + +from haiway import Observability, ObservabilityLevel, State, ctx +from haiway.starlette import ( + ServerContext, + StreamResponse, + application, +) +from tests.asgi import ( + TRACE_ID_HEADER, + Result, + http_scope, + receive_request, + running, + send_request, +) + + +class ExampleState(State): + value: str = "example" + + +@mark.asyncio +async def test_stream_response_streams_within_the_request_scope() -> None: + async def endpoint(request: Request) -> Response: + async def content() -> AsyncGenerator[bytes]: + for index in range(3): + # the state and the trace of the request, resolved mid-stream + yield f"{index}:{ctx.state(ExampleState).value}:{ctx.trace_id()}".encode() + + return StreamResponse(content()) + + app: Starlette = application( + ServerContext(ExampleState(value="streamed")), + routes=[Route("/example", endpoint)], + ) + + async with running(app): + result: Result = await send_request(app) + + trace_id: str = result.headers[TRACE_ID_HEADER] + assert result.chunks == [f"{index}:streamed:{trace_id}".encode() for index in range(3)] + + +@mark.asyncio +async def test_stream_response_closes_the_body_when_it_ends() -> None: + released: MutableSequence[str] = [] + + async def endpoint(request: Request) -> Response: + async def content() -> AsyncGenerator[bytes]: + try: + yield b"first" + yield b"second" + + finally: + released.append("closed") + + return StreamResponse(content()) + + app: Starlette = application(routes=[Route("/example", endpoint)]) + + async with running(app): + result: Result = await send_request(app) + + assert result.chunks == [b"first", b"second"] + assert released == ["closed"] + + +@mark.asyncio +async def test_stream_response_closes_the_body_on_a_broken_connection() -> None: + released: MutableSequence[str] = [] + + async def endpoint(request: Request) -> Response: + async def content() -> AsyncGenerator[bytes]: + try: + yield b"first" + yield b"second" # never reaches the connection + + finally: + released.append("closed") + + return StreamResponse(content()) + + app: Starlette = application(routes=[Route("/example", endpoint)]) + sent: MutableSequence[Message] = [] + + async def failing_send(message: Message) -> None: + sent.append(message) + if message["type"] == "http.response.body" and message.get("body") == b"first": + raise OSError("connection gone") # the consumer is not there anymore + + async with running(app): + with raises(ClientDisconnect): + await app(http_scope(), receive_request, failing_send) + + # closed by the response, before the request was over - the generator was + # left suspended at its yield, which the iteration alone does not close + assert released == ["closed"] + + +@mark.asyncio +async def test_abandoned_stream_releases_its_own_scope() -> None: + disposed: MutableSequence[str] = [] + + class Resource: + async def __aenter__(self) -> Iterable[State]: + disposed.append("acquired") + return (ExampleState(value="nested"),) + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: object, + ) -> None: + await sleep(0) # releasing a real resource awaits + disposed.append("released") + + async def produce() -> AsyncGenerator[bytes]: + async with ctx.scope("producing", disposables=(Resource(),)): + yield ctx.state(ExampleState).value.encode() + await sleep(1) # never reached by the consumer + yield b"unreachable" + + async def endpoint(request: Request) -> Response: + return StreamResponse(produce()) + + app: Starlette = application(routes=[Route("/example", endpoint)]) + + async def failing_send(message: Message) -> None: + if message["type"] == "http.response.body" and message.get("body") == b"nested": + raise OSError("connection gone") + + async with running(app): + with raises(ClientDisconnect): + await app(http_scope(), receive_request, failing_send) + + # the scope living inside the generator is released where the streaming + # ended, not whenever the garbage collector reaches the generator + assert disposed == ["acquired", "released"] + + +@mark.asyncio +async def test_failed_stream_is_reported_within_the_request() -> None: + async def endpoint(request: Request) -> Response: + async def content() -> AsyncGenerator[bytes]: + yield b"partial" + raise ValueError("broken") + + return StreamResponse(content()) + + app: Starlette = application(routes=[Route("/example", endpoint)]) + result = Result() + + async with running(app): + with raises(ValueError): + await app(http_scope(), receive_request, result.collecting()) + + # a response which already started can not be replaced by an error + assert result.status == 200 + assert result.body == b"partial" + + +@mark.asyncio +async def test_stream_response_carries_the_provided_media_type() -> None: + async def endpoint(request: Request) -> Response: + async def content() -> AsyncGenerator[bytes]: + yield b'{"row": 1}\n' + + return StreamResponse(content(), media_type="application/x-ndjson") + + app: Starlette = application(routes=[Route("/example", endpoint)]) + + async with running(app): + result: Result = await send_request(app) + + assert result.headers["content-type"] == "application/x-ndjson" + assert result.body == b'{"row": 1}\n' + + +@mark.asyncio +async def test_framework_response_leaves_an_abandoned_body_open() -> None: + # characterization of what `StreamResponse` is for - should the framework + # start closing an abandoned body itself, the override becomes redundant + released: MutableSequence[str] = [] + + async def endpoint(request: Request) -> Response: + async def content() -> AsyncGenerator[bytes]: + try: + yield b"first" + yield b"second" + + finally: + released.append("closed") + + return StreamingResponse(content()) + + app: Starlette = application(routes=[Route("/example", endpoint)]) + + async def failing_send(message: Message) -> None: + if message["type"] == "http.response.body" and message.get("body") == b"first": + raise OSError("connection gone") + + async with running(app): + with raises(ClientDisconnect): + await app(http_scope(), receive_request, failing_send) + + assert released == [] # left for the garbage collector + + +@mark.asyncio +async def test_context_stream_is_closed_when_abandoned() -> None: + released: MutableSequence[str] = [] + + async def produce() -> AsyncGenerator[bytes]: + try: + # the state of the request, resolved from the parent of the stream scope + yield ctx.state(ExampleState).value.encode() + await sleep(1) # never reached by the consumer + yield b"unreachable" + + finally: + released.append("closed") + + async def endpoint(request: Request) -> Response: + # the scope of `ctx.stream` lives inside its generator, so it spans the + # whole response instead of being released before the streaming starts + return StreamResponse(ctx.stream(produce)) + + app: Starlette = application( + ServerContext(ExampleState(value="from-stream")), + routes=[Route("/example", endpoint)], + ) + sent: MutableSequence[bytes] = [] + + async def failing_send(message: Message) -> None: + if message["type"] == "http.response.body" and message.get("body"): + sent.append(message["body"]) + raise OSError("connection gone") + + async with running(app): + with raises(ClientDisconnect): + await app(http_scope(), receive_request, failing_send) + + # both the producer and the scope of the stream ended with the response - + # the context checks of the suite catch a scope left behind + assert released == ["closed"] + + assert sent == [b"from-stream"] + + +class _Records: + """Observability capturing what a request scope recorded.""" + + def __init__(self) -> None: + self.logs: MutableSequence[tuple[ObservabilityLevel, str, str | None]] = [] + self.failures: MutableSequence[str] = [] + + def observability(self) -> Observability: + def scope_exiting( + scope: Any, + /, + *, + exception: BaseException | None, + ) -> None: + if exception is not None: + self.failures.append(type(exception).__name__) + + def log_recording( + scope: Any, + /, + level: ObservabilityLevel, + message: str, + *args: Any, + exception: BaseException | None, + ) -> None: + self.logs.append( + (level, message, None if exception is None else str(exception)), + ) + + return Observability( + trace_identifying=lambda scope, /: UUID(int=1), + log_recording=log_recording, + metric_recording=lambda scope, /, level, **kwargs: None, + event_recording=lambda scope, /, level, **kwargs: None, + attributes_recording=lambda scope, /, level, attributes: None, + scope_entering=lambda scope, /: "trace", + scope_exiting=scope_exiting, + trace_context_encoding=lambda scope, /: {}, + ) + + +class ExampleError(Exception): + pass + + +@mark.asyncio +async def test_failed_stream_is_recorded_with_the_actual_error() -> None: + async def endpoint(request: Request) -> Response: + async def content() -> AsyncGenerator[bytes]: + yield b"partial" + raise ExampleError("stream broke") + + return StreamResponse(content()) + + records = _Records() + app: Starlette = application( + ServerContext(observability=records.observability()), + routes=[Route("/example", endpoint)], + # a handler matching the error makes the framework replace it with a + # `RuntimeError` about a response already started, which is all the + # request would otherwise be recorded as failing with + exception_handlers={ExampleError: _handled}, + ) + result = Result() + + async with running(app): + with raises(RuntimeError): + await app(http_scope(), receive_request, result.collecting()) + + assert result.body == b"partial" + assert (ObservabilityLevel.ERROR, "Response streaming failed", "stream broke") in records.logs + + +@mark.asyncio +async def test_failing_stream_close_does_not_replace_the_error() -> None: + async def endpoint(request: Request) -> Response: + async def content() -> AsyncGenerator[bytes]: + try: + yield b"partial" # the connection goes away here + yield b"unreachable" + + except GeneratorExit: + # what a scope failing to release within the generator looks like + raise ExampleError("close broke") from None + + return StreamResponse(content()) + + records = _Records() + app: Starlette = application( + ServerContext(observability=records.observability()), + routes=[Route("/example", endpoint)], + ) + + async def failing_send(message: Message) -> None: + if message["type"] == "http.response.body" and message.get("body") == b"partial": + raise ExampleError("send broke") + + async with running(app): + # the failure of the response, not the one from closing after it + with raises(ExampleError) as failure: + await app(http_scope(), receive_request, failing_send) + + assert str(failure.value) == "send broke" + assert ( + ObservabilityLevel.WARNING, + "Response stream failed to close", + "close broke", + ) in records.logs + assert [message for _, message, _ in records.logs].count("Response streaming failed") == 1 + + +@mark.asyncio +async def test_disconnected_consumer_is_not_a_stream_failure() -> None: + async def endpoint(request: Request) -> Response: + async def content() -> AsyncGenerator[bytes]: + yield b"partial" + yield b"unreachable" + + return StreamResponse(content()) + + records = _Records() + app: Starlette = application( + ServerContext(observability=records.observability()), + routes=[Route("/example", endpoint)], + ) + + async def failing_send(message: Message) -> None: + if message["type"] == "http.response.body" and message.get("body") == b"partial": + raise OSError("connection gone") # the consumer is not there anymore + + async with running(app): + with raises(ClientDisconnect): + await app(http_scope(), receive_request, failing_send) + + # a consumer which went away ends the response without failing it - recorded + # as what happened rather than as an error, and not as a failed request + assert ( + ObservabilityLevel.DEBUG, + "Response streaming ended by a disconnected consumer", + None, + ) in records.logs + assert [message for _, message, _ in records.logs].count("Response streaming failed") == 0 + assert records.failures == [] + + +@mark.asyncio +async def test_body_failing_with_an_os_error_is_a_stream_failure() -> None: + async def endpoint(request: Request) -> Response: + async def content() -> AsyncGenerator[bytes]: + yield b"partial" + raise OSError("the resource behind the body died") + + return StreamResponse(content()) + + records = _Records() + app: Starlette = application( + ServerContext(observability=records.observability()), + routes=[Route("/example", endpoint)], + ) + result = Result() + + async with running(app): + # the framework turns an `OSError` reaching it into a `ClientDisconnect`, + # which is what a gone consumer is reported with as well + with raises(ClientDisconnect): + await app(http_scope(), receive_request, result.collecting()) + + # a gone consumer is the send failing - a body failing with an error of the + # same type is the failure of the response it was producing + assert ( + ObservabilityLevel.ERROR, + "Response streaming failed", + "the resource behind the body died", + ) in records.logs + assert [message for _, message, _ in records.logs].count( + "Response streaming ended by a disconnected consumer" + ) == 0 + + +async def _handled( + request: Request, + exception: Exception, +) -> Response: + return Response(status_code=400) diff --git a/uv.lock b/uv.lock index 5ae655cb..d0aa4718 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,24 @@ version = 1 revision = 3 requires-python = ">=3.14" +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + [[package]] name = "anyio" version = "4.14.2" @@ -306,6 +324,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/5a/234e8fadf85c3cc48cb31c247b9e8e0c7f06ece80f5b29f9b8c241f9da4c/coverage-7.16.0-py3-none-any.whl", hash = "sha256:245f7de6d023a5bba375dbec9f2e0869bfa26ac0cc639bbb7b4c814884000b73", size = 214977, upload-time = "2026-08-28T21:54:35.189Z" }, ] +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + [[package]] name = "ghp-import" version = "2.1.0" @@ -394,6 +428,10 @@ docs = [ { name = "mkdocstrings", extra = ["python"] }, { name = "pymarkdownlnt" }, ] +fastapi = [ + { name = "fastapi" }, + { name = "starlette" }, +] httpx = [ { name = "httpx2" }, ] @@ -408,11 +446,15 @@ postgres = [ rabbitmq = [ { name = "pika" }, ] +starlette = [ + { name = "starlette" }, +] [package.metadata] requires-dist = [ { name = "asyncpg", marker = "extra == 'postgres'", specifier = "~=0.31.0" }, { name = "bandit", marker = "extra == 'dev'", specifier = "~=1.9" }, + { name = "fastapi", marker = "extra == 'fastapi'", specifier = "~=0.141" }, { name = "httpx2", marker = "extra == 'httpx'", specifier = "~=2.10" }, { name = "mdformat", marker = "extra == 'docs'", specifier = "~=0.7" }, { name = "mdformat-gfm", marker = "extra == 'docs'", specifier = "~=0.4" }, @@ -429,9 +471,11 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "~=1.4" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = "~=7.1" }, { name = "ruff", marker = "extra == 'dev'", specifier = "~=0.16" }, + { name = "starlette", marker = "extra == 'fastapi'", specifier = "~=1.6" }, + { name = "starlette", marker = "extra == 'starlette'", specifier = "~=1.6" }, { name = "typing-extensions", specifier = "~=4.16" }, ] -provides-extras = ["opentelemetry", "httpx", "postgres", "rabbitmq", "dev", "docs"] +provides-extras = ["opentelemetry", "httpx", "postgres", "rabbitmq", "starlette", "fastapi", "dev", "docs"] [[package]] name = "httpcore2" @@ -869,11 +913,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.11.6" +version = "4.11.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/1d/6e762a6b060e662208951aefc5c39f6a96a272c4a10c0c1f7b6113fc3c09/platformdirs-4.11.6.tar.gz", hash = "sha256:1a4016e373f89f8ec458431fe0e0c5c4285858ac623f3e20efdfcbc0bd862941", size = 35131, upload-time = "2026-09-01T04:41:00.6Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/b7/802a56eca9f2fac455b8bab5375a2647b0f0e14a2cd63ef077de3c4a7658/platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d", size = 35127, upload-time = "2026-09-01T13:35:10.502Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/d8/2784c6eabb991b5b7494ff9e9888c74a0a72ad613c3ec5adbfcecc0724c7/platformdirs-4.11.6-py3-none-any.whl", hash = "sha256:b22d992e863bc651c26b16242041c7979db6e3286e548f9a76cc91238fac599e", size = 23938, upload-time = "2026-09-01T04:40:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/27/6e/80993e10a0482f630cef528635789233224f36b1ffd11592aa15d13ff9ce/platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6", size = 23938, upload-time = "2026-09-01T13:35:09.02Z" }, ] [[package]] @@ -912,6 +956,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/38/56b67abdbf6797475dfe2f62d391b4a6ead851c76acbaf07e118e53651b6/py_walk-0.3.3-py3-none-any.whl", hash = "sha256:238fc018165138021ce0bfd9c351cdc473d3120ccc5534df35611b92608c94d5", size = 14537, upload-time = "2024-10-26T14:30:38.06Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -1174,6 +1274,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/4d/c96d807295183f2360329cd8d8bf5e8072c53d664125b3858c04153f026e/sly-0.5-py3-none-any.whl", hash = "sha256:20485483259eec7f6ba85ff4d2e96a4e50c6621902667fc2695cc8bc2a3e5133", size = 28864, upload-time = "2022-10-25T14:35:28.054Z" }, ] +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + [[package]] name = "stevedore" version = "5.9.1" @@ -1237,6 +1349,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + [[package]] name = "urllib3" version = "2.7.0"