From ad87cfabd06c7752b67d19f713889a81eb0c6aca Mon Sep 17 00:00:00 2001 From: BabyChrist666 Date: Mon, 16 Feb 2026 23:32:52 -0500 Subject: [PATCH 1/2] fix: add HTTP readiness check to wait_for_server and remove dead code (#1777) The flaky SSE tests (test_sse_client_basic_connection_mounted_app, test_request_context_isolation) fail intermittently because wait_for_server() only checks TCP port connectivity. On slow CI machines, the port may accept connections before the ASGI app is fully initialized, causing SSE requests to fail. - Add a two-stage readiness check to wait_for_server(): first TCP connect, then an actual HTTP request to verify the app is handling requests (any HTTP response, even 404, confirms readiness) - Remove unreachable dead code after blocking server.run() calls in run_server() and run_mounted_server() - Remove unused `time` import from test_sse.py Co-Authored-By: Claude Opus 4.6 --- tests/shared/test_sse.py | 11 ----------- tests/test_helpers.py | 38 +++++++++++++++++++++++++++++++------- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/tests/shared/test_sse.py b/tests/shared/test_sse.py index 207364cdc..7b2bc0a13 100644 --- a/tests/shared/test_sse.py +++ b/tests/shared/test_sse.py @@ -1,7 +1,6 @@ import json import multiprocessing import socket -import time from collections.abc import AsyncGenerator, Generator from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch @@ -134,11 +133,6 @@ def run_server(server_port: int) -> None: # pragma: no cover print(f"starting server on {server_port}") server.run() - # Give server time to start - while not server.started: - print("waiting for server to start") - time.sleep(0.5) - @pytest.fixture() def server(server_port: int) -> Generator[None, None, None]: @@ -313,11 +307,6 @@ def run_mounted_server(server_port: int) -> None: # pragma: no cover print(f"starting server on {server_port}") server.run() - # Give server time to start - while not server.started: - print("waiting for server to start") - time.sleep(0.5) - @pytest.fixture() def mounted_server(server_port: int) -> Generator[None, None, None]: diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 5c04c269f..41417aa72 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -2,30 +2,54 @@ import socket import time +import urllib.error +import urllib.request def wait_for_server(port: int, timeout: float = 20.0) -> None: """Wait for server to be ready to accept connections. - Polls the server port until it accepts connections or timeout is reached. - This eliminates race conditions without arbitrary sleeps. + First polls until the TCP port accepts connections, then verifies the + HTTP server is actually ready to handle requests. This two-stage check + prevents race conditions where the port is open but the ASGI app hasn't + finished initializing. Args: port: The port number to check - timeout: Maximum time to wait in seconds (default 5.0) + timeout: Maximum time to wait in seconds (default 20.0) Raises: TimeoutError: If server doesn't start within the timeout period """ start_time = time.time() + + # Stage 1: Wait for TCP port to accept connections while time.time() - start_time < timeout: try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.settimeout(0.1) s.connect(("127.0.0.1", port)) - # Server is ready - return + break # Port is open, move to stage 2 except (ConnectionRefusedError, OSError): - # Server not ready yet, retry quickly time.sleep(0.01) - raise TimeoutError(f"Server on port {port} did not start within {timeout} seconds") # pragma: no cover + else: + raise TimeoutError(f"Server on port {port} did not start within {timeout} seconds") # pragma: no cover + + # Stage 2: Verify HTTP server is ready by making a request. + # A non-existent path returns 404/405 if the app is ready, or + # raises an error if the ASGI app hasn't finished initializing. + while time.time() - start_time < timeout: + try: + req = urllib.request.Request( + f"http://127.0.0.1:{port}/healthz", + method="GET", + ) + with urllib.request.urlopen(req, timeout=1): + return # Any successful response means server is ready + except urllib.error.HTTPError: + # 404/405/etc means the server IS handling requests + return + except (urllib.error.URLError, ConnectionError, OSError): + # Server not ready for HTTP yet + time.sleep(0.05) + raise TimeoutError(f"Server on port {port} did not become HTTP-ready within {timeout} seconds") # pragma: no cover From 2342188cf52b68ae732d2862350a2fc97532ec42 Mon Sep 17 00:00:00 2001 From: BabyChrist666 Date: Mon, 16 Feb 2026 23:43:58 -0500 Subject: [PATCH 2/2] Revert wait_for_server HTTP check; also remove dead code in test_ws.py The HTTP health check in wait_for_server() caused false-positive readiness signals for mounted Starlette apps (404 from root vs 404 from uninitialized routes are indistinguishable). Revert to TCP-only polling which matches the original behavior. Also remove the same dead-code pattern from test_ws.py (unreachable loop after blocking server.run()). Co-Authored-By: Claude Opus 4.6 --- tests/shared/test_ws.py | 6 ------ tests/test_helpers.py | 38 +++++++------------------------------- 2 files changed, 7 insertions(+), 37 deletions(-) diff --git a/tests/shared/test_ws.py b/tests/shared/test_ws.py index d82850529..9addb661d 100644 --- a/tests/shared/test_ws.py +++ b/tests/shared/test_ws.py @@ -1,6 +1,5 @@ import multiprocessing import socket -import time from collections.abc import AsyncGenerator, Generator from urllib.parse import urlparse @@ -114,11 +113,6 @@ def run_server(server_port: int) -> None: # pragma: no cover print(f"starting server on {server_port}") server.run() - # Give server time to start - while not server.started: - print("waiting for server to start") - time.sleep(0.5) - @pytest.fixture() def server(server_port: int) -> Generator[None, None, None]: diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 41417aa72..5c04c269f 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -2,54 +2,30 @@ import socket import time -import urllib.error -import urllib.request def wait_for_server(port: int, timeout: float = 20.0) -> None: """Wait for server to be ready to accept connections. - First polls until the TCP port accepts connections, then verifies the - HTTP server is actually ready to handle requests. This two-stage check - prevents race conditions where the port is open but the ASGI app hasn't - finished initializing. + Polls the server port until it accepts connections or timeout is reached. + This eliminates race conditions without arbitrary sleeps. Args: port: The port number to check - timeout: Maximum time to wait in seconds (default 20.0) + timeout: Maximum time to wait in seconds (default 5.0) Raises: TimeoutError: If server doesn't start within the timeout period """ start_time = time.time() - - # Stage 1: Wait for TCP port to accept connections while time.time() - start_time < timeout: try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.settimeout(0.1) s.connect(("127.0.0.1", port)) - break # Port is open, move to stage 2 + # Server is ready + return except (ConnectionRefusedError, OSError): + # Server not ready yet, retry quickly time.sleep(0.01) - else: - raise TimeoutError(f"Server on port {port} did not start within {timeout} seconds") # pragma: no cover - - # Stage 2: Verify HTTP server is ready by making a request. - # A non-existent path returns 404/405 if the app is ready, or - # raises an error if the ASGI app hasn't finished initializing. - while time.time() - start_time < timeout: - try: - req = urllib.request.Request( - f"http://127.0.0.1:{port}/healthz", - method="GET", - ) - with urllib.request.urlopen(req, timeout=1): - return # Any successful response means server is ready - except urllib.error.HTTPError: - # 404/405/etc means the server IS handling requests - return - except (urllib.error.URLError, ConnectionError, OSError): - # Server not ready for HTTP yet - time.sleep(0.05) - raise TimeoutError(f"Server on port {port} did not become HTTP-ready within {timeout} seconds") # pragma: no cover + raise TimeoutError(f"Server on port {port} did not start within {timeout} seconds") # pragma: no cover