Skip to content

Commit 43b2084

Browse files
authored
Issue #906: Add timeouts to QP and Translator clients (#1010)
Adds the configs: - `QUERY_PLANNER_CLIENT_TIMEOUT_SECONDS` - `TRANSLATOR_CLIENT_TIMEOUT_SECONDS` With a default of `30`.
2 parents 62467b1 + df8431e commit 43b2084

6 files changed

Lines changed: 190 additions & 4 deletions

File tree

cloudformation/lif-learner-data-export-api-taskdef-includes.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ Environment:
1414
- Name: LIF_TRANSLATOR_BASE_URL
1515
Value:
1616
Fn::Sub: "http://translator-org1.lif.${EnvironmentName}.aws:8007"
17+
- Name: QUERY_PLANNER_CLIENT_TIMEOUT_SECONDS
18+
Value: "30"
19+
- Name: TRANSLATOR_CLIENT_TIMEOUT_SECONDS
20+
Value: "30"
1721
Secrets:
1822
- Name: LIF_MDR_API_AUTH_TOKEN
1923
ValueFrom:

components/lif/query_planner_client/core.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,30 @@
1+
import os
2+
from typing import AsyncGenerator
3+
14
import httpx
25
from lif.exceptions.core import LIFException
36
from lif.logging import get_logger
47

58
logger = get_logger(__name__)
69

10+
# Default timeout for Query Planner client API calls (in seconds)
11+
DEFAULT_QUERY_PLANNER_CLIENT_TIMEOUT_SECONDS = 30
12+
13+
14+
def _get_query_planner_timeout_seconds() -> int:
15+
return int(os.getenv("QUERY_PLANNER_CLIENT_TIMEOUT_SECONDS", str(DEFAULT_QUERY_PLANNER_CLIENT_TIMEOUT_SECONDS)))
16+
17+
18+
async def _get_query_planner_client() -> AsyncGenerator[httpx.AsyncClient]:
19+
"""
20+
Generator that yields an httpx AsyncClient.
21+
22+
Allows a test harness to override this method to connect to an in-memory Query Planner instance.
23+
"""
24+
timeout = _get_query_planner_timeout_seconds()
25+
async with httpx.AsyncClient(timeout=timeout) as client:
26+
yield client
27+
728

829
async def fetch_query_from_query_planner(base_url: str, query: dict) -> list[dict]:
930
"""POST a LIF query to the Query Planner and return the results.
@@ -14,11 +35,13 @@ async def fetch_query_from_query_planner(base_url: str, query: dict) -> list[dic
1435
# FUTURE WORK: While the CONFIG has a query_planner_query property,
1536
# It should be folded into this method (or at least this query_planner_client)
1637
url = base_url.rstrip("/") + "/query"
38+
logger.info("Calling the Query Planner URL: %s with a timeout of %s", url, _get_query_planner_timeout_seconds())
39+
1740
try:
18-
async with httpx.AsyncClient() as client:
41+
async for client in _get_query_planner_client():
1942
response = await client.post(url, json=query)
2043
except httpx.TimeoutException as e:
21-
msg = f"Query Planner request timed out: {e}"
44+
msg = f"Query Planner request timed out due to: {e}"
2245
logger.error(msg)
2346
raise QueryPlannerException(msg) from e
2447
except httpx.ConnectError as e:

components/lif/translator_client/core.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,32 @@
1+
import os
2+
from typing import AsyncGenerator
3+
14
import httpx
25
from lif.exceptions.core import LIFException
36
from lif.logging import get_logger
47

58
logger = get_logger(__name__)
69

710

11+
# Default timeout for Translator client API calls (in seconds)
12+
DEFAULT_TRANSLATOR_CLIENT_TIMEOUT_SECONDS = 30
13+
14+
15+
def _get_translator_timeout_seconds() -> int:
16+
return int(os.getenv("TRANSLATOR_CLIENT_TIMEOUT_SECONDS", str(DEFAULT_TRANSLATOR_CLIENT_TIMEOUT_SECONDS)))
17+
18+
19+
async def _get_translator_client() -> AsyncGenerator[httpx.AsyncClient]:
20+
"""
21+
Generator that yields an httpx AsyncClient.
22+
23+
Allows a test harness to override this method to connect to an in-memory Translator instance.
24+
"""
25+
timeout = _get_translator_timeout_seconds()
26+
async with httpx.AsyncClient(timeout=timeout) as client:
27+
yield client
28+
29+
830
async def translate_learner_data(
931
base_url: str, source_schema_id: str, target_schema_id: str, learner_data: dict, tenant_schema: str | None = None
1032
) -> dict:
@@ -15,11 +37,12 @@ async def translate_learner_data(
1537
"""
1638
url = f"{base_url.rstrip('/')}/translate/source/{source_schema_id}/target/{target_schema_id}"
1739
headers = {"X-API-Tenant-Schema": tenant_schema} if tenant_schema else {}
40+
logger.info("Calling the Translator URL: %s with a timeout of %s", url, _get_translator_timeout_seconds())
1841
try:
19-
async with httpx.AsyncClient() as client:
42+
async for client in _get_translator_client():
2043
response = await client.post(url, json=learner_data, headers=headers)
2144
except httpx.TimeoutException as e:
22-
msg = f"Translator request timed out: {e}"
45+
msg = f"Translator request timed out due to: {e}"
2346
logger.error(msg)
2447
raise TranslatorException(msg) from e
2548
except httpx.ConnectError as e:

test/components/lif/query_planner_client/__init__.py

Whitespace-only changes.
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import os
2+
from unittest import mock
3+
4+
import httpx
5+
import pytest
6+
from lif.query_planner_client import QueryPlannerException, fetch_query_from_query_planner
7+
from lif.query_planner_client.core import (
8+
DEFAULT_QUERY_PLANNER_CLIENT_TIMEOUT_SECONDS,
9+
_get_query_planner_timeout_seconds,
10+
)
11+
12+
_BASE_URL = "http://localhost:8008"
13+
_QUERY = {"query": "{ Person { firstName } }"}
14+
15+
16+
def _make_http_mock(status_code: int, json_return=None):
17+
mock_response = mock.Mock()
18+
mock_response.status_code = status_code
19+
mock_response.json.return_value = json_return or []
20+
mock_response.text = str(json_return or [])
21+
22+
mock_client = mock.AsyncMock()
23+
mock_client.post.return_value = mock_response
24+
25+
mock_cls = mock.MagicMock()
26+
mock_cls.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client)
27+
mock_cls.return_value.__aexit__ = mock.AsyncMock(return_value=False)
28+
return mock_cls, mock_client
29+
30+
31+
async def test_successful_query_returns_json():
32+
mock_cls, _ = _make_http_mock(200, [{"firstName": "John"}])
33+
with mock.patch("lif.query_planner_client.core.httpx.AsyncClient", mock_cls):
34+
result = await fetch_query_from_query_planner(_BASE_URL, _QUERY)
35+
assert result == [{"firstName": "John"}]
36+
37+
38+
async def test_url_is_constructed_from_base():
39+
mock_cls, mock_client = _make_http_mock(200, [])
40+
with mock.patch("lif.query_planner_client.core.httpx.AsyncClient", mock_cls):
41+
await fetch_query_from_query_planner(_BASE_URL, _QUERY)
42+
mock_client.post.assert_called_once()
43+
call_url = mock_client.post.call_args[0][0]
44+
assert call_url == f"{_BASE_URL}/query"
45+
46+
47+
async def test_trailing_slash_stripped_from_base_url():
48+
mock_cls, mock_client = _make_http_mock(200, [])
49+
with mock.patch("lif.query_planner_client.core.httpx.AsyncClient", mock_cls):
50+
await fetch_query_from_query_planner(_BASE_URL + "/", _QUERY)
51+
call_url = mock_client.post.call_args[0][0]
52+
assert call_url == f"{_BASE_URL}/query"
53+
54+
55+
async def test_query_sent_as_json_body():
56+
mock_cls, mock_client = _make_http_mock(200, [])
57+
with mock.patch("lif.query_planner_client.core.httpx.AsyncClient", mock_cls):
58+
await fetch_query_from_query_planner(_BASE_URL, _QUERY)
59+
assert mock_client.post.call_args[1]["json"] == _QUERY
60+
61+
62+
async def test_non_200_raises_query_planner_exception():
63+
mock_cls, _ = _make_http_mock(500)
64+
with mock.patch("lif.query_planner_client.core.httpx.AsyncClient", mock_cls):
65+
with pytest.raises(QueryPlannerException, match="HTTP 500"):
66+
await fetch_query_from_query_planner(_BASE_URL, _QUERY)
67+
68+
69+
async def test_timeout_raises_query_planner_exception():
70+
with mock.patch("lif.query_planner_client.core.httpx.AsyncClient") as mock_cls:
71+
mock_cls.return_value.__aenter__ = mock.AsyncMock(side_effect=httpx.TimeoutException("timed out"))
72+
mock_cls.return_value.__aexit__ = mock.AsyncMock(return_value=False)
73+
with pytest.raises(QueryPlannerException, match="timed out"):
74+
await fetch_query_from_query_planner(_BASE_URL, _QUERY)
75+
76+
77+
async def test_connect_error_raises_query_planner_exception():
78+
with mock.patch("lif.query_planner_client.core.httpx.AsyncClient") as mock_cls:
79+
mock_cls.return_value.__aenter__ = mock.AsyncMock(side_effect=httpx.ConnectError("connection refused"))
80+
mock_cls.return_value.__aexit__ = mock.AsyncMock(return_value=False)
81+
with pytest.raises(QueryPlannerException, match="connection refused"):
82+
await fetch_query_from_query_planner(_BASE_URL, _QUERY)
83+
84+
85+
def test_timeout_defaults_when_env_var_absent():
86+
with mock.patch.dict(os.environ, {}, clear=True):
87+
assert _get_query_planner_timeout_seconds() == DEFAULT_QUERY_PLANNER_CLIENT_TIMEOUT_SECONDS
88+
89+
90+
def test_timeout_reads_from_env_var():
91+
with mock.patch.dict(os.environ, {"QUERY_PLANNER_CLIENT_TIMEOUT_SECONDS": "5"}):
92+
assert _get_query_planner_timeout_seconds() == 5
93+
94+
95+
async def test_default_timeout_passed_to_async_client():
96+
mock_cls, _ = _make_http_mock(200, [])
97+
with mock.patch.dict(os.environ, {}, clear=True):
98+
with mock.patch("lif.query_planner_client.core.httpx.AsyncClient", mock_cls):
99+
await fetch_query_from_query_planner(_BASE_URL, _QUERY)
100+
assert mock_cls.call_args.kwargs["timeout"] == DEFAULT_QUERY_PLANNER_CLIENT_TIMEOUT_SECONDS
101+
102+
103+
async def test_configured_timeout_passed_to_async_client():
104+
mock_cls, _ = _make_http_mock(200, [])
105+
with mock.patch.dict(os.environ, {"QUERY_PLANNER_CLIENT_TIMEOUT_SECONDS": "7"}):
106+
with mock.patch("lif.query_planner_client.core.httpx.AsyncClient", mock_cls):
107+
await fetch_query_from_query_planner(_BASE_URL, _QUERY)
108+
assert mock_cls.call_args.kwargs["timeout"] == 7

test/components/lif/translator_client/test_core.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import os
12
from unittest import mock
23

34
import httpx
45
import pytest
56
from lif.translator_client import TranslatorException, translate_learner_data
7+
from lif.translator_client.core import DEFAULT_TRANSLATOR_CLIENT_TIMEOUT_SECONDS, _get_translator_timeout_seconds
68

79
_BASE_URL = "http://localhost:8007"
810
_SOURCE_ID = "17"
@@ -78,3 +80,29 @@ async def test_connect_error_raises_translator_exception():
7880
mock_cls.return_value.__aexit__ = mock.AsyncMock(return_value=False)
7981
with pytest.raises(TranslatorException, match="connection refused"):
8082
await translate_learner_data(_BASE_URL, _SOURCE_ID, _TARGET_ID, _LEARNER_DATA)
83+
84+
85+
def test_timeout_defaults_when_env_var_absent():
86+
with mock.patch.dict(os.environ, {}, clear=True):
87+
assert _get_translator_timeout_seconds() == DEFAULT_TRANSLATOR_CLIENT_TIMEOUT_SECONDS
88+
89+
90+
def test_timeout_reads_from_env_var():
91+
with mock.patch.dict(os.environ, {"TRANSLATOR_CLIENT_TIMEOUT_SECONDS": "5"}):
92+
assert _get_translator_timeout_seconds() == 5
93+
94+
95+
async def test_default_timeout_passed_to_async_client():
96+
mock_cls, _ = _make_http_mock(200, {})
97+
with mock.patch.dict(os.environ, {}, clear=True):
98+
with mock.patch("lif.translator_client.core.httpx.AsyncClient", mock_cls):
99+
await translate_learner_data(_BASE_URL, _SOURCE_ID, _TARGET_ID, _LEARNER_DATA)
100+
assert mock_cls.call_args.kwargs["timeout"] == DEFAULT_TRANSLATOR_CLIENT_TIMEOUT_SECONDS
101+
102+
103+
async def test_configured_timeout_passed_to_async_client():
104+
mock_cls, _ = _make_http_mock(200, {})
105+
with mock.patch.dict(os.environ, {"TRANSLATOR_CLIENT_TIMEOUT_SECONDS": "7"}):
106+
with mock.patch("lif.translator_client.core.httpx.AsyncClient", mock_cls):
107+
await translate_learner_data(_BASE_URL, _SOURCE_ID, _TARGET_ID, _LEARNER_DATA)
108+
assert mock_cls.call_args.kwargs["timeout"] == 7

0 commit comments

Comments
 (0)