Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 11 additions & 35 deletions docs/adr/0001-two-connection-providers-one-reference-only.md
Original file line number Diff line number Diff line change
@@ -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)`.
36 changes: 12 additions & 24 deletions docs/adr/0002-scope-follows-the-handshake-probe.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion tests/test_connection_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
Loading