From d1528f3493b88fc0c376e7674e379d40fc8eed5b Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 19 Sep 2026 14:10:10 +0300 Subject: [PATCH] docs(adr): compress to 2 records in the domain-modeling format Both records survive the admission test and keep their numbers and slugs. Each is now a single paragraph: the **Decision:** lead, the section-per-argument layout and the **Revisit trigger:** footers are gone, with each trigger folded into the paragraph as the decision's boundary. No drops, no merges. The 0001 citation in tests/test_connection_providers.py moves from the bare "ADR 0001" form to the full docs/adr path the citation test can resolve. --- ...connection-providers-one-reference-only.md | 46 +++++-------------- .../0002-scope-follows-the-handshake-probe.md | 36 +++++---------- tests/test_connection_providers.py | 2 +- 3 files changed, 24 insertions(+), 60 deletions(-) diff --git a/docs/adr/0001-two-connection-providers-one-reference-only.md b/docs/adr/0001-two-connection-providers-one-reference-only.md index 3386c34..626cc53 100644 --- a/docs/adr/0001-two-connection-providers-one-reference-only.md +++ b/docs/adr/0001-two-connection-providers-one-reference-only.md @@ -1,37 +1,13 @@ # Two connection providers, one registered by type and one reference-only -**Decision:** the connection is exposed by two `ContextProvider`s — `aiohttp_request_provider` at -`Scope.REQUEST`, registered by type, and `aiohttp_websocket_provider` at `Scope.SESSION` with -`bound_type=None` — rather than by a single provider serving both connection kinds. - -Two facts fix the shape. aiohttp is not ASGI: a WebSocket is an ordinary HTTP GET carrying -`Upgrade` headers, upgraded *inside* the route handler, so the only connection object at middleware -entry is a `web.Request` for both kinds — there is no second type to bind. And a `ContextProvider` -reads its value from the ancestor at its **own** scope, so a `REQUEST`-scoped provider is -unreachable from a WebSocket's `SESSION` container and a `SESSION`-scoped one is unreachable from -an HTTP `REQUEST`-off-`APP` container. One fixed-scope provider cannot serve both paths, and -modern-di's providers registry admits one provider per bound type, so two cannot both be registered -by type. - -The alternative is dishka's: bind the connection once at `Scope.SESSION` and enter scopes -contiguously, `APP → SESSION → REQUEST` for HTTP as well, so a single binding serves both paths. -Rejected because modern-di permits scope-skipping and the shape we want is one container per -connection, with HTTP a plain `REQUEST` child of `APP` and no `SESSION` level opened for it. -Contiguous scopes would buy the single binding by giving every HTTP request a container it has no -use for. - -Making the WebSocket provider reference-only is what buys the second provider: `add_providers` -skips a provider with no bound type, so no `DuplicateProviderTypeError`, while the provider stays -fully resolvable by reference from the `SESSION` container and every nested per-message `REQUEST` -child. - -The consequence is an asymmetry that is documented rather than hidden: on the HTTP path the -connection is injected as a bare `web.Request`, on the WebSocket path only as -`FromDI(aiohttp_websocket_provider)`. That asymmetry is inherent to aiohttp's single-`web.Request` -reality under one-container-per-connection scoping, not a rough edge to file off; WebSocket -handlers also always receive `request` positionally, so the loss is small. - -**Revisit trigger:** aiohttp exposes a distinct connection object for WebSockets at middleware -entry, or modern-di admits two providers under one bound type. Either removes the constraint the -two-provider split exists to satisfy, and the connection should then be a single provider bound by -type on both paths. +aiohttp is not ASGI: a WebSocket is an ordinary HTTP GET upgraded inside the route handler, so the +only connection object at middleware entry is a `web.Request` for both kinds. A `ContextProvider` +reads its value from the ancestor at its own scope, so one fixed-scope provider cannot serve both +the HTTP `REQUEST` child and the WebSocket `SESSION` child, and modern-di's registry admits one +provider per bound type. The connection is therefore two providers: `aiohttp_request_provider` at +`Scope.REQUEST` bound by type, and `aiohttp_websocket_provider` at `Scope.SESSION` with +`bound_type=None`, which `add_providers` skips rather than rejecting as a duplicate while leaving +it resolvable by reference. dishka's alternative, binding once at `SESSION` and entering +`APP -> SESSION -> REQUEST` for HTTP too, was rejected because it gives every HTTP request a +container it has no use for. The cost is an asymmetry: HTTP handlers inject a bare `web.Request`, +WebSocket handlers use `FromDI(aiohttp_websocket_provider)`. diff --git a/docs/adr/0002-scope-follows-the-handshake-probe.md b/docs/adr/0002-scope-follows-the-handshake-probe.md index 718c86c..392c7af 100644 --- a/docs/adr/0002-scope-follows-the-handshake-probe.md +++ b/docs/adr/0002-scope-follows-the-handshake-probe.md @@ -1,26 +1,14 @@ # The child's scope follows a handshake probe, not headers and not the route -**Decision:** `_di_middleware` picks the connection kind with -`web.WebSocketResponse().can_prepare(request).ok` — aiohttp's own handshake check — and derives the -child container's scope from the provider that probe selects. - -`can_prepare` is synchronous, never raises, does not consume the request and does not start the -handshake; it answers exactly the question asked, and it is aiohttp's own answer rather than a -reimplementation that can drift from it. - -The alternative reviewed was dishka's header comparison, `Connection == "Upgrade"`. Rejected as -brittle: it misses the legal `keep-alive, Upgrade` form and casing variants, and it re-derives from -raw headers something the framework already computes. - -Dispatching on the route was not taken either. Scope selection happens in middleware, before -routing has produced a handler, and a route table would have to be kept in sync with the handlers -by hand. - -The consequence is that scope follows the request's handshake headers and nothing else: a request -that advertises a valid WebSocket upgrade opens a `SESSION` child whatever its handler actually -does, and a `REQUEST`-scoped provider would then fail to resolve for it. This is the price of -deciding before routing, and it is only reachable by a client that sends upgrade headers to a plain -HTTP endpoint. - -**Revisit trigger:** a real handler needs `REQUEST` scope on a request carrying WebSocket-upgrade -headers, or aiohttp makes the connection kind available at middleware entry without a probe. +Both connection providers bind `web.Request` +(see [ADR-0001](0001-two-connection-providers-one-reference-only.md)), so +`integrations.classify_connection`'s isinstance dispatch cannot tell them apart and +`_di_middleware` picks the kind itself with `web.WebSocketResponse().can_prepare(request).ok`, +passing the chosen provider to `integrations.bind` for the child's scope and context. The probe is +aiohttp's own handshake check rather than a reimplementation that can drift from it: on a fresh +response object it cannot raise, it is synchronous, and it neither consumes the request nor starts +the handshake. dishka's `Connection == "Upgrade"` header comparison was rejected as brittle, missing +the legal `keep-alive, Upgrade` form and casing variants, and dispatching on the route was +unavailable because scope selection runs before routing has produced a handler. The boundary is that +scope follows the handshake headers and nothing else, so a request advertising a valid upgrade to a +plain HTTP endpoint opens a `SESSION` child and a `REQUEST`-scoped provider then fails to resolve. diff --git a/tests/test_connection_providers.py b/tests/test_connection_providers.py index c792ecf..96f64d2 100644 --- a/tests/test_connection_providers.py +++ b/tests/test_connection_providers.py @@ -14,7 +14,7 @@ def test_exactly_one_connection_provider_is_registered_by_type() -> None: connection provider makes ``setup_di`` raise ``DuplicateProviderTypeError`` for every application built. Reference-only is what lets a ``SESSION``-scoped connection provider exist at all, and the reason the WebSocket path reads the connection by provider reference instead of - by bare type (ADR 0001). + by bare type (``docs/adr/0001-two-connection-providers-one-reference-only.md``). """ assert {provider.context_type for provider in _CONNECTION_PROVIDERS} == {web.Request}