Skip to content

Commit 873e8d0

Browse files
olivermeyerclaude
andcommitted
fix(platform): isolate health check HTTP pool from API client to prevent response cross-contamination
The authenticated health check previously shared the same ApiClient and urllib3 PoolManager as production API calls (e.g. Client().me()). Under xdist parallel test execution, a health check response body could leak into a subsequent /me request via the shared connection pool, causing MeReadResponse validation failures. Fix: use the dedicated Service._http_pool (same as public health check) with a Bearer token header, and reset Client._api_client_cached between tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 73ede20 commit 873e8d0

3 files changed

Lines changed: 143 additions & 12 deletions

File tree

src/aignostics/platform/_service.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -222,21 +222,27 @@ def _determine_api_public_health(self) -> Health:
222222
return Health(status=Health.Code.UP)
223223

224224
def _determine_api_authenticated_health(self) -> Health:
225-
"""Determine healthiness and reachability of Aignostics Platform API via authenticated API client.
225+
"""Determine healthiness and reachability of Aignostics Platform API via authenticated request.
226226
227-
- Checks if health endpoint is reachable and returns 200 OK
227+
Uses a dedicated HTTP pool (separate from the API client's connection pool) to prevent
228+
connection-level cross-contamination between health checks and API calls.
228229
229230
Returns:
230-
Health: The healthiness of the Aignostics Platform API when trying to reach via authenticated API client.
231+
Health: The healthiness of the Aignostics Platform API when trying to reach via authenticated request.
231232
"""
232233
try:
233-
api_client = Client.get_api_client(cache_token=True).api_client
234-
response = api_client.call_api(
235-
url=self._settings.api_root + "/api/v1/health",
234+
token = get_token(use_cache=True)
235+
http = self._get_http_pool()
236+
response = http.request(
236237
method="GET",
237-
header_params={"User-Agent": user_agent()},
238-
_request_timeout=self._settings.health_timeout,
238+
url=f"{self._settings.api_root}/health",
239+
headers={
240+
"User-Agent": user_agent(),
241+
"Authorization": f"Bearer {token}",
242+
},
243+
timeout=urllib3.Timeout(total=self._settings.health_timeout),
239244
)
245+
240246
if response.status != HTTPStatus.OK:
241247
logger.error("Aignostics Platform API (authenticated) returned '{}'", response.status)
242248
return Health(status=Health.Code.DOWN, reason=f"Aignostics Platform API returned '{response.status}'")

tests/aignostics/platform/conftest.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from aignostics.platform._client import Client
99
from aignostics.platform._operation_cache import _operation_cache
10+
from aignostics.platform._service import Service
1011

1112

1213
@pytest.fixture
@@ -54,12 +55,21 @@ def mock_api_client() -> MagicMock:
5455

5556

5657
@pytest.fixture(autouse=True)
57-
def clear_cache() -> None:
58-
"""Clear the operation cache before each test.
58+
def clear_cache() -> t.Generator[None, None, None]:
59+
"""Clear the operation cache and API client singletons before and after each test.
5960
60-
This ensures tests don't interfere with each other through shared cache state.
61+
This ensures tests don't interfere with each other through shared cache state
62+
or shared urllib3 connection pools (which could cause response cross-contamination).
6163
"""
6264
_operation_cache.clear()
65+
Client._api_client_cached = None
66+
Client._api_client_uncached = None
67+
Service._http_pool = None
68+
yield
69+
_operation_cache.clear()
70+
Client._api_client_cached = None
71+
Client._api_client_uncached = None
72+
Service._http_pool = None
6373

6474

6575
@pytest.fixture

tests/aignostics/platform/service_test.py

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
"""Tests for the platform service module."""
22

3-
from unittest.mock import MagicMock
3+
from http import HTTPStatus
4+
from unittest.mock import MagicMock, patch
45

56
import pytest
67

78
from aignostics.platform._service import Service, UserInfo
9+
from aignostics.utils import Health
10+
11+
_PATCH_AUTH_GETTER = "aignostics.platform._service.get_token"
812

913

1014
@pytest.mark.unit
@@ -42,6 +46,117 @@ def test_http_pool_singleton() -> None:
4246
assert pool_from_service1 is pool_from_service2, "Service instances should share the same HTTP pool"
4347

4448

49+
@pytest.mark.unit
50+
def test_determine_api_authenticated_health_success() -> None:
51+
"""Health.UP returned when the dedicated pool responds 200 with auth token."""
52+
mock_response = MagicMock()
53+
mock_response.status = HTTPStatus.OK
54+
55+
mock_pool = MagicMock()
56+
mock_pool.request.return_value = mock_response
57+
58+
with (
59+
patch.object(Service, "_get_http_pool", return_value=mock_pool),
60+
patch(_PATCH_AUTH_GETTER, return_value="test-token"),
61+
):
62+
result = Service()._determine_api_authenticated_health()
63+
64+
assert result.status == Health.Code.UP
65+
66+
67+
@pytest.mark.unit
68+
def test_determine_api_authenticated_health_non_200() -> None:
69+
"""Health.DOWN returned when the dedicated pool responds with non-200."""
70+
mock_response = MagicMock()
71+
mock_response.status = HTTPStatus.SERVICE_UNAVAILABLE
72+
73+
mock_pool = MagicMock()
74+
mock_pool.request.return_value = mock_response
75+
76+
with (
77+
patch.object(Service, "_get_http_pool", return_value=mock_pool),
78+
patch(_PATCH_AUTH_GETTER, return_value="test-token"),
79+
):
80+
result = Service()._determine_api_authenticated_health()
81+
82+
assert result.status == Health.Code.DOWN
83+
assert result.reason is not None
84+
85+
86+
@pytest.mark.unit
87+
def test_determine_api_authenticated_health_handles_exception() -> None:
88+
"""Health.DOWN with reason when get_token raises."""
89+
with patch(_PATCH_AUTH_GETTER, side_effect=RuntimeError("no auth")):
90+
result = Service()._determine_api_authenticated_health()
91+
92+
assert result.status == Health.Code.DOWN
93+
assert result.reason is not None
94+
95+
96+
@pytest.mark.unit
97+
def test_determine_api_public_health_success() -> None:
98+
"""Health.UP returned when the public pool responds 200."""
99+
mock_response = MagicMock()
100+
mock_response.status = HTTPStatus.OK
101+
102+
mock_pool = MagicMock()
103+
mock_pool.request.return_value = mock_response
104+
105+
with patch.object(Service, "_get_http_pool", return_value=mock_pool):
106+
result = Service()._determine_api_public_health()
107+
108+
assert result.status == Health.Code.UP
109+
110+
111+
@pytest.mark.unit
112+
def test_determine_api_public_health_non_200() -> None:
113+
"""Health.DOWN returned when the public pool responds with non-200."""
114+
mock_response = MagicMock()
115+
mock_response.status = HTTPStatus.SERVICE_UNAVAILABLE
116+
117+
mock_pool = MagicMock()
118+
mock_pool.request.return_value = mock_response
119+
120+
with patch.object(Service, "_get_http_pool", return_value=mock_pool):
121+
result = Service()._determine_api_public_health()
122+
123+
assert result.status == Health.Code.DOWN
124+
assert result.reason is not None
125+
126+
127+
@pytest.mark.unit
128+
def test_determine_api_public_health_handles_exception() -> None:
129+
"""Health.DOWN returned when the public pool raises."""
130+
mock_pool = MagicMock()
131+
mock_pool.request.side_effect = ConnectionError("unreachable")
132+
133+
with patch.object(Service, "_get_http_pool", return_value=mock_pool):
134+
result = Service()._determine_api_public_health()
135+
136+
assert result.status == Health.Code.DOWN
137+
assert result.reason is not None
138+
139+
140+
@pytest.mark.unit
141+
def test_health_returns_both_components() -> None:
142+
"""health() aggregates api_public and api_authenticated component keys."""
143+
public_health = Health(status=Health.Code.UP)
144+
auth_health = Health(status=Health.Code.UP)
145+
146+
service = Service()
147+
with (
148+
patch.object(service, "_determine_api_public_health", return_value=public_health),
149+
patch.object(service, "_determine_api_authenticated_health", return_value=auth_health),
150+
):
151+
result = service.health()
152+
153+
assert result.components is not None
154+
assert "api_public" in result.components
155+
assert "api_authenticated" in result.components
156+
assert result.components["api_public"] is public_health
157+
assert result.components["api_authenticated"] is auth_health
158+
159+
45160
@pytest.mark.unit
46161
@pytest.mark.parametrize(
47162
("organization_name", "is_internal"),

0 commit comments

Comments
 (0)