Skip to content

Commit fb79cf0

Browse files
Copilotsoyacz
andauthored
Add configurable timeout and retry to ArgusClient HTTP requests (#875)
* Add timeout and retry configuration to ArgusClient - Add configurable timeout (default 60s) and max_retries (default 3) parameters to ArgusClient - Configure requests Session with HTTPAdapter and urllib3 Retry strategy - Apply timeout to all GET and POST requests - Update all child client classes to pass through timeout and retry parameters - Add comprehensive tests for timeout and retry functionality Co-authored-by: soyacz <18242288+soyacz@users.noreply.github.com> * feat(client): refine retry policy and raise reporter timeout - retry only GET requests on timeouts (no 5xx retries) - keep POST submissions non-retried to avoid duplicates - update timeout/retry tests to match new policy - document client timeout/retry behavior in docs - set pytest-argus-reporter default timeout to 180s and document it --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: soyacz <18242288+soyacz@users.noreply.github.com> Co-authored-by: Lukasz Sojka <lukasz.sojka@scylladb.com>
1 parent 88f110f commit fb79cf0

File tree

8 files changed

+253
-11
lines changed

8 files changed

+253
-11
lines changed

argus/client/base.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from uuid import UUID
66

77
import requests
8+
from requests.adapters import HTTPAdapter
9+
from urllib3.util.retry import Retry
810

911
from argus.common.enums import TestStatus
1012
from argus.client.generic_result import GenericResultTable
@@ -33,11 +35,30 @@ class Routes():
3335
FETCH_RESULTS = "/testrun/$type/$id/fetch_results"
3436
FINALIZE = "/testrun/$type/$id/finalize"
3537

36-
def __init__(self, auth_token: str, base_url: str, api_version="v1", extra_headers: dict | None = None) -> None:
38+
def __init__(self, auth_token: str, base_url: str, api_version="v1", extra_headers: dict | None = None,
39+
timeout: int = 60, max_retries: int = 3) -> None:
3740
self._auth_token = auth_token
3841
self._base_url = base_url
3942
self._api_ver = api_version
43+
self._timeout = timeout
4044
self.session = requests.Session()
45+
46+
# Configure retry strategy
47+
retry_strategy = Retry(
48+
total=max_retries,
49+
connect=max_retries,
50+
read=max_retries,
51+
status=0,
52+
backoff_factor=1,
53+
status_forcelist=(),
54+
allowed_methods=["GET"],
55+
)
56+
57+
# Mount adapter with retry strategy for both http and https
58+
adapter = HTTPAdapter(max_retries=retry_strategy)
59+
self.session.mount("http://", adapter)
60+
self.session.mount("https://", adapter)
61+
4162
if extra_headers:
4263
self.session.headers.update(extra_headers)
4364

@@ -101,7 +122,8 @@ def get(self, endpoint: str, location_params: dict[str, str] = None, params: dic
101122
response = self.session.get(
102123
url=url,
103124
params=params,
104-
headers=self.request_headers
125+
headers=self.request_headers,
126+
timeout=self._timeout
105127
)
106128
LOGGER.debug("GET Response: %s %s", response.status_code, response.url)
107129

@@ -124,6 +146,7 @@ def post(
124146
params=params,
125147
json=body,
126148
headers=self.request_headers,
149+
timeout=self._timeout
127150
)
128151
LOGGER.debug("POST Response: %s %s", response.status_code, response.url)
129152

argus/client/driver_matrix_tests/client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ class Routes(ArgusClient.Routes):
1212
SUBMIT_DRIVER_FAILURE = "/driver_matrix/result/fail"
1313
SUBMIT_ENV = "/driver_matrix/env/submit"
1414

15-
def __init__(self, run_id: UUID, auth_token: str, base_url: str, api_version="v1", extra_headers: dict | None = None) -> None:
16-
super().__init__(auth_token, base_url, api_version, extra_headers=extra_headers)
15+
def __init__(self, run_id: UUID, auth_token: str, base_url: str, api_version="v1", extra_headers: dict | None = None,
16+
timeout: int = 60, max_retries: int = 3) -> None:
17+
super().__init__(auth_token, base_url, api_version, extra_headers=extra_headers,
18+
timeout=timeout, max_retries=max_retries)
1719
self.run_id = run_id
1820

1921
def submit_driver_matrix_run(self, job_name: str, job_url: str) -> None:

argus/client/generic/client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ class ArgusGenericClient(ArgusClient):
1212
class Routes(ArgusClient.Routes):
1313
TRIGGER_JOBS = "/planning/plan/trigger"
1414

15-
def __init__(self, auth_token: str, base_url: str, api_version="v1", extra_headers: dict | None = None) -> None:
16-
super().__init__(auth_token, base_url, api_version, extra_headers=extra_headers)
15+
def __init__(self, auth_token: str, base_url: str, api_version="v1", extra_headers: dict | None = None,
16+
timeout: int = 180, max_retries: int = 3) -> None:
17+
super().__init__(auth_token, base_url, api_version, extra_headers=extra_headers,
18+
timeout=timeout, max_retries=max_retries)
1719

1820
def submit_generic_run(self, build_id: str, run_id: str, started_by: str, build_url: str, sub_type: str = None, scylla_version: str | None = None):
1921
request_body = {

argus/client/sct/client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@ class Routes(ArgusClient.Routes):
3030
SUBMIT_JUNIT_REPORT = "/sct/$id/junit/submit"
3131
SUBMIT_EMAIL = "/testrun/report/email"
3232

33-
def __init__(self, run_id: UUID, auth_token: str, base_url: str, api_version="v1", extra_headers: dict | None = None) -> None:
34-
super().__init__(auth_token, base_url, api_version, extra_headers=extra_headers)
33+
def __init__(self, run_id: UUID, auth_token: str, base_url: str, api_version="v1", extra_headers: dict | None = None,
34+
timeout: int = 60, max_retries: int = 3) -> None:
35+
super().__init__(auth_token, base_url, api_version, extra_headers=extra_headers,
36+
timeout=timeout, max_retries=max_retries)
3537
self.run_id = run_id
3638

3739
def submit_sct_run(self, job_name: str, job_url: str, started_by: str, commit_id: str,

argus/client/sirenada/client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,11 @@ class ArgusSirenadaClient(ArgusClient):
4545
"skipped": "skipped"
4646
}
4747

48-
def __init__(self, auth_token: str, base_url: str, api_version="v1", extra_headers: dict | None = None) -> None:
48+
def __init__(self, auth_token: str, base_url: str, api_version="v1", extra_headers: dict | None = None,
49+
timeout: int = 60, max_retries: int = 3) -> None:
4950
self.results_path: Path | None = None
50-
super().__init__(auth_token, base_url, api_version, extra_headers=extra_headers)
51+
super().__init__(auth_token, base_url, api_version, extra_headers=extra_headers,
52+
timeout=timeout, max_retries=max_retries)
5153

5254
def _verify_required_files_exist(self, results_path: Path):
5355
assert (results_path / self._junit_xml_filename).exists(), "Missing jUnit XML results file!"
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""Tests for timeout and retry functionality in ArgusClient."""
2+
from unittest.mock import patch
3+
from uuid import uuid4
4+
5+
from argus.client.base import ArgusClient
6+
from argus.client.sct.client import ArgusSCTClient
7+
from argus.client.generic.client import ArgusGenericClient
8+
from argus.client.driver_matrix_tests.client import ArgusDriverMatrixClient
9+
from argus.client.sirenada.client import ArgusSirenadaClient
10+
11+
12+
def test_argus_client_default_timeout():
13+
"""Test that ArgusClient uses default timeout of 60 seconds."""
14+
client = ArgusClient(
15+
auth_token="test_token",
16+
base_url="https://test.example.com"
17+
)
18+
assert client._timeout == 60
19+
20+
21+
def test_argus_client_custom_timeout():
22+
"""Test that ArgusClient accepts custom timeout."""
23+
client = ArgusClient(
24+
auth_token="test_token",
25+
base_url="https://test.example.com",
26+
timeout=120
27+
)
28+
assert client._timeout == 120
29+
30+
31+
def test_argus_client_session_has_retry_adapter():
32+
"""Test that ArgusClient session is configured with retry adapter."""
33+
client = ArgusClient(
34+
auth_token="test_token",
35+
base_url="https://test.example.com",
36+
max_retries=5
37+
)
38+
39+
# Check that adapters are mounted
40+
assert "https://" in client.session.adapters
41+
assert "http://" in client.session.adapters
42+
43+
# Check that the adapter has retry configuration
44+
adapter = client.session.get_adapter("https://test.example.com")
45+
assert adapter.max_retries.total == 5
46+
assert adapter.max_retries.backoff_factor == 1
47+
assert adapter.max_retries.status == 0
48+
assert adapter.max_retries.status_forcelist == set()
49+
50+
51+
def test_get_request_uses_timeout(requests_mock):
52+
"""Test that GET requests include timeout parameter."""
53+
requests_mock.get(
54+
"https://test.example.com/api/v1/client/testrun/test-type/test-id/get",
55+
json={"status": "ok", "response": {}},
56+
status_code=200
57+
)
58+
59+
client = ArgusClient(
60+
auth_token="test_token",
61+
base_url="https://test.example.com",
62+
timeout=30
63+
)
64+
65+
with patch.object(client.session, 'get', wraps=client.session.get) as mock_get:
66+
try:
67+
client.get(
68+
endpoint=ArgusClient.Routes.GET,
69+
location_params={"type": "test-type", "id": "test-id"}
70+
)
71+
except Exception:
72+
pass # We're just checking the call arguments
73+
74+
# Verify timeout was passed to the request
75+
assert mock_get.called
76+
call_kwargs = mock_get.call_args.kwargs
77+
assert 'timeout' in call_kwargs
78+
assert call_kwargs['timeout'] == 30
79+
80+
81+
def test_post_request_uses_timeout(requests_mock):
82+
"""Test that POST requests include timeout parameter."""
83+
requests_mock.post(
84+
"https://test.example.com/api/v1/client/testrun/test-type/submit",
85+
json={"status": "ok"},
86+
status_code=200
87+
)
88+
89+
client = ArgusClient(
90+
auth_token="test_token",
91+
base_url="https://test.example.com",
92+
timeout=45
93+
)
94+
95+
with patch.object(client.session, 'post', wraps=client.session.post) as mock_post:
96+
try:
97+
client.post(
98+
endpoint=ArgusClient.Routes.SUBMIT,
99+
location_params={"type": "test-type"},
100+
body={"test": "data"}
101+
)
102+
except Exception:
103+
pass # We're just checking the call arguments
104+
105+
# Verify timeout was passed to the request
106+
assert mock_post.called
107+
call_kwargs = mock_post.call_args.kwargs
108+
assert 'timeout' in call_kwargs
109+
assert call_kwargs['timeout'] == 45
110+
111+
112+
def test_retry_configuration_is_correct():
113+
"""Test that the retry adapter is correctly configured."""
114+
client = ArgusClient(
115+
auth_token="test_token",
116+
base_url="https://test.example.com",
117+
max_retries=5
118+
)
119+
120+
# Get the adapter
121+
adapter = client.session.get_adapter("https://test.example.com")
122+
123+
# Verify retry configuration
124+
assert adapter.max_retries.total == 5
125+
assert adapter.max_retries.backoff_factor == 1
126+
assert adapter.max_retries.status == 0
127+
assert adapter.max_retries.status_forcelist == set()
128+
assert "GET" in adapter.max_retries.allowed_methods
129+
assert "POST" not in adapter.max_retries.allowed_methods
130+
131+
132+
def test_sct_client_passes_timeout_and_retries():
133+
"""Test that ArgusSCTClient passes timeout and retry parameters to parent."""
134+
run_id = uuid4()
135+
client = ArgusSCTClient(
136+
run_id=run_id,
137+
auth_token="test_token",
138+
base_url="https://test.example.com",
139+
timeout=90,
140+
max_retries=5
141+
)
142+
143+
assert client._timeout == 90
144+
adapter = client.session.get_adapter("https://test.example.com")
145+
assert adapter.max_retries.total == 5
146+
147+
148+
def test_generic_client_passes_timeout_and_retries():
149+
"""Test that ArgusGenericClient passes timeout and retry parameters to parent."""
150+
client = ArgusGenericClient(
151+
auth_token="test_token",
152+
base_url="https://test.example.com",
153+
timeout=75,
154+
max_retries=4
155+
)
156+
157+
assert client._timeout == 75
158+
adapter = client.session.get_adapter("https://test.example.com")
159+
assert adapter.max_retries.total == 4
160+
161+
162+
def test_driver_matrix_client_passes_timeout_and_retries():
163+
"""Test that ArgusDriverMatrixClient passes timeout and retry parameters to parent."""
164+
run_id = uuid4()
165+
client = ArgusDriverMatrixClient(
166+
run_id=run_id,
167+
auth_token="test_token",
168+
base_url="https://test.example.com",
169+
timeout=100,
170+
max_retries=2
171+
)
172+
173+
assert client._timeout == 100
174+
adapter = client.session.get_adapter("https://test.example.com")
175+
assert adapter.max_retries.total == 2
176+
177+
178+
def test_sirenada_client_passes_timeout_and_retries():
179+
"""Test that ArgusSirenadaClient passes timeout and retry parameters to parent."""
180+
client = ArgusSirenadaClient(
181+
auth_token="test_token",
182+
base_url="https://test.example.com",
183+
timeout=80,
184+
max_retries=6
185+
)
186+
187+
assert client._timeout == 80
188+
adapter = client.session.get_adapter("https://test.example.com")
189+
assert adapter.max_retries.total == 6
190+
191+
192+
def test_client_uses_default_values_when_not_specified():
193+
"""Test that client uses defaults when timeout and retries not specified."""
194+
run_id = uuid4()
195+
client = ArgusSCTClient(
196+
run_id=run_id,
197+
auth_token="test_token",
198+
base_url="https://test.example.com"
199+
)
200+
201+
# Should use default timeout of 60 and default retries of 3
202+
assert client._timeout == 60
203+
adapter = client.session.get_adapter("https://test.example.com")
204+
assert adapter.max_retries.total == 3

docs/generic_results.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ Generic results are stored in Argus database and can be accessed via Argus API a
3838
To send generic results to Argus, you need to create `ResultTable` class basing on `GenericResultTable` available in Argus Client library
3939
and send it using `ArgusClient` class (or it's child class like `ArgusSCTClient`).
4040

41+
Note: Argus client requests use a 60s timeout by default. `ArgusGenericClient` defaults to 180s to accommodate larger payloads. Only GET
42+
requests are retried, and only on timeouts; POST submissions surface timeouts to the caller so clients can handle retries explicitly.
43+
4144
Child class of `GenericResultTable` needs to define `Meta` class with `name` and `description` fields. It also needs to define `Columns`
4245
field that will describe available columns details for given result: as a list of `ColumnMetadata` objects. Each `ColumnMetadata` object
4346
needs to have `name`, `unit` and `type` and optionally `higher_is_better` fields. `type` field can be one of `ResultType` enum values.

pytest-argus-reporter/pytest_argus_reporter.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,11 @@ def __init__(self, config):
183183

184184
@cached_property
185185
def argus_client(self):
186-
return ArgusGenericClient(auth_token=self.api_key, base_url=self.base_url, extra_headers=self.extra_headers)
186+
return ArgusGenericClient(
187+
auth_token=self.api_key,
188+
base_url=self.base_url,
189+
extra_headers=self.extra_headers,
190+
)
187191

188192
def append_test_data(self, request, test_data):
189193
self.test_data[request.node.nodeid].update(**test_data)

0 commit comments

Comments
 (0)