From f32200b1728464ad0cc7fb7b4bab46b003e12f8c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 17 Oct 2026 21:04:09 +0300 Subject: [PATCH] perf: skip the WebSocket handshake probe without an Upgrade header --- .../0002-scope-follows-the-handshake-probe.md | 3 ++- modern_di_aiohttp/main.py | 7 +++---- tests/test_middleware.py | 18 ++++++++++++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/docs/adr/0002-scope-follows-the-handshake-probe.md b/docs/adr/0002-scope-follows-the-handshake-probe.md index 392c7af..61143e0 100644 --- a/docs/adr/0002-scope-follows-the-handshake-probe.md +++ b/docs/adr/0002-scope-follows-the-handshake-probe.md @@ -7,7 +7,8 @@ Both connection providers bind `web.Request` 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 handshake. The probe is skipped when the request has no `Upgrade` header, because aiohttp's +handshake rejects that case first, so the outcome is the same without the probe's cost. 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 diff --git a/modern_di_aiohttp/main.py b/modern_di_aiohttp/main.py index 9da6ec2..97e44c0 100644 --- a/modern_di_aiohttp/main.py +++ b/modern_di_aiohttp/main.py @@ -5,7 +5,7 @@ import typing import aiohttp.abc -from aiohttp import web +from aiohttp import hdrs, web from modern_di import Container, Scope, integrations, providers @@ -64,9 +64,8 @@ async def _di_middleware( # bind `web.Request`, so `integrations.classify_connection`'s isinstance # dispatch can't tell them apart — this probe is what picks the provider; # `integrations.bind` only derives the scope+context once it's picked. - provider = ( - aiohttp_websocket_provider if web.WebSocketResponse().can_prepare(request).ok else aiohttp_request_provider - ) + is_websocket = hdrs.UPGRADE in request.headers and web.WebSocketResponse().can_prepare(request).ok + provider = aiohttp_websocket_provider if is_websocket else aiohttp_request_provider match = integrations.bind(provider, request) async with fetch_di_container(request.app).build_child_container( scope=match.scope, context=match.context diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 94b975d..50574b3 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -1,7 +1,9 @@ import collections.abc import gc import http +from unittest import mock +import pytest from aiohttp import web from aiohttp.test_utils import TestClient from modern_di import Container, Scope @@ -26,6 +28,22 @@ async def endpoint(request: web.Request) -> web.Response: assert (await client.get("/")).status == http.HTTPStatus.OK +async def test_a_request_without_an_upgrade_header_skips_the_handshake_probe( + aiohttp_client: AiohttpClient, app: web.Application, monkeypatch: pytest.MonkeyPatch +) -> None: + async def endpoint(request: web.Request) -> web.Response: + assert request[_CONTAINER_REQUEST_KEY].scope is Scope.REQUEST + return web.Response(text="ok") + + app.router.add_get("/", endpoint) + client = await aiohttp_client(app) + websocket_response = mock.Mock(wraps=web.WebSocketResponse) + monkeypatch.setattr(web, "WebSocketResponse", websocket_response) + + assert (await client.get("/")).status == http.HTTPStatus.OK + assert websocket_response.call_count == 0 + + async def test_middleware_opens_session_scoped_child_for_websocket( aiohttp_client: AiohttpClient, app: web.Application ) -> None: