From d01078cb315c60cf16bd4bab3f0ea7360f3b8b70 Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Sat, 24 Jan 2026 00:20:51 -0300 Subject: [PATCH 01/30] Fix flaky import time test for Python 3.12+ --- CHANGES/11992.contrib.rst | 5 +++++ CONTRIBUTORS.txt | 1 + tests/test_imports.py | 29 +++++++++++------------------ 3 files changed, 17 insertions(+), 18 deletions(-) create mode 100644 CHANGES/11992.contrib.rst diff --git a/CHANGES/11992.contrib.rst b/CHANGES/11992.contrib.rst new file mode 100644 index 00000000000..2a27dc22a65 --- /dev/null +++ b/CHANGES/11992.contrib.rst @@ -0,0 +1,5 @@ +Fixed flaky import time test for Python 3.12+ -- by :user:`rodrigobnogueira`. + +Refactored to use version comparison instead of explicit version list, +making the test future-proof for new Python releases. + diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 46a3f491a38..9d593e1e6a2 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -318,6 +318,7 @@ Raúl Cumplido Required Field Robert Lu Robert Nikolich +Rodrigo Nogueira Roman Markeloff Roman Podoliaka Roman Postnov diff --git a/tests/test_imports.py b/tests/test_imports.py index 1579135d539..7a57780a9f9 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -28,20 +28,14 @@ def test_web___all__(pytester: pytest.Pytester) -> None: result.assert_outcomes(passed=0, errors=0) -_IS_CI_ENV = os.getenv("CI") == "true" -_XDIST_WORKER_COUNT = int(os.getenv("PYTEST_XDIST_WORKER_COUNT", 0)) -_IS_XDIST_RUN = _XDIST_WORKER_COUNT > 1 - -_TARGET_TIMINGS_BY_PYTHON_VERSION = { - "3.12": ( - # 3.12+ is expected to be a bit slower due to performance trade-offs, - # and even slower under pytest-xdist, especially in CI - _XDIST_WORKER_COUNT * 100 * (1 if _IS_CI_ENV else 1.53) - if _IS_XDIST_RUN - else 295 - ), -} -_TARGET_TIMINGS_BY_PYTHON_VERSION["3.13"] = _TARGET_TIMINGS_BY_PYTHON_VERSION["3.12"] +_IMPORT_TIME_THRESHOLD_PY312 = 350 +_IMPORT_TIME_THRESHOLD_DEFAULT = 200 + + +def _get_import_time_threshold() -> float: + if sys.version_info >= (3, 12): + return _IMPORT_TIME_THRESHOLD_PY312 + return _IMPORT_TIME_THRESHOLD_DEFAULT @pytest.mark.internal @@ -67,7 +61,7 @@ def test_import_time(pytester: pytest.Pytester) -> None: for _ in range(3): r = pytester.run(sys.executable, "-We", "-c", cmd) - assert not r.stderr.str() + assert not r.stderr.str(), r.stderr.str() runtime_ms = int(r.stdout.str()) if runtime_ms < best_time_ms: best_time_ms = runtime_ms @@ -77,7 +71,6 @@ def test_import_time(pytester: pytest.Pytester) -> None: else: os.environ["PYTHONPATH"] = old_path - expected_time = _TARGET_TIMINGS_BY_PYTHON_VERSION.get( - f"{sys.version_info.major}.{sys.version_info.minor}", 200 - ) + expected_time = _get_import_time_threshold() assert best_time_ms < expected_time + From 35976611dbc10dbd312e592d49d2e629991b2e59 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 19:19:42 +0000 Subject: [PATCH 02/30] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- CHANGES/11992.contrib.rst | 1 - tests/test_imports.py | 1 - 2 files changed, 2 deletions(-) diff --git a/CHANGES/11992.contrib.rst b/CHANGES/11992.contrib.rst index 2a27dc22a65..297278d6add 100644 --- a/CHANGES/11992.contrib.rst +++ b/CHANGES/11992.contrib.rst @@ -2,4 +2,3 @@ Fixed flaky import time test for Python 3.12+ -- by :user:`rodrigobnogueira`. Refactored to use version comparison instead of explicit version list, making the test future-proof for new Python releases. - diff --git a/tests/test_imports.py b/tests/test_imports.py index 7a57780a9f9..024f069a480 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -73,4 +73,3 @@ def test_import_time(pytester: pytest.Pytester) -> None: expected_time = _get_import_time_threshold() assert best_time_ms < expected_time - From a6313f9b657018176e7522605d5c7a44a40fd4db Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Sat, 24 Jan 2026 17:11:02 -0300 Subject: [PATCH 03/30] Fix flaky test_regex_performance timing test --- tests/test_client_middleware_digest_auth.py | 24 +++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/test_client_middleware_digest_auth.py b/tests/test_client_middleware_digest_auth.py index c15bf1b422e..c639b4ba6ba 100644 --- a/tests/test_client_middleware_digest_auth.py +++ b/tests/test_client_middleware_digest_auth.py @@ -1333,11 +1333,23 @@ async def handler(request: Request) -> Response: def test_regex_performance() -> None: value = "0" * 54773 + "\\0=a" - start = time.perf_counter() - matches = _HEADER_PAIRS_PATTERN.findall(value) - end = time.perf_counter() - # If this is taking more than 10ms, there's probably a performance/ReDoS issue. - assert (end - start) < 0.01 + best_time = float("inf") + best_matches: list[tuple[str, str]] = [] + + for _ in range(5): + start = time.perf_counter() + matches = _HEADER_PAIRS_PATTERN.findall(value) + elapsed = time.perf_counter() - start + + if elapsed < best_time: + best_time = elapsed + best_matches = matches + + # Relaxed for CI/platform variability (e.g., macOS runners ~40-50ms observed) + assert ( + best_time < 0.1 + ), f"Regex took {best_time * 1000:.1f}ms, expected <100ms - potential ReDoS issue" + # This example probably shouldn't produce a match either. - assert not matches + assert not best_matches From 6ec38c66e993b0567dc5d72ed7583fcf00538e6e Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Sun, 25 Jan 2026 13:49:10 -0300 Subject: [PATCH 04/30] Improve flaky test handling using pytest-rerunfailures --- aiohttp/pytest_plugin.py | 20 +++++++++++ requirements/test-common.in | 1 + tests/test_client_middleware_digest_auth.py | 35 +++++++++--------- tests/test_imports.py | 40 +++++++++++---------- 4 files changed, 61 insertions(+), 35 deletions(-) diff --git a/aiohttp/pytest_plugin.py b/aiohttp/pytest_plugin.py index 65f8dc8aa7f..f794bd3561e 100644 --- a/aiohttp/pytest_plugin.py +++ b/aiohttp/pytest_plugin.py @@ -64,6 +64,26 @@ def __call__( ) -> Awaitable[RawTestServer]: ... +def get_flaky_threshold( + request: pytest.FixtureRequest, + base: float, + increment: float, +) -> float: + """Calculate dynamic threshold for flaky tests based on rerun count. + + When using `@pytest.mark.flaky(reruns=N)`: + - execution_count is 1-based (1 for first run, 2 for first rerun, etc.) + - With reruns=3, the test runs up to 4 times (1 initial + 3 reruns) + - Returns base threshold on first run, incrementing by `increment` per rerun + + Example with reruns=3, base=20ms, increment=30ms: + Run 1: 20ms, Rerun 1: 50ms, Rerun 2: 80ms, Rerun 3: 110ms + """ + execution_count: int = getattr(request.node, "execution_count", 0) + rerun_count = max(0, execution_count - 1) + return base + (rerun_count * increment) + + def pytest_addoption(parser): # type: ignore[no-untyped-def] parser.addoption( "--aiohttp-fast", diff --git a/requirements/test-common.in b/requirements/test-common.in index c010f61fa8a..db55c4b47d4 100644 --- a/requirements/test-common.in +++ b/requirements/test-common.in @@ -8,6 +8,7 @@ proxy.py >= 2.4.4rc5 pytest pytest-cov pytest-mock +pytest-rerunfailures pytest-xdist pytest_codspeed python-on-whales diff --git a/tests/test_client_middleware_digest_auth.py b/tests/test_client_middleware_digest_auth.py index c639b4ba6ba..5e3e8c6d87e 100644 --- a/tests/test_client_middleware_digest_auth.py +++ b/tests/test_client_middleware_digest_auth.py @@ -26,6 +26,7 @@ from aiohttp.payload import BytesIOPayload from aiohttp.pytest_plugin import AiohttpServer from aiohttp.web import Application, Request, Response +from aiohttp.pytest_plugin import get_flaky_threshold @pytest.fixture @@ -1331,25 +1332,27 @@ async def handler(request: Request) -> Response: assert auth_algorithms[0] == "MD5-sess" # Not "MD5-SESS" -def test_regex_performance() -> None: - value = "0" * 54773 + "\\0=a" +@pytest.mark.flaky(reruns=3) +def test_regex_performance(request: pytest.FixtureRequest) -> None: + """Test that the regex pattern doesn't suffer from ReDoS issues. - best_time = float("inf") - best_matches: list[tuple[str, str]] = [] + Threshold starts at 20ms and increases on each rerun for CI variability. + """ + REGEX_TIME_THRESHOLD_DEFAULT = 0.02 # 20ms + REGEX_TIME_INCREMENT_PER_RERUN = 0.03 # 30ms + # CI/platform variability (e.g., macOS runners ~40-50ms observed) + threshold_ms = get_flaky_threshold( + request, REGEX_TIME_THRESHOLD_DEFAULT, REGEX_TIME_INCREMENT_PER_RERUN + ) - for _ in range(5): - start = time.perf_counter() - matches = _HEADER_PAIRS_PATTERN.findall(value) - elapsed = time.perf_counter() - start + value = "0" * 54773 + "\\0=a" - if elapsed < best_time: - best_time = elapsed - best_matches = matches + start = time.perf_counter() + matches = _HEADER_PAIRS_PATTERN.findall(value) + elapsed = time.perf_counter() - start - # Relaxed for CI/platform variability (e.g., macOS runners ~40-50ms observed) assert ( - best_time < 0.1 - ), f"Regex took {best_time * 1000:.1f}ms, expected <100ms - potential ReDoS issue" + elapsed < threshold_ms + ), f"Regex took {elapsed * 1000:.1f}ms, expected <{threshold_ms * 1000:.0f}ms - potential ReDoS issue" - # This example probably shouldn't produce a match either. - assert not best_matches + assert not matches diff --git a/tests/test_imports.py b/tests/test_imports.py index 024f069a480..53714dc10c1 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -2,8 +2,8 @@ import platform import sys from pathlib import Path - import pytest +from aiohttp.pytest_plugin import get_flaky_threshold def test___all__(pytester: pytest.Pytester) -> None: @@ -28,48 +28,50 @@ def test_web___all__(pytester: pytest.Pytester) -> None: result.assert_outcomes(passed=0, errors=0) -_IMPORT_TIME_THRESHOLD_PY312 = 350 +_IMPORT_TIME_THRESHOLD_PY312 = 300 _IMPORT_TIME_THRESHOLD_DEFAULT = 200 - - -def _get_import_time_threshold() -> float: - if sys.version_info >= (3, 12): - return _IMPORT_TIME_THRESHOLD_PY312 - return _IMPORT_TIME_THRESHOLD_DEFAULT +_IMPORT_TIME_INCREMENT_PER_RERUN = 50 @pytest.mark.internal @pytest.mark.dev_mode +@pytest.mark.flaky(reruns=3) @pytest.mark.skipif( not sys.platform.startswith("linux") or platform.python_implementation() == "PyPy", reason="Timing is more reliable on Linux", ) -def test_import_time(pytester: pytest.Pytester) -> None: +def test_import_time(request: pytest.FixtureRequest, pytester: pytest.Pytester) -> None: """Check that importing aiohttp doesn't take too long. Obviously, the time may vary on different machines and may need to be adjusted from time to time, but this should provide an early warning if something is added that significantly increases import time. + + Threshold increases by _IMPORT_TIME_INCREMENT_PER_RERUN ms on each rerun + to account for CI variability. """ + base_threshold = ( + _IMPORT_TIME_THRESHOLD_PY312 + if sys.version_info >= (3, 12) + else _IMPORT_TIME_THRESHOLD_DEFAULT + ) + expected_time = get_flaky_threshold( + request, base_threshold, _IMPORT_TIME_INCREMENT_PER_RERUN + ) + root = Path(__file__).parent.parent old_path = os.environ.get("PYTHONPATH") os.environ["PYTHONPATH"] = os.pathsep.join([str(root)] + sys.path) - best_time_ms = 1000 cmd = "import timeit; print(int(timeit.timeit('import aiohttp', number=1) * 1000))" try: - for _ in range(3): - r = pytester.run(sys.executable, "-We", "-c", cmd) - - assert not r.stderr.str(), r.stderr.str() - runtime_ms = int(r.stdout.str()) - if runtime_ms < best_time_ms: - best_time_ms = runtime_ms + r = pytester.run(sys.executable, "-We", "-c", cmd) + assert not r.stderr.str(), r.stderr.str() + runtime_ms = int(r.stdout.str()) finally: if old_path is None: os.environ.pop("PYTHONPATH") else: os.environ["PYTHONPATH"] = old_path - expected_time = _get_import_time_threshold() - assert best_time_ms < expected_time + assert runtime_ms < expected_time From 7b58495dea024843cf7ce355b6ecfc0945054fbd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 25 Jan 2026 16:57:31 +0000 Subject: [PATCH 05/30] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_client_middleware_digest_auth.py | 3 +-- tests/test_imports.py | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_client_middleware_digest_auth.py b/tests/test_client_middleware_digest_auth.py index 5e3e8c6d87e..7f70fc4b273 100644 --- a/tests/test_client_middleware_digest_auth.py +++ b/tests/test_client_middleware_digest_auth.py @@ -24,9 +24,8 @@ ) from aiohttp.client_reqrep import ClientResponse from aiohttp.payload import BytesIOPayload -from aiohttp.pytest_plugin import AiohttpServer +from aiohttp.pytest_plugin import AiohttpServer, get_flaky_threshold from aiohttp.web import Application, Request, Response -from aiohttp.pytest_plugin import get_flaky_threshold @pytest.fixture diff --git a/tests/test_imports.py b/tests/test_imports.py index 53714dc10c1..b1993904bf9 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -2,7 +2,9 @@ import platform import sys from pathlib import Path + import pytest + from aiohttp.pytest_plugin import get_flaky_threshold From afea84b3806376260afa83ffcba316910062f773 Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Mon, 26 Jan 2026 13:25:40 -0300 Subject: [PATCH 06/30] Fix socket leaks in TestShutdown suite for Windows CI --- tests/test_run_app.py | 43 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/tests/test_run_app.py b/tests/test_run_app.py index dab36942e49..24a74fddbf1 100644 --- a/tests/test_run_app.py +++ b/tests/test_run_app.py @@ -1219,13 +1219,15 @@ def test_shutdown_close_idle_keepalive( sock = unused_port_socket port = sock.getsockname()[1] t = None + sess_holder: list[ClientSession] = [] async def test() -> None: await asyncio.sleep(1) - async with ClientSession() as sess: + sess = ClientSession() + sess_holder.append(sess) + async with sess: async with sess.get(f"http://127.0.0.1:{port}/stop"): pass - # Hold on to keep-alive connection. await asyncio.sleep(5) @@ -1233,9 +1235,19 @@ async def run_test(app: web.Application) -> AsyncIterator[None]: nonlocal t t = asyncio.create_task(test()) yield - t.cancel() - with contextlib.suppress(asyncio.CancelledError): - await t + try: + t.cancel() + with contextlib.suppress(asyncio.CancelledError): + await t + finally: + if sys.platform == "win32": + # On Windows, explicitly close the session and yield to the + # event loop to mitigate resource warnings related to unclosed + # sockets, which are more common due to ProactorEventLoop + # timing issues. + for sess in sess_holder: + await sess.close() + await asyncio.sleep(0.1) app = web.Application() app.cleanup_ctx.append(run_test) @@ -1268,9 +1280,13 @@ async def close_websockets(app: web.Application) -> None: for ws in app[WS]: await ws.close(code=WSCloseCode.GOING_AWAY) + sess_holder: list[ClientSession] = [] + async def test() -> None: await asyncio.sleep(1) - async with ClientSession() as sess: + sess = ClientSession() + sess_holder.append(sess) + async with sess: async with sess.ws_connect(f"http://127.0.0.1:{port}/ws") as ws: async with sess.get(f"http://127.0.0.1:{port}/stop"): pass @@ -1285,9 +1301,18 @@ async def run_test(app: web.Application) -> AsyncIterator[None]: t = asyncio.create_task(test()) yield await asyncio.sleep(0) # In case test() hasn't resumed yet. - t.cancel() - with contextlib.suppress(asyncio.CancelledError): - await t + try: + try: + await asyncio.wait_for(t, timeout=3.0) + except asyncio.TimeoutError: + t.cancel() + with contextlib.suppress(asyncio.CancelledError): + await t + finally: + if sys.platform == "win32": + for sess in sess_holder: + await sess.close() + await asyncio.sleep(0.1) app = web.Application() app[WS] = set() From 55e7368d9a5ac0e7b563980669f8de94111437d3 Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Mon, 26 Jan 2026 13:50:55 -0300 Subject: [PATCH 07/30] reverting the windows socket handling. The scope might be growing too much --- tests/test_run_app.py | 43 +++++++++---------------------------------- 1 file changed, 9 insertions(+), 34 deletions(-) diff --git a/tests/test_run_app.py b/tests/test_run_app.py index 24a74fddbf1..dab36942e49 100644 --- a/tests/test_run_app.py +++ b/tests/test_run_app.py @@ -1219,15 +1219,13 @@ def test_shutdown_close_idle_keepalive( sock = unused_port_socket port = sock.getsockname()[1] t = None - sess_holder: list[ClientSession] = [] async def test() -> None: await asyncio.sleep(1) - sess = ClientSession() - sess_holder.append(sess) - async with sess: + async with ClientSession() as sess: async with sess.get(f"http://127.0.0.1:{port}/stop"): pass + # Hold on to keep-alive connection. await asyncio.sleep(5) @@ -1235,19 +1233,9 @@ async def run_test(app: web.Application) -> AsyncIterator[None]: nonlocal t t = asyncio.create_task(test()) yield - try: - t.cancel() - with contextlib.suppress(asyncio.CancelledError): - await t - finally: - if sys.platform == "win32": - # On Windows, explicitly close the session and yield to the - # event loop to mitigate resource warnings related to unclosed - # sockets, which are more common due to ProactorEventLoop - # timing issues. - for sess in sess_holder: - await sess.close() - await asyncio.sleep(0.1) + t.cancel() + with contextlib.suppress(asyncio.CancelledError): + await t app = web.Application() app.cleanup_ctx.append(run_test) @@ -1280,13 +1268,9 @@ async def close_websockets(app: web.Application) -> None: for ws in app[WS]: await ws.close(code=WSCloseCode.GOING_AWAY) - sess_holder: list[ClientSession] = [] - async def test() -> None: await asyncio.sleep(1) - sess = ClientSession() - sess_holder.append(sess) - async with sess: + async with ClientSession() as sess: async with sess.ws_connect(f"http://127.0.0.1:{port}/ws") as ws: async with sess.get(f"http://127.0.0.1:{port}/stop"): pass @@ -1301,18 +1285,9 @@ async def run_test(app: web.Application) -> AsyncIterator[None]: t = asyncio.create_task(test()) yield await asyncio.sleep(0) # In case test() hasn't resumed yet. - try: - try: - await asyncio.wait_for(t, timeout=3.0) - except asyncio.TimeoutError: - t.cancel() - with contextlib.suppress(asyncio.CancelledError): - await t - finally: - if sys.platform == "win32": - for sess in sess_holder: - await sess.close() - await asyncio.sleep(0.1) + t.cancel() + with contextlib.suppress(asyncio.CancelledError): + await t app = web.Application() app[WS] = set() From c81897fdd334d2c3aafc94b34264a5f855278803 Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Mon, 26 Jan 2026 18:46:53 -0300 Subject: [PATCH 08/30] Refactor get_flaky_threshold into rerun_adjusted_threshold fixture - Converted get_flaky_threshold function to a pytest fixture using indirect parametrization - Renamed to rerun_adjusted_threshold for clarity - Updated RST docstring with usage examples and rerun count logic - Added proper type annotations (tuple[float, float]) for mypy compliance - Updated test_imports.py and test_client_middleware_digest_auth.py to use new fixture - Improved code readability by splitting long assertion lines - Restored macOS timing observation comment (40-50ms) --- aiohttp/pytest_plugin.py | 29 +++++++++++---------- tests/test_client_middleware_digest_auth.py | 25 +++++++++--------- tests/test_imports.py | 25 +++++++++--------- 3 files changed, 40 insertions(+), 39 deletions(-) diff --git a/aiohttp/pytest_plugin.py b/aiohttp/pytest_plugin.py index f794bd3561e..44215a16c3d 100644 --- a/aiohttp/pytest_plugin.py +++ b/aiohttp/pytest_plugin.py @@ -64,21 +64,22 @@ def __call__( ) -> Awaitable[RawTestServer]: ... -def get_flaky_threshold( - request: pytest.FixtureRequest, - base: float, - increment: float, -) -> float: - """Calculate dynamic threshold for flaky tests based on rerun count. - - When using `@pytest.mark.flaky(reruns=N)`: - - execution_count is 1-based (1 for first run, 2 for first rerun, etc.) - - With reruns=3, the test runs up to 4 times (1 initial + 3 reruns) - - Returns base threshold on first run, incrementing by `increment` per rerun - - Example with reruns=3, base=20ms, increment=30ms: - Run 1: 20ms, Rerun 1: 50ms, Rerun 2: 80ms, Rerun 3: 110ms +@pytest.fixture +def rerun_adjusted_threshold(request: pytest.FixtureRequest) -> float: + """Calculate dynamic threshold based on rerun count (via indirect parametrization). + + Returns ``base + (rerun_count * increment)``. + The ``rerun_count`` is determined from ``pytest-rerunfailures`` (0 for initial run, + 1 for first rerun, etc.). + + Usage:: + + @pytest.mark.flaky(reruns=3) + @pytest.mark.parametrize("rerun_adjusted_threshold", [(20, 30)], indirect=True) + def test_timing(rerun_adjusted_threshold: float) -> None: ... """ + param: tuple[float, float] = request.param + base, increment = param execution_count: int = getattr(request.node, "execution_count", 0) rerun_count = max(0, execution_count - 1) return base + (rerun_count * increment) diff --git a/tests/test_client_middleware_digest_auth.py b/tests/test_client_middleware_digest_auth.py index 7f70fc4b273..0413e6cb226 100644 --- a/tests/test_client_middleware_digest_auth.py +++ b/tests/test_client_middleware_digest_auth.py @@ -24,7 +24,7 @@ ) from aiohttp.client_reqrep import ClientResponse from aiohttp.payload import BytesIOPayload -from aiohttp.pytest_plugin import AiohttpServer, get_flaky_threshold +from aiohttp.pytest_plugin import AiohttpServer from aiohttp.web import Application, Request, Response @@ -1331,27 +1331,28 @@ async def handler(request: Request) -> Response: assert auth_algorithms[0] == "MD5-sess" # Not "MD5-SESS" +_REGEX_TIME_THRESHOLD = (0.02, 0.03) # (base=20ms, increment=30ms) + + @pytest.mark.flaky(reruns=3) -def test_regex_performance(request: pytest.FixtureRequest) -> None: +@pytest.mark.parametrize( + "rerun_adjusted_threshold", [_REGEX_TIME_THRESHOLD], indirect=True +) +def test_regex_performance(rerun_adjusted_threshold: float) -> None: """Test that the regex pattern doesn't suffer from ReDoS issues. Threshold starts at 20ms and increases on each rerun for CI variability. """ - REGEX_TIME_THRESHOLD_DEFAULT = 0.02 # 20ms - REGEX_TIME_INCREMENT_PER_RERUN = 0.03 # 30ms - # CI/platform variability (e.g., macOS runners ~40-50ms observed) - threshold_ms = get_flaky_threshold( - request, REGEX_TIME_THRESHOLD_DEFAULT, REGEX_TIME_INCREMENT_PER_RERUN - ) - value = "0" * 54773 + "\\0=a" start = time.perf_counter() matches = _HEADER_PAIRS_PATTERN.findall(value) elapsed = time.perf_counter() - start - assert ( - elapsed < threshold_ms - ), f"Regex took {elapsed * 1000:.1f}ms, expected <{threshold_ms * 1000:.0f}ms - potential ReDoS issue" + # Relaxed for CI/platform variability (e.g., macOS runners ~40-50ms observed) + assert elapsed < rerun_adjusted_threshold, ( + f"Regex took {elapsed * 1000:.1f}ms, " + f"expected <{rerun_adjusted_threshold * 1000:.0f}ms - potential ReDoS issue" + ) assert not matches diff --git a/tests/test_imports.py b/tests/test_imports.py index b1993904bf9..11bdd416056 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -5,8 +5,6 @@ import pytest -from aiohttp.pytest_plugin import get_flaky_threshold - def test___all__(pytester: pytest.Pytester) -> None: """See https://github.com/aio-libs/aiohttp/issues/6197""" @@ -33,16 +31,26 @@ def test_web___all__(pytester: pytest.Pytester) -> None: _IMPORT_TIME_THRESHOLD_PY312 = 300 _IMPORT_TIME_THRESHOLD_DEFAULT = 200 _IMPORT_TIME_INCREMENT_PER_RERUN = 50 +_IMPORT_TIME_THRESHOLD = ( + (_IMPORT_TIME_THRESHOLD_PY312, _IMPORT_TIME_INCREMENT_PER_RERUN) + if sys.version_info >= (3, 12) + else (_IMPORT_TIME_THRESHOLD_DEFAULT, _IMPORT_TIME_INCREMENT_PER_RERUN) +) @pytest.mark.internal @pytest.mark.dev_mode @pytest.mark.flaky(reruns=3) +@pytest.mark.parametrize( + "rerun_adjusted_threshold", [_IMPORT_TIME_THRESHOLD], indirect=True +) @pytest.mark.skipif( not sys.platform.startswith("linux") or platform.python_implementation() == "PyPy", reason="Timing is more reliable on Linux", ) -def test_import_time(request: pytest.FixtureRequest, pytester: pytest.Pytester) -> None: +def test_import_time( + rerun_adjusted_threshold: float, pytester: pytest.Pytester +) -> None: """Check that importing aiohttp doesn't take too long. Obviously, the time may vary on different machines and may need to be adjusted @@ -52,15 +60,6 @@ def test_import_time(request: pytest.FixtureRequest, pytester: pytest.Pytester) Threshold increases by _IMPORT_TIME_INCREMENT_PER_RERUN ms on each rerun to account for CI variability. """ - base_threshold = ( - _IMPORT_TIME_THRESHOLD_PY312 - if sys.version_info >= (3, 12) - else _IMPORT_TIME_THRESHOLD_DEFAULT - ) - expected_time = get_flaky_threshold( - request, base_threshold, _IMPORT_TIME_INCREMENT_PER_RERUN - ) - root = Path(__file__).parent.parent old_path = os.environ.get("PYTHONPATH") os.environ["PYTHONPATH"] = os.pathsep.join([str(root)] + sys.path) @@ -76,4 +75,4 @@ def test_import_time(request: pytest.FixtureRequest, pytester: pytest.Pytester) else: os.environ["PYTHONPATH"] = old_path - assert runtime_ms < expected_time + assert runtime_ms < rerun_adjusted_threshold From 4019cac3ee511a2fe9c5f6746605d9112e258d96 Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Mon, 26 Jan 2026 20:19:07 -0300 Subject: [PATCH 09/30] Marking flaky tests to rerun --- tests/test_client_middleware_digest_auth.py | 2 +- tests/test_cookie_helpers.py | 23 ++++++++++++++++----- tests/test_web_request.py | 18 +++++++++++----- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/tests/test_client_middleware_digest_auth.py b/tests/test_client_middleware_digest_auth.py index 0413e6cb226..4d9cb286bb0 100644 --- a/tests/test_client_middleware_digest_auth.py +++ b/tests/test_client_middleware_digest_auth.py @@ -1331,7 +1331,7 @@ async def handler(request: Request) -> Response: assert auth_algorithms[0] == "MD5-sess" # Not "MD5-SESS" -_REGEX_TIME_THRESHOLD = (0.02, 0.03) # (base=20ms, increment=30ms) +_REGEX_TIME_THRESHOLD = (0.02, 0.02) # 20ms, +20ms each retry @pytest.mark.flaky(reruns=3) diff --git a/tests/test_cookie_helpers.py b/tests/test_cookie_helpers.py index 38a44972c09..eabaee59840 100644 --- a/tests/test_cookie_helpers.py +++ b/tests/test_cookie_helpers.py @@ -637,15 +637,28 @@ def test_cookie_pattern_matches_partitioned_attribute(test_string: str) -> None: assert match.group("key").lower() == "partitioned" -def test_cookie_pattern_performance() -> None: +_COOKIE_PATTERN_TIME_THRESHOLD = (0.02, 0.02) # 20ms, +20ms each retry + + +@pytest.mark.flaky(reruns=3) +@pytest.mark.parametrize( + "rerun_adjusted_threshold", [_COOKIE_PATTERN_TIME_THRESHOLD], indirect=True +) +def test_cookie_pattern_performance(rerun_adjusted_threshold: float) -> None: + """Test that the cookie pattern doesn't suffer from ReDoS issues. + + This test is marked as flaky because timing can vary on loaded CI machines. + CI failure observed: ~20ms on Windows. + """ value = "a" + "=" * 21651 + "\x00" start = time.perf_counter() match = helpers._COOKIE_PATTERN.match(value) - end = time.perf_counter() + elapsed = time.perf_counter() - start - # If this is taking more than 10ms, there's probably a performance/ReDoS issue. - assert (end - start) < 0.01 - # This example shouldn't produce a match either. + assert elapsed < rerun_adjusted_threshold, ( + f"Pattern took {elapsed * 1000:.1f}ms, " + f"expected <{rerun_adjusted_threshold * 1000:.0f}ms - potential ReDoS issue" + ) assert match is None diff --git a/tests/test_web_request.py b/tests/test_web_request.py index cd18a90015e..586cd99a2b4 100644 --- a/tests/test_web_request.py +++ b/tests/test_web_request.py @@ -600,15 +600,23 @@ def test_single_forwarded_header() -> None: assert req.forwarded[0]["proto"] == "identifier" -def test_forwarded_re_performance() -> None: +_FORWARDED_RE_TIME_THRESHOLD = (0.02, 0.02) # 20ms, +20ms each retry + + +@pytest.mark.flaky(reruns=3) +@pytest.mark.parametrize( + "rerun_adjusted_threshold", [_FORWARDED_RE_TIME_THRESHOLD], indirect=True +) +def test_forwarded_re_performance(rerun_adjusted_threshold: float) -> None: value = "{" + "f" * 54773 + "z\x00a=v" start = time.perf_counter() match = _FORWARDED_PAIR_RE.match(value) - end = time.perf_counter() + elapsed = time.perf_counter() - start - # If this is taking more than 10ms, there's probably a performance/ReDoS issue. - assert (end - start) < 0.01 - # This example shouldn't produce a match either. + assert elapsed < rerun_adjusted_threshold, ( + f"Regex took {elapsed * 1000:.1f}ms, " + f"expected <{rerun_adjusted_threshold * 1000:.0f}ms - potential ReDoS issue" + ) assert match is None From a0ba5e477523ab09433952ea04b40e448c98599d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 23:32:08 +0000 Subject: [PATCH 10/30] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_client_middleware_digest_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_client_middleware_digest_auth.py b/tests/test_client_middleware_digest_auth.py index 4d9cb286bb0..a4164ac3057 100644 --- a/tests/test_client_middleware_digest_auth.py +++ b/tests/test_client_middleware_digest_auth.py @@ -1331,7 +1331,7 @@ async def handler(request: Request) -> Response: assert auth_algorithms[0] == "MD5-sess" # Not "MD5-SESS" -_REGEX_TIME_THRESHOLD = (0.02, 0.02) # 20ms, +20ms each retry +_REGEX_TIME_THRESHOLD = (0.02, 0.02) # 20ms, +20ms each retry @pytest.mark.flaky(reruns=3) From 9c60f370db79827613ec5d54c3dd4b0c17ca41b1 Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Wed, 28 Jan 2026 14:48:32 -0300 Subject: [PATCH 11/30] Fix make_client_request fixture to prevent session leaks --- tests/conftest.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 80b94ffa50a..50bbee20067 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -431,13 +431,14 @@ async def make_client_request( loop: asyncio.AbstractEventLoop, ) -> AsyncIterator[Callable[[str, URL, Unpack[ClientRequestArgs]], ClientRequest]]: """Fixture to help creating test ClientRequest objects with defaults.""" - request = session = None + requests: list[ClientRequest] = [] + sessions: list[ClientSession] = [] def maker( method: str, url: URL, **kwargs: Unpack[ClientRequestArgs] ) -> ClientRequest: - nonlocal request, session session = ClientSession() + sessions.append(session) default_args: ClientRequestArgs = { "loop": loop, "params": {}, @@ -462,13 +463,14 @@ def maker( "server_hostname": None, } request = ClientRequest(method, url, **(default_args | kwargs)) + requests.append(request) return request yield maker - if request is not None: + for request in requests: await request._close() - assert session is not None + for session in sessions: await session.close() From 0b181b6e885cdd1c103abaa0bdb6bc377ac31052 Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Wed, 28 Jan 2026 18:58:23 -0300 Subject: [PATCH 12/30] fix: add Windows cleanup delay in secure_proxy_url fixture On Windows, proxy.py uses threaded mode which can leave sockets in a state where they haven't been fully released when GC runs during pytest cleanup. A short delay + explicit gc.collect() ensures the proxy threads finish cleanup before pytest's unraisableexception plugin collects warnings. --- tests/test_proxy_functional.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_proxy_functional.py b/tests/test_proxy_functional.py index dc30bd36f5c..5e7abca3539 100644 --- a/tests/test_proxy_functional.py +++ b/tests/test_proxy_functional.py @@ -89,6 +89,16 @@ def secure_proxy_url(tls_certificate_pem_path: str) -> Iterator[URL]: port=proxy_instance.flags.port, ) + # On Windows, proxy.py uses threaded mode which can leave sockets in + # a state where they haven't been fully released when GC runs during + # pytest cleanup. A short delay + explicit gc.collect() ensures the + # proxy threads finish cleanup before pytest's unraisableexception + # plugin collects warnings. + if os.name == "nt": + import time + import gc + time.sleep(0.1) + gc.collect() @pytest.fixture def web_server_endpoint_payload() -> str: From e5efc52aab69af5c4cfc938ea6ddb08b309dc4e4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 28 Jan 2026 21:59:46 +0000 Subject: [PATCH 13/30] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_proxy_functional.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_proxy_functional.py b/tests/test_proxy_functional.py index 5e7abca3539..8851b275ff0 100644 --- a/tests/test_proxy_functional.py +++ b/tests/test_proxy_functional.py @@ -95,11 +95,13 @@ def secure_proxy_url(tls_certificate_pem_path: str) -> Iterator[URL]: # proxy threads finish cleanup before pytest's unraisableexception # plugin collects warnings. if os.name == "nt": - import time import gc + import time + time.sleep(0.1) gc.collect() + @pytest.fixture def web_server_endpoint_payload() -> str: return str(uuid4()) From 1e8374a18520852a016b9a935c7b6aa2cb096904 Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Wed, 28 Jan 2026 19:15:15 -0300 Subject: [PATCH 14/30] fix: increase Windows cleanup delay to 0.5s with multiple gc passes The 0.1s delay was insufficient for proxy.py worker threads to release their sockets. Increased to 0.5s and added 3 gc.collect() passes to ensure all cyclic references are broken before pytest cleanup. --- tests/test_proxy_functional.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_proxy_functional.py b/tests/test_proxy_functional.py index 8851b275ff0..b3ced345032 100644 --- a/tests/test_proxy_functional.py +++ b/tests/test_proxy_functional.py @@ -91,14 +91,16 @@ def secure_proxy_url(tls_certificate_pem_path: str) -> Iterator[URL]: # On Windows, proxy.py uses threaded mode which can leave sockets in # a state where they haven't been fully released when GC runs during - # pytest cleanup. A short delay + explicit gc.collect() ensures the - # proxy threads finish cleanup before pytest's unraisableexception + # pytest cleanup. A longer delay + multiple gc.collect() passes ensures + # the proxy threads finish cleanup before pytest's unraisableexception # plugin collects warnings. if os.name == "nt": import gc import time - time.sleep(0.1) + time.sleep(0.5) + gc.collect() + gc.collect() gc.collect() From 6c1a4b1aad633be713ae06cbaefefb5dec7ef18b Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Wed, 28 Jan 2026 19:30:04 -0300 Subject: [PATCH 15/30] test: use extreme 5s delay to verify socket leak source --- tests/test_proxy_functional.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_proxy_functional.py b/tests/test_proxy_functional.py index b3ced345032..347251cea6a 100644 --- a/tests/test_proxy_functional.py +++ b/tests/test_proxy_functional.py @@ -94,11 +94,12 @@ def secure_proxy_url(tls_certificate_pem_path: str) -> Iterator[URL]: # pytest cleanup. A longer delay + multiple gc.collect() passes ensures # the proxy threads finish cleanup before pytest's unraisableexception # plugin collects warnings. + # TEMPORARY: Using 5 second delay to test if this is the source of leaks if os.name == "nt": import gc import time - time.sleep(0.5) + time.sleep(5) gc.collect() gc.collect() gc.collect() From e55b246ade1cabc45a19d87455fe1e2a85905c01 Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Wed, 28 Jan 2026 19:30:57 -0300 Subject: [PATCH 16/30] test: add delay after gc.collect() to test async finalization --- tests/test_proxy_functional.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_proxy_functional.py b/tests/test_proxy_functional.py index 347251cea6a..0d16e3188ad 100644 --- a/tests/test_proxy_functional.py +++ b/tests/test_proxy_functional.py @@ -94,7 +94,7 @@ def secure_proxy_url(tls_certificate_pem_path: str) -> Iterator[URL]: # pytest cleanup. A longer delay + multiple gc.collect() passes ensures # the proxy threads finish cleanup before pytest's unraisableexception # plugin collects warnings. - # TEMPORARY: Using 5 second delay to test if this is the source of leaks + # TEMPORARY: Testing with delays before AND after gc.collect() if os.name == "nt": import gc import time @@ -103,6 +103,7 @@ def secure_proxy_url(tls_certificate_pem_path: str) -> Iterator[URL]: gc.collect() gc.collect() gc.collect() + time.sleep(1) @pytest.fixture From e99fea235e7bad43d636626b24aee604e06ec93b Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Thu, 29 Jan 2026 07:43:10 -0300 Subject: [PATCH 17/30] test: use thread polling instead of fixed sleep for Windows cleanup Instead of waiting a fixed 5+ seconds, poll for proxy threads to finish with gc.collect() calls. This is faster on typical runs (exits as soon as threads are gone) while still having a 5s timeout for robustness. --- tests/test_proxy_functional.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/test_proxy_functional.py b/tests/test_proxy_functional.py index 0d16e3188ad..ba3d260254a 100644 --- a/tests/test_proxy_functional.py +++ b/tests/test_proxy_functional.py @@ -89,21 +89,21 @@ def secure_proxy_url(tls_certificate_pem_path: str) -> Iterator[URL]: port=proxy_instance.flags.port, ) - # On Windows, proxy.py uses threaded mode which can leave sockets in - # a state where they haven't been fully released when GC runs during - # pytest cleanup. A longer delay + multiple gc.collect() passes ensures - # the proxy threads finish cleanup before pytest's unraisableexception - # plugin collects warnings. - # TEMPORARY: Testing with delays before AND after gc.collect() if os.name == "nt": import gc + import threading import time - time.sleep(5) - gc.collect() - gc.collect() - gc.collect() - time.sleep(1) + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + gc.collect() + proxy_threads = [ + t for t in threading.enumerate() + if "proxy" in t.name.lower() or "acceptor" in t.name.lower() + ] + if not proxy_threads: + break + time.sleep(0.05) @pytest.fixture From 1787cdd753fd013fea8e92326c9f1be209f1c9ba Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 10:45:57 +0000 Subject: [PATCH 18/30] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_proxy_functional.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_proxy_functional.py b/tests/test_proxy_functional.py index ba3d260254a..660a5200f1d 100644 --- a/tests/test_proxy_functional.py +++ b/tests/test_proxy_functional.py @@ -98,7 +98,8 @@ def secure_proxy_url(tls_certificate_pem_path: str) -> Iterator[URL]: while time.monotonic() < deadline: gc.collect() proxy_threads = [ - t for t in threading.enumerate() + t + for t in threading.enumerate() if "proxy" in t.name.lower() or "acceptor" in t.name.lower() ] if not proxy_threads: From f3c5e29f320568598b885182e04b40c856066df7 Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Thu, 29 Jan 2026 08:00:18 -0300 Subject: [PATCH 19/30] test: use baseline thread detection for Windows cleanup Instead of matching thread names (which was unreliable), capture the baseline set of threads before starting proxy.py and wait until all extra threads have finished. --- tests/test_proxy_functional.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/test_proxy_functional.py b/tests/test_proxy_functional.py index 660a5200f1d..4cef32a2edf 100644 --- a/tests/test_proxy_functional.py +++ b/tests/test_proxy_functional.py @@ -66,6 +66,13 @@ def secure_proxy_url(tls_certificate_pem_path: str) -> Iterator[URL]: This fixture also spawns that instance and tears it down after the test. """ + if os.name == "nt": + import gc + import threading + import time + + baseline_threads = set(threading.enumerate()) + proxypy_args = [ # --threadless does not work on windows, see # https://github.com/abhinavsingh/proxy.py/issues/492 @@ -83,6 +90,9 @@ def secure_proxy_url(tls_certificate_pem_path: str) -> Iterator[URL]: ] with proxy.Proxy(input_args=proxypy_args) as proxy_instance: + if os.name == "nt": + spawned_threads = set(threading.enumerate()) - baseline_threads + yield URL.build( scheme="https", host=str(proxy_instance.flags.hostname), @@ -90,19 +100,11 @@ def secure_proxy_url(tls_certificate_pem_path: str) -> Iterator[URL]: ) if os.name == "nt": - import gc - import threading - import time - deadline = time.monotonic() + 5.0 while time.monotonic() < deadline: gc.collect() - proxy_threads = [ - t - for t in threading.enumerate() - if "proxy" in t.name.lower() or "acceptor" in t.name.lower() - ] - if not proxy_threads: + remaining = set(threading.enumerate()).intersection(spawned_threads) + if not remaining: break time.sleep(0.05) From 859da2ddbb9b2c0b3a1a69c54b0213e7e5be4258 Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Thu, 29 Jan 2026 08:41:34 -0300 Subject: [PATCH 20/30] fix: Improve thread cleanup in proxy test fixture by simplifying thread tracking and adding post-loop garbage collection. --- tests/test_proxy_functional.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_proxy_functional.py b/tests/test_proxy_functional.py index 4cef32a2edf..8f9709c390a 100644 --- a/tests/test_proxy_functional.py +++ b/tests/test_proxy_functional.py @@ -90,9 +90,6 @@ def secure_proxy_url(tls_certificate_pem_path: str) -> Iterator[URL]: ] with proxy.Proxy(input_args=proxypy_args) as proxy_instance: - if os.name == "nt": - spawned_threads = set(threading.enumerate()) - baseline_threads - yield URL.build( scheme="https", host=str(proxy_instance.flags.hostname), @@ -103,10 +100,13 @@ def secure_proxy_url(tls_certificate_pem_path: str) -> Iterator[URL]: deadline = time.monotonic() + 5.0 while time.monotonic() < deadline: gc.collect() - remaining = set(threading.enumerate()).intersection(spawned_threads) - if not remaining: + new_threads = set(threading.enumerate()) - baseline_threads + if not new_threads: break time.sleep(0.05) + for _ in range(3): + gc.collect() + time.sleep(0.1) @pytest.fixture From 187b072476691897f931320b53f71bad82f5c8a2 Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Thu, 29 Jan 2026 21:07:28 -0300 Subject: [PATCH 21/30] Add Windows socket warning filter for Py3.10/3.11 --- tests/conftest.py | 7 +++++++ tests/test_proxy_functional.py | 19 ------------------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 50bbee20067..e97334eb15a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -49,6 +49,13 @@ TRUSTME = False +def pytest_configure(config: pytest.Config) -> None: + if os.name == "nt" and sys.version_info[:2] in ((3, 10), (3, 11)): + config.addinivalue_line( + "filterwarnings", + "ignore:Exception ignored in.*socket.*:pytest.PytestUnraisableExceptionWarning", + ) + try: if sys.platform == "win32": import winloop as uvloop diff --git a/tests/test_proxy_functional.py b/tests/test_proxy_functional.py index 8f9709c390a..dc30bd36f5c 100644 --- a/tests/test_proxy_functional.py +++ b/tests/test_proxy_functional.py @@ -66,13 +66,6 @@ def secure_proxy_url(tls_certificate_pem_path: str) -> Iterator[URL]: This fixture also spawns that instance and tears it down after the test. """ - if os.name == "nt": - import gc - import threading - import time - - baseline_threads = set(threading.enumerate()) - proxypy_args = [ # --threadless does not work on windows, see # https://github.com/abhinavsingh/proxy.py/issues/492 @@ -96,18 +89,6 @@ def secure_proxy_url(tls_certificate_pem_path: str) -> Iterator[URL]: port=proxy_instance.flags.port, ) - if os.name == "nt": - deadline = time.monotonic() + 5.0 - while time.monotonic() < deadline: - gc.collect() - new_threads = set(threading.enumerate()) - baseline_threads - if not new_threads: - break - time.sleep(0.05) - for _ in range(3): - gc.collect() - time.sleep(0.1) - @pytest.fixture def web_server_endpoint_payload() -> str: From 8e33e20323f6acb8dece0b3fc6ed959747b95cbb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 00:15:43 +0000 Subject: [PATCH 22/30] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/conftest.py b/tests/conftest.py index e97334eb15a..4e83b25e3cc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,6 +56,7 @@ def pytest_configure(config: pytest.Config) -> None: "ignore:Exception ignored in.*socket.*:pytest.PytestUnraisableExceptionWarning", ) + try: if sys.platform == "win32": import winloop as uvloop From dbea72509ce9432cadbeb974887c3731abb2d7a3 Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Fri, 30 Jan 2026 10:29:58 -0300 Subject: [PATCH 23/30] refactor: introduce RerunThresholdParams NamedTuple for performance test thresholds Replaces tuple-based threshold parameters with a self-documenting RerunThresholdParams NamedTuple containing 'base' and 'increment_per_rerun' fields for improved readability and maintainability. --- aiohttp/pytest_plugin.py | 22 +++++++++++++++------ tests/test_client_middleware_digest_auth.py | 4 ++-- tests/test_cookie_helpers.py | 5 ++++- tests/test_imports.py | 13 ++++++++---- tests/test_web_request.py | 4 ++-- 5 files changed, 33 insertions(+), 15 deletions(-) diff --git a/aiohttp/pytest_plugin.py b/aiohttp/pytest_plugin.py index 44215a16c3d..3afba4f3f67 100644 --- a/aiohttp/pytest_plugin.py +++ b/aiohttp/pytest_plugin.py @@ -3,7 +3,7 @@ import inspect import warnings from collections.abc import Awaitable, Callable, Iterator -from typing import Any, Protocol, TypeVar, overload +from typing import Any, NamedTuple, Protocol, TypeVar, overload import pytest @@ -64,25 +64,35 @@ def __call__( ) -> Awaitable[RawTestServer]: ... +class RerunThresholdParams(NamedTuple): + """Parameters for dynamic threshold calculation in flaky tests.""" + + base: float + increment_per_rerun: float + + @pytest.fixture def rerun_adjusted_threshold(request: pytest.FixtureRequest) -> float: """Calculate dynamic threshold based on rerun count (via indirect parametrization). - Returns ``base + (rerun_count * increment)``. + Returns ``base + (rerun_count * increment_per_rerun)``. The ``rerun_count`` is determined from ``pytest-rerunfailures`` (0 for initial run, 1 for first rerun, etc.). Usage:: @pytest.mark.flaky(reruns=3) - @pytest.mark.parametrize("rerun_adjusted_threshold", [(20, 30)], indirect=True) + @pytest.mark.parametrize( + "rerun_adjusted_threshold", + [RerunThresholdParams(base=0.02, increment_per_rerun=0.02)], + indirect=True, + ) def test_timing(rerun_adjusted_threshold: float) -> None: ... """ - param: tuple[float, float] = request.param - base, increment = param + param: RerunThresholdParams = request.param execution_count: int = getattr(request.node, "execution_count", 0) rerun_count = max(0, execution_count - 1) - return base + (rerun_count * increment) + return param.base + (rerun_count * param.increment_per_rerun) def pytest_addoption(parser): # type: ignore[no-untyped-def] diff --git a/tests/test_client_middleware_digest_auth.py b/tests/test_client_middleware_digest_auth.py index a4164ac3057..f04016148ec 100644 --- a/tests/test_client_middleware_digest_auth.py +++ b/tests/test_client_middleware_digest_auth.py @@ -24,7 +24,7 @@ ) from aiohttp.client_reqrep import ClientResponse from aiohttp.payload import BytesIOPayload -from aiohttp.pytest_plugin import AiohttpServer +from aiohttp.pytest_plugin import AiohttpServer, RerunThresholdParams from aiohttp.web import Application, Request, Response @@ -1331,7 +1331,7 @@ async def handler(request: Request) -> Response: assert auth_algorithms[0] == "MD5-sess" # Not "MD5-SESS" -_REGEX_TIME_THRESHOLD = (0.02, 0.02) # 20ms, +20ms each retry +_REGEX_TIME_THRESHOLD = RerunThresholdParams(base=0.02, increment_per_rerun=0.02) @pytest.mark.flaky(reruns=3) diff --git a/tests/test_cookie_helpers.py b/tests/test_cookie_helpers.py index eabaee59840..9adfe627769 100644 --- a/tests/test_cookie_helpers.py +++ b/tests/test_cookie_helpers.py @@ -19,6 +19,7 @@ parse_set_cookie_headers, preserve_morsel_with_coded_value, ) +from aiohttp.pytest_plugin import RerunThresholdParams def test_known_attrs_is_superset_of_morsel_reserved() -> None: @@ -637,7 +638,9 @@ def test_cookie_pattern_matches_partitioned_attribute(test_string: str) -> None: assert match.group("key").lower() == "partitioned" -_COOKIE_PATTERN_TIME_THRESHOLD = (0.02, 0.02) # 20ms, +20ms each retry +_COOKIE_PATTERN_TIME_THRESHOLD = RerunThresholdParams( + base=0.02, increment_per_rerun=0.02 +) @pytest.mark.flaky(reruns=3) diff --git a/tests/test_imports.py b/tests/test_imports.py index 11bdd416056..5bd1746f79f 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -5,6 +5,8 @@ import pytest +from aiohttp.pytest_plugin import RerunThresholdParams + def test___all__(pytester: pytest.Pytester) -> None: """See https://github.com/aio-libs/aiohttp/issues/6197""" @@ -31,10 +33,13 @@ def test_web___all__(pytester: pytest.Pytester) -> None: _IMPORT_TIME_THRESHOLD_PY312 = 300 _IMPORT_TIME_THRESHOLD_DEFAULT = 200 _IMPORT_TIME_INCREMENT_PER_RERUN = 50 -_IMPORT_TIME_THRESHOLD = ( - (_IMPORT_TIME_THRESHOLD_PY312, _IMPORT_TIME_INCREMENT_PER_RERUN) - if sys.version_info >= (3, 12) - else (_IMPORT_TIME_THRESHOLD_DEFAULT, _IMPORT_TIME_INCREMENT_PER_RERUN) +_IMPORT_TIME_THRESHOLD = RerunThresholdParams( + base=( + _IMPORT_TIME_THRESHOLD_PY312 + if sys.version_info >= (3, 12) + else _IMPORT_TIME_THRESHOLD_DEFAULT + ), + increment_per_rerun=_IMPORT_TIME_INCREMENT_PER_RERUN, ) diff --git a/tests/test_web_request.py b/tests/test_web_request.py index 586cd99a2b4..392d8ee0077 100644 --- a/tests/test_web_request.py +++ b/tests/test_web_request.py @@ -17,7 +17,7 @@ from aiohttp import ETag, HttpVersion, web from aiohttp.base_protocol import BaseProtocol from aiohttp.http_parser import RawRequestMessage -from aiohttp.pytest_plugin import AiohttpClient +from aiohttp.pytest_plugin import AiohttpClient, RerunThresholdParams from aiohttp.streams import StreamReader from aiohttp.test_utils import make_mocked_request from aiohttp.web_request import _FORWARDED_PAIR_RE @@ -600,7 +600,7 @@ def test_single_forwarded_header() -> None: assert req.forwarded[0]["proto"] == "identifier" -_FORWARDED_RE_TIME_THRESHOLD = (0.02, 0.02) # 20ms, +20ms each retry +_FORWARDED_RE_TIME_THRESHOLD = RerunThresholdParams(base=0.02, increment_per_rerun=0.02) @pytest.mark.flaky(reruns=3) From 114fc76ac8206995bd9fd16f2e2bc0eb62c44491 Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Fri, 30 Jan 2026 12:01:03 -0300 Subject: [PATCH 24/30] refactor: use asyncio.gather() for parallel cleanup in make_request fixture --- tests/conftest.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4e83b25e3cc..2f98a0fc25b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -476,10 +476,10 @@ def maker( yield maker - for request in requests: - await request._close() - for session in sessions: - await session.close() + await asyncio.gather( + *[request._close() for request in requests], + *[session.close() for session in sessions], + ) @pytest.fixture From a07489e9720132e70e4d0df17410a1d3573292fe Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Sat, 7 Feb 2026 18:54:45 -0300 Subject: [PATCH 25/30] Remove dynamic thresholds and use fixed values for performance tests - Remove RerunThresholdParams and rerun_adjusted_threshold fixture - Set fixed thresholds: 400ms/300ms for import test, 80ms for regex tests - Remove pytest-rerunfailures dependency - Remove @pytest.mark.flaky decorators from performance tests The new fixed thresholds are set to the previous maximum values (after 3 reruns), providing generous headroom for CI variability while eliminating complexity. --- CHANGES/11992.contrib.rst | 5 +--- aiohttp/pytest_plugin.py | 33 +-------------------- requirements/test-common.in | 1 - tests/test_client_middleware_digest_auth.py | 23 +++++--------- tests/test_cookie_helpers.py | 23 +++++--------- tests/test_imports.py | 27 ++--------------- tests/test_web_request.py | 16 +++++----- 7 files changed, 27 insertions(+), 101 deletions(-) diff --git a/CHANGES/11992.contrib.rst b/CHANGES/11992.contrib.rst index 297278d6add..c56c2ab7059 100644 --- a/CHANGES/11992.contrib.rst +++ b/CHANGES/11992.contrib.rst @@ -1,4 +1 @@ -Fixed flaky import time test for Python 3.12+ -- by :user:`rodrigobnogueira`. - -Refactored to use version comparison instead of explicit version list, -making the test future-proof for new Python releases. +Fixed flaky performance tests by using appropriate fixed thresholds that account for CI variability -- by :user:`rodrigobnogueira`. diff --git a/aiohttp/pytest_plugin.py b/aiohttp/pytest_plugin.py index 3afba4f3f67..65f8dc8aa7f 100644 --- a/aiohttp/pytest_plugin.py +++ b/aiohttp/pytest_plugin.py @@ -3,7 +3,7 @@ import inspect import warnings from collections.abc import Awaitable, Callable, Iterator -from typing import Any, NamedTuple, Protocol, TypeVar, overload +from typing import Any, Protocol, TypeVar, overload import pytest @@ -64,37 +64,6 @@ def __call__( ) -> Awaitable[RawTestServer]: ... -class RerunThresholdParams(NamedTuple): - """Parameters for dynamic threshold calculation in flaky tests.""" - - base: float - increment_per_rerun: float - - -@pytest.fixture -def rerun_adjusted_threshold(request: pytest.FixtureRequest) -> float: - """Calculate dynamic threshold based on rerun count (via indirect parametrization). - - Returns ``base + (rerun_count * increment_per_rerun)``. - The ``rerun_count`` is determined from ``pytest-rerunfailures`` (0 for initial run, - 1 for first rerun, etc.). - - Usage:: - - @pytest.mark.flaky(reruns=3) - @pytest.mark.parametrize( - "rerun_adjusted_threshold", - [RerunThresholdParams(base=0.02, increment_per_rerun=0.02)], - indirect=True, - ) - def test_timing(rerun_adjusted_threshold: float) -> None: ... - """ - param: RerunThresholdParams = request.param - execution_count: int = getattr(request.node, "execution_count", 0) - rerun_count = max(0, execution_count - 1) - return param.base + (rerun_count * param.increment_per_rerun) - - def pytest_addoption(parser): # type: ignore[no-untyped-def] parser.addoption( "--aiohttp-fast", diff --git a/requirements/test-common.in b/requirements/test-common.in index db55c4b47d4..c010f61fa8a 100644 --- a/requirements/test-common.in +++ b/requirements/test-common.in @@ -8,7 +8,6 @@ proxy.py >= 2.4.4rc5 pytest pytest-cov pytest-mock -pytest-rerunfailures pytest-xdist pytest_codspeed python-on-whales diff --git a/tests/test_client_middleware_digest_auth.py b/tests/test_client_middleware_digest_auth.py index f04016148ec..3125a0e6719 100644 --- a/tests/test_client_middleware_digest_auth.py +++ b/tests/test_client_middleware_digest_auth.py @@ -24,7 +24,7 @@ ) from aiohttp.client_reqrep import ClientResponse from aiohttp.payload import BytesIOPayload -from aiohttp.pytest_plugin import AiohttpServer, RerunThresholdParams +from aiohttp.pytest_plugin import AiohttpServer from aiohttp.web import Application, Request, Response @@ -1331,28 +1331,21 @@ async def handler(request: Request) -> Response: assert auth_algorithms[0] == "MD5-sess" # Not "MD5-SESS" -_REGEX_TIME_THRESHOLD = RerunThresholdParams(base=0.02, increment_per_rerun=0.02) +_REGEX_TIME_THRESHOLD_SECONDS = 0.08 -@pytest.mark.flaky(reruns=3) -@pytest.mark.parametrize( - "rerun_adjusted_threshold", [_REGEX_TIME_THRESHOLD], indirect=True -) -def test_regex_performance(rerun_adjusted_threshold: float) -> None: - """Test that the regex pattern doesn't suffer from ReDoS issues. - - Threshold starts at 20ms and increases on each rerun for CI variability. - """ +def test_regex_performance() -> None: + """Test that the regex pattern doesn't suffer from ReDoS issues.""" value = "0" * 54773 + "\\0=a" start = time.perf_counter() matches = _HEADER_PAIRS_PATTERN.findall(value) elapsed = time.perf_counter() - start - # Relaxed for CI/platform variability (e.g., macOS runners ~40-50ms observed) - assert elapsed < rerun_adjusted_threshold, ( + # If this is taking more time, there's probably a performance/ReDoS issue. + assert elapsed < _REGEX_TIME_THRESHOLD_SECONDS, ( f"Regex took {elapsed * 1000:.1f}ms, " - f"expected <{rerun_adjusted_threshold * 1000:.0f}ms - potential ReDoS issue" + f"expected <{_REGEX_TIME_THRESHOLD_SECONDS * 1000:.0f}ms - potential ReDoS issue" ) - + # This example shouldn't produce a match either. assert not matches diff --git a/tests/test_cookie_helpers.py b/tests/test_cookie_helpers.py index 9adfe627769..d03c5f69295 100644 --- a/tests/test_cookie_helpers.py +++ b/tests/test_cookie_helpers.py @@ -19,7 +19,6 @@ parse_set_cookie_headers, preserve_morsel_with_coded_value, ) -from aiohttp.pytest_plugin import RerunThresholdParams def test_known_attrs_is_superset_of_morsel_reserved() -> None: @@ -638,30 +637,22 @@ def test_cookie_pattern_matches_partitioned_attribute(test_string: str) -> None: assert match.group("key").lower() == "partitioned" -_COOKIE_PATTERN_TIME_THRESHOLD = RerunThresholdParams( - base=0.02, increment_per_rerun=0.02 -) +_COOKIE_PATTERN_TIME_THRESHOLD_SECONDS = 0.08 -@pytest.mark.flaky(reruns=3) -@pytest.mark.parametrize( - "rerun_adjusted_threshold", [_COOKIE_PATTERN_TIME_THRESHOLD], indirect=True -) -def test_cookie_pattern_performance(rerun_adjusted_threshold: float) -> None: - """Test that the cookie pattern doesn't suffer from ReDoS issues. - - This test is marked as flaky because timing can vary on loaded CI machines. - CI failure observed: ~20ms on Windows. - """ +def test_cookie_pattern_performance() -> None: + """Test that the cookie pattern doesn't suffer from ReDoS issues.""" value = "a" + "=" * 21651 + "\x00" start = time.perf_counter() match = helpers._COOKIE_PATTERN.match(value) elapsed = time.perf_counter() - start - assert elapsed < rerun_adjusted_threshold, ( + # If this is taking more time, there's probably a performance/ReDoS issue. + assert elapsed < _COOKIE_PATTERN_TIME_THRESHOLD_SECONDS, ( f"Pattern took {elapsed * 1000:.1f}ms, " - f"expected <{rerun_adjusted_threshold * 1000:.0f}ms - potential ReDoS issue" + f"expected <{_COOKIE_PATTERN_TIME_THRESHOLD_SECONDS * 1000:.0f}ms - potential ReDoS issue" ) + # This example shouldn't produce a match either. assert match is None diff --git a/tests/test_imports.py b/tests/test_imports.py index 5bd1746f79f..7f3f49720c9 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -5,8 +5,6 @@ import pytest -from aiohttp.pytest_plugin import RerunThresholdParams - def test___all__(pytester: pytest.Pytester) -> None: """See https://github.com/aio-libs/aiohttp/issues/6197""" @@ -30,40 +28,21 @@ def test_web___all__(pytester: pytest.Pytester) -> None: result.assert_outcomes(passed=0, errors=0) -_IMPORT_TIME_THRESHOLD_PY312 = 300 -_IMPORT_TIME_THRESHOLD_DEFAULT = 200 -_IMPORT_TIME_INCREMENT_PER_RERUN = 50 -_IMPORT_TIME_THRESHOLD = RerunThresholdParams( - base=( - _IMPORT_TIME_THRESHOLD_PY312 - if sys.version_info >= (3, 12) - else _IMPORT_TIME_THRESHOLD_DEFAULT - ), - increment_per_rerun=_IMPORT_TIME_INCREMENT_PER_RERUN, -) +_IMPORT_TIME_THRESHOLD_MS = 400 if sys.version_info >= (3, 12) else 300 @pytest.mark.internal @pytest.mark.dev_mode -@pytest.mark.flaky(reruns=3) -@pytest.mark.parametrize( - "rerun_adjusted_threshold", [_IMPORT_TIME_THRESHOLD], indirect=True -) @pytest.mark.skipif( not sys.platform.startswith("linux") or platform.python_implementation() == "PyPy", reason="Timing is more reliable on Linux", ) -def test_import_time( - rerun_adjusted_threshold: float, pytester: pytest.Pytester -) -> None: +def test_import_time(pytester: pytest.Pytester) -> None: """Check that importing aiohttp doesn't take too long. Obviously, the time may vary on different machines and may need to be adjusted from time to time, but this should provide an early warning if something is added that significantly increases import time. - - Threshold increases by _IMPORT_TIME_INCREMENT_PER_RERUN ms on each rerun - to account for CI variability. """ root = Path(__file__).parent.parent old_path = os.environ.get("PYTHONPATH") @@ -80,4 +59,4 @@ def test_import_time( else: os.environ["PYTHONPATH"] = old_path - assert runtime_ms < rerun_adjusted_threshold + assert runtime_ms < _IMPORT_TIME_THRESHOLD_MS diff --git a/tests/test_web_request.py b/tests/test_web_request.py index 392d8ee0077..2861c3e99c3 100644 --- a/tests/test_web_request.py +++ b/tests/test_web_request.py @@ -17,7 +17,7 @@ from aiohttp import ETag, HttpVersion, web from aiohttp.base_protocol import BaseProtocol from aiohttp.http_parser import RawRequestMessage -from aiohttp.pytest_plugin import AiohttpClient, RerunThresholdParams +from aiohttp.pytest_plugin import AiohttpClient from aiohttp.streams import StreamReader from aiohttp.test_utils import make_mocked_request from aiohttp.web_request import _FORWARDED_PAIR_RE @@ -600,23 +600,21 @@ def test_single_forwarded_header() -> None: assert req.forwarded[0]["proto"] == "identifier" -_FORWARDED_RE_TIME_THRESHOLD = RerunThresholdParams(base=0.02, increment_per_rerun=0.02) +_FORWARDED_RE_TIME_THRESHOLD_SECONDS = 0.08 -@pytest.mark.flaky(reruns=3) -@pytest.mark.parametrize( - "rerun_adjusted_threshold", [_FORWARDED_RE_TIME_THRESHOLD], indirect=True -) -def test_forwarded_re_performance(rerun_adjusted_threshold: float) -> None: +def test_forwarded_re_performance() -> None: value = "{" + "f" * 54773 + "z\x00a=v" start = time.perf_counter() match = _FORWARDED_PAIR_RE.match(value) elapsed = time.perf_counter() - start - assert elapsed < rerun_adjusted_threshold, ( + # If this is taking more time, there's probably a performance/ReDoS issue. + assert elapsed < _FORWARDED_RE_TIME_THRESHOLD_SECONDS, ( f"Regex took {elapsed * 1000:.1f}ms, " - f"expected <{rerun_adjusted_threshold * 1000:.0f}ms - potential ReDoS issue" + f"expected <{_FORWARDED_RE_TIME_THRESHOLD_SECONDS * 1000:.0f}ms - potential ReDoS issue" ) + # This example shouldn't produce a match either. assert match is None From f781696ee8c018b744ff00254b26c6db4b396b1e Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Thu, 12 Feb 2026 08:03:41 -0300 Subject: [PATCH 26/30] fix: suppress unraisable exception warnings on Windows for Python 3.10/3.11 and refactor `asyncio.gather` calls to use generator expressions. --- tests/conftest.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2f98a0fc25b..4eee9cc91e8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -50,6 +50,10 @@ def pytest_configure(config: pytest.Config) -> None: + # On Windows with Python 3.10/3.11, proxy.py's threaded mode can leave + # sockets not fully released by the time pytest's unraisableexception + # plugin collects warnings during teardown. Suppress these warnings + # since they are not actionable and only affect older Python versions. if os.name == "nt" and sys.version_info[:2] in ((3, 10), (3, 11)): config.addinivalue_line( "filterwarnings", @@ -477,8 +481,8 @@ def maker( yield maker await asyncio.gather( - *[request._close() for request in requests], - *[session.close() for session in sessions], + *(request._close() for request in requests), + *(session.close() for session in sessions), ) From e5b3c1ea37861cd344a4cb803fa176dfb9c6f241 Mon Sep 17 00:00:00 2001 From: Rodrigo Nogueira Date: Sat, 14 Feb 2026 18:58:32 -0300 Subject: [PATCH 27/30] Update tests/conftest.py Co-authored-by: Sam Bull --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4eee9cc91e8..5a9c26628d2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -54,7 +54,7 @@ def pytest_configure(config: pytest.Config) -> None: # sockets not fully released by the time pytest's unraisableexception # plugin collects warnings during teardown. Suppress these warnings # since they are not actionable and only affect older Python versions. - if os.name == "nt" and sys.version_info[:2] in ((3, 10), (3, 11)): + if os.name == "nt" and sys.version_info < (3, 12): config.addinivalue_line( "filterwarnings", "ignore:Exception ignored in.*socket.*:pytest.PytestUnraisableExceptionWarning", From e2b107fb5c4cc30388c4e07c2f94c702a19d4ada Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Sat, 14 Feb 2026 19:27:15 -0300 Subject: [PATCH 28/30] Inline regex/threshold constants & restore test_import_time best-of-3 --- tests/test_client_middleware_digest_auth.py | 8 +++----- tests/test_cookie_helpers.py | 8 +++----- tests/test_imports.py | 16 +++++++++------- tests/test_web_request.py | 8 +++----- 4 files changed, 18 insertions(+), 22 deletions(-) diff --git a/tests/test_client_middleware_digest_auth.py b/tests/test_client_middleware_digest_auth.py index 3125a0e6719..52e6f97050a 100644 --- a/tests/test_client_middleware_digest_auth.py +++ b/tests/test_client_middleware_digest_auth.py @@ -1331,11 +1331,9 @@ async def handler(request: Request) -> Response: assert auth_algorithms[0] == "MD5-sess" # Not "MD5-SESS" -_REGEX_TIME_THRESHOLD_SECONDS = 0.08 - - def test_regex_performance() -> None: """Test that the regex pattern doesn't suffer from ReDoS issues.""" + REGEX_TIME_THRESHOLD_SECONDS = 0.08 value = "0" * 54773 + "\\0=a" start = time.perf_counter() @@ -1343,9 +1341,9 @@ def test_regex_performance() -> None: elapsed = time.perf_counter() - start # If this is taking more time, there's probably a performance/ReDoS issue. - assert elapsed < _REGEX_TIME_THRESHOLD_SECONDS, ( + assert elapsed < REGEX_TIME_THRESHOLD_SECONDS, ( f"Regex took {elapsed * 1000:.1f}ms, " - f"expected <{_REGEX_TIME_THRESHOLD_SECONDS * 1000:.0f}ms - potential ReDoS issue" + f"expected <{REGEX_TIME_THRESHOLD_SECONDS * 1000:.0f}ms - potential ReDoS issue" ) # This example shouldn't produce a match either. assert not matches diff --git a/tests/test_cookie_helpers.py b/tests/test_cookie_helpers.py index d03c5f69295..767e1eaa34a 100644 --- a/tests/test_cookie_helpers.py +++ b/tests/test_cookie_helpers.py @@ -637,20 +637,18 @@ def test_cookie_pattern_matches_partitioned_attribute(test_string: str) -> None: assert match.group("key").lower() == "partitioned" -_COOKIE_PATTERN_TIME_THRESHOLD_SECONDS = 0.08 - - def test_cookie_pattern_performance() -> None: """Test that the cookie pattern doesn't suffer from ReDoS issues.""" + COOKIE_PATTERN_TIME_THRESHOLD_SECONDS = 0.08 value = "a" + "=" * 21651 + "\x00" start = time.perf_counter() match = helpers._COOKIE_PATTERN.match(value) elapsed = time.perf_counter() - start # If this is taking more time, there's probably a performance/ReDoS issue. - assert elapsed < _COOKIE_PATTERN_TIME_THRESHOLD_SECONDS, ( + assert elapsed < COOKIE_PATTERN_TIME_THRESHOLD_SECONDS, ( f"Pattern took {elapsed * 1000:.1f}ms, " - f"expected <{_COOKIE_PATTERN_TIME_THRESHOLD_SECONDS * 1000:.0f}ms - potential ReDoS issue" + f"expected <{COOKIE_PATTERN_TIME_THRESHOLD_SECONDS * 1000:.0f}ms - potential ReDoS issue" ) # This example shouldn't produce a match either. assert match is None diff --git a/tests/test_imports.py b/tests/test_imports.py index 7f3f49720c9..791fe2ed915 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -28,9 +28,6 @@ def test_web___all__(pytester: pytest.Pytester) -> None: result.assert_outcomes(passed=0, errors=0) -_IMPORT_TIME_THRESHOLD_MS = 400 if sys.version_info >= (3, 12) else 300 - - @pytest.mark.internal @pytest.mark.dev_mode @pytest.mark.skipif( @@ -43,20 +40,25 @@ def test_import_time(pytester: pytest.Pytester) -> None: Obviously, the time may vary on different machines and may need to be adjusted from time to time, but this should provide an early warning if something is added that significantly increases import time. + + Runs 3 times and keeps the minimum time to reduce flakiness. """ + IMPORT_TIME_THRESHOLD_MS = 300 if sys.version_info >= (3, 12) else 200 + best_time_ms = 1000 root = Path(__file__).parent.parent old_path = os.environ.get("PYTHONPATH") os.environ["PYTHONPATH"] = os.pathsep.join([str(root)] + sys.path) cmd = "import timeit; print(int(timeit.timeit('import aiohttp', number=1) * 1000))" try: - r = pytester.run(sys.executable, "-We", "-c", cmd) - assert not r.stderr.str(), r.stderr.str() - runtime_ms = int(r.stdout.str()) + for _ in range(3): + r = pytester.run(sys.executable, "-We", "-c", cmd) + assert not r.stderr.str(), r.stderr.str() + best_time_ms = min(best_time_ms, int(r.stdout.str())) finally: if old_path is None: os.environ.pop("PYTHONPATH") else: os.environ["PYTHONPATH"] = old_path - assert runtime_ms < _IMPORT_TIME_THRESHOLD_MS + assert best_time_ms < IMPORT_TIME_THRESHOLD_MS diff --git a/tests/test_web_request.py b/tests/test_web_request.py index 2861c3e99c3..9dec08a7b5f 100644 --- a/tests/test_web_request.py +++ b/tests/test_web_request.py @@ -600,19 +600,17 @@ def test_single_forwarded_header() -> None: assert req.forwarded[0]["proto"] == "identifier" -_FORWARDED_RE_TIME_THRESHOLD_SECONDS = 0.08 - - def test_forwarded_re_performance() -> None: + FORWARDED_RE_TIME_THRESHOLD_SECONDS = 0.08 value = "{" + "f" * 54773 + "z\x00a=v" start = time.perf_counter() match = _FORWARDED_PAIR_RE.match(value) elapsed = time.perf_counter() - start # If this is taking more time, there's probably a performance/ReDoS issue. - assert elapsed < _FORWARDED_RE_TIME_THRESHOLD_SECONDS, ( + assert elapsed < FORWARDED_RE_TIME_THRESHOLD_SECONDS, ( f"Regex took {elapsed * 1000:.1f}ms, " - f"expected <{_FORWARDED_RE_TIME_THRESHOLD_SECONDS * 1000:.0f}ms - potential ReDoS issue" + f"expected <{FORWARDED_RE_TIME_THRESHOLD_SECONDS * 1000:.0f}ms - potential ReDoS issue" ) # This example shouldn't produce a match either. assert match is None From 27525141c9964677fc39de82823039561e5971ef Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Sat, 14 Feb 2026 19:57:20 -0300 Subject: [PATCH 29/30] Fix flaky test_uvloop_secure_https_proxy: use local server instead of example.com The test was connecting to real https://example.com through a local proxy, but client_ssl_ctx (built from ssl.create_default_context + trustme) depends on the system trust store for public CA verification. On macOS CI, the trust store may not include the required public CAs, causing CERTIFICATE_VERIFY_FAILED. Replace the external request with a local TestServer using trustme-issued certificates, matching the pattern of test_secure_https_proxy_absolute_path. --- tests/test_proxy_functional.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/test_proxy_functional.py b/tests/test_proxy_functional.py index dc30bd36f5c..7c92b840790 100644 --- a/tests/test_proxy_functional.py +++ b/tests/test_proxy_functional.py @@ -21,6 +21,7 @@ from aiohttp.client import _RequestOptions from aiohttp.client_exceptions import ClientConnectionError from aiohttp.pytest_plugin import AiohttpRawServer, AiohttpServer +from aiohttp.test_utils import TestServer ASYNCIO_SUPPORTS_TLS_IN_TLS = sys.version_info >= (3, 11) @@ -244,24 +245,34 @@ async def test_https_proxy_unsupported_tls_in_tls( # otherwise this test will fail because the proxy will die with an error. async def test_uvloop_secure_https_proxy( client_ssl_ctx: ssl.SSLContext, + ssl_ctx: ssl.SSLContext, secure_proxy_url: URL, uvloop_loop: asyncio.AbstractEventLoop, ) -> None: """Ensure HTTPS sites are accessible through a secure proxy without warning when using uvloop.""" + payload = str(uuid4()) + + async def handler(request: web.Request) -> web.Response: + return web.Response(text=payload) + + app = web.Application() + app.router.add_route("GET", "/", handler) + server = TestServer(app, host="127.0.0.1") + await server.start_server(ssl=ssl_ctx) + + url = URL.build(scheme="https", host=server.host, port=server.port) conn = aiohttp.TCPConnector(force_close=True) sess = aiohttp.ClientSession(connector=conn) try: - url = URL("https://example.com") - async with sess.get( url, proxy=secure_proxy_url, ssl=client_ssl_ctx ) as response: assert response.status == 200 - # Ensure response body is read to completion - await response.read() + assert await response.text() == payload finally: await sess.close() await conn.close() + await server.close() await asyncio.sleep(0) await asyncio.sleep(0.1) From cbc302106c9686f225be9b2395b94085908942c1 Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Sat, 14 Feb 2026 20:14:07 -0300 Subject: [PATCH 30/30] refactor: move `best_time_ms` initialization after `PYTHONPATH` setup in import test. --- tests/test_imports.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_imports.py b/tests/test_imports.py index 791fe2ed915..0d220a656ed 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -44,11 +44,11 @@ def test_import_time(pytester: pytest.Pytester) -> None: Runs 3 times and keeps the minimum time to reduce flakiness. """ IMPORT_TIME_THRESHOLD_MS = 300 if sys.version_info >= (3, 12) else 200 - best_time_ms = 1000 root = Path(__file__).parent.parent old_path = os.environ.get("PYTHONPATH") os.environ["PYTHONPATH"] = os.pathsep.join([str(root)] + sys.path) + best_time_ms = 1000 cmd = "import timeit; print(int(timeit.timeit('import aiohttp', number=1) * 1000))" try: for _ in range(3):