Skip to content
This repository was archived by the owner on Sep 1, 2024. It is now read-only.

Commit 1f6aca3

Browse files
committed
Add retries to API requests
1 parent df3b14a commit 1f6aca3

File tree

5 files changed

+262
-48
lines changed

5 files changed

+262
-48
lines changed

src/pytest_unflakable/_api.py

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@
22

33
# Copyright (c) 2022-2023 Developer Innovations, LLC
44

5+
from __future__ import annotations
6+
57
import logging
68
import platform
79
import pprint
810
import sys
9-
from typing import TYPE_CHECKING, List, Optional
11+
import time
12+
from typing import TYPE_CHECKING, Any, List, Mapping, Optional
1013

1114
import pkg_resources
1215
import requests
16+
from requests import Response, Session
1317

1418
if TYPE_CHECKING:
1519
from typing_extensions import NotRequired, TypedDict
@@ -29,6 +33,7 @@
2933
f'unflakable-pytest-plugin/{PACKAGE_VERSION} (PyTest {PYTEST_VERSION}; '
3034
f'Python {PYTHON_VERSION}; Platform {PLATFORM_VERSION})'
3135
)
36+
NUM_REQUEST_TRIES = 3
3237

3338

3439
class TestRef(TypedDict):
@@ -77,6 +82,47 @@ class TestSuiteRunPendingSummary(TypedDict):
7782
commit: NotRequired[Optional[str]]
7883

7984

85+
def send_api_request(
86+
api_key: str,
87+
method: Literal['GET', 'POST'],
88+
url: str,
89+
logger: logging.Logger,
90+
headers: Optional[Mapping[str, str | bytes | None]] = None,
91+
json: Optional[Any] = None,
92+
verify: Optional[bool | str] = None,
93+
) -> Response:
94+
session = Session()
95+
session.headers.update({
96+
'Authorization': f'Bearer {api_key}',
97+
'User-Agent': USER_AGENT,
98+
})
99+
100+
for idx in range(NUM_REQUEST_TRIES):
101+
try:
102+
response = session.request(method, url, headers=headers, json=json, verify=verify)
103+
if response.status_code not in [429, 500, 502, 503, 504]:
104+
return response
105+
elif idx + 1 != NUM_REQUEST_TRIES:
106+
logger.warning(
107+
'Retrying request to `%s` due to unexpected response with status code %d' % (
108+
url,
109+
response.status_code,
110+
)
111+
)
112+
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
113+
if idx + 1 != NUM_REQUEST_TRIES:
114+
logger.warning('Retrying %s request to `%s` due to error: %s' %
115+
(method, url, repr(e)))
116+
else:
117+
raise
118+
119+
sleep_sec = (2 ** idx)
120+
logger.debug('Sleeping for %f second(s) before retry' % sleep_sec)
121+
time.sleep(sleep_sec)
122+
123+
return response
124+
125+
80126
def create_test_suite_run(
81127
request: CreateTestSuiteRunRequest,
82128
test_suite_id: str,
@@ -87,16 +133,15 @@ def create_test_suite_run(
87133
) -> TestSuiteRunPendingSummary:
88134
logger.debug(f'creating test suite run {pprint.pformat(request)}')
89135

90-
run_response = requests.post(
136+
run_response = send_api_request(
137+
api_key=api_key,
138+
method='POST',
91139
url=(
92140
f'{base_url if base_url is not None else BASE_URL}/api/v1/test-suites/{test_suite_id}'
93141
'/runs'
94142
),
95-
headers={
96-
'Authorization': f'Bearer {api_key}',
97-
'Content-Type': 'application/json',
98-
'User-Agent': USER_AGENT,
99-
},
143+
logger=logger,
144+
headers={'Content-Type': 'application/json'},
100145
json=request,
101146
verify=not insecure_disable_tls_validation,
102147
)
@@ -117,15 +162,14 @@ def get_test_suite_manifest(
117162
) -> TestSuiteManifest:
118163
logger.debug(f'fetching manifest for test suite {test_suite_id}')
119164

120-
manifest_response = requests.get(
165+
manifest_response = send_api_request(
166+
api_key=api_key,
167+
method='GET',
121168
url=(
122169
f'{base_url if base_url is not None else BASE_URL}/api/v1/test-suites/{test_suite_id}'
123170
'/manifest'
124171
),
125-
headers={
126-
'Authorization': f'Bearer {api_key}',
127-
'User-Agent': USER_AGENT,
128-
},
172+
logger=logger,
129173
verify=not insecure_disable_tls_validation,
130174
)
131175
manifest_response.raise_for_status()

src/pytest_unflakable/_plugin.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import _pytest
1414
import pytest
15+
from _pytest.config import ExitCode
1516

1617
from ._api import (CreateTestSuiteRunRequest, TestAttemptResult,
1718
TestRunAttemptRecord, TestRunRecord, TestSuiteManifest,
@@ -609,7 +610,10 @@ def pytest_sessionfinish(
609610
logger=self.logger,
610611
)
611612
except Exception as e:
612-
pytest.exit('ERROR: Failed to report results to Unflakable: %s\n' % (repr(e)), 1)
613+
pytest.exit(
614+
'ERROR: Failed to report results to Unflakable: %s\n' % (repr(e)),
615+
ExitCode.INTERNAL_ERROR,
616+
)
613617
else:
614618
print(
615619
'Unflakable report: %s' % (
@@ -623,4 +627,4 @@ def pytest_sessionfinish(
623627
# We multiply by 2 here because each quarantined test is double-counted by the Session: once
624628
# for the quarantined report, and once for the fake report that's used for logging errors.
625629
if session.testsfailed > 0 and session.testsfailed == self.num_tests_quarantined * 2:
626-
pytest.exit('All failed tests are quarantined', 0)
630+
pytest.exit('All failed tests are quarantined', ExitCode.OK)

tests/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import pytest
2+
3+
pytest.register_assert_rewrite('tests.common')

tests/common.py

Lines changed: 90 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from enum import Enum
88
from typing import (TYPE_CHECKING, Callable, Dict, Iterable, List, Optional,
99
Sequence, Tuple, cast)
10+
from unittest import mock
11+
from unittest.mock import Mock, call, patch
1012

1113
import pytest
1214
import requests
@@ -244,11 +246,13 @@ def assert_regex(regex: str, string: str) -> None:
244246
assert re.match(regex, string) is not None, f'`{string}` does not match regex {regex}'
245247

246248

249+
@patch.multiple('time', sleep=mock.DEFAULT)
247250
@requests_mock.Mocker(case_sensitive=True, kw='requests_mocker')
248251
def run_test_case(
249252
pytester: pytest.Pytester,
250-
manifest: _api.TestSuiteManifest,
253+
manifest: Optional[_api.TestSuiteManifest],
251254
requests_mocker: requests_mock.Mocker,
255+
sleep: Mock,
252256
expected_test_file_outcomes: List[
253257
Tuple[str, List[Tuple[Tuple[str, ...], List[_TestAttemptOutcome]]]]],
254258
expected_test_result_counts: _TestResultCounts,
@@ -265,14 +269,21 @@ def run_test_case(
265269
env_vars: Optional[Dict[str, str]] = None,
266270
expect_progress: bool = True,
267271
expect_xdist: bool = False,
272+
failed_manifest_requests: int = 0,
273+
failed_upload_requests: int = 0,
268274
) -> None:
269275
api_key_path = pytester.makefile('', expected_api_key) if use_api_key_path else None
270276
requests_mocker.get(
271277
url='https://app.unflakable.com/api/v1/test-suites/MOCK_SUITE_ID/manifest',
272278
request_headers={'Authorization': f'Bearer {expected_api_key}'},
273279
complete_qs=True,
274-
status_code=200,
275-
json=manifest,
280+
response_list=[
281+
{'exc': requests.exceptions.ConnectTimeout}
282+
for _ in range(failed_manifest_requests)
283+
] + ([{
284+
'status_code': 200,
285+
'json': manifest,
286+
}] if manifest is not None else [])
276287
)
277288

278289
requests_mocker.post(
@@ -282,8 +293,13 @@ def run_test_case(
282293
'Content-Type': 'application/json',
283294
},
284295
complete_qs=True,
285-
status_code=201,
286-
json=mock_create_test_suite_run_response,
296+
response_list=[
297+
{'exc': requests.exceptions.ConnectTimeout}
298+
for _ in range(failed_upload_requests)
299+
] + [{
300+
'status_code': 201,
301+
'json': mock_create_test_suite_run_response,
302+
}]
287303
)
288304

289305
pytest_args: List[str] = (
@@ -483,42 +499,82 @@ def run_test_case(
483499
expected_test_result_counts.non_skipped_tests > 0) else [])
484500
)
485501

486-
assert requests_mocker.call_count == (
487-
(
488-
2 if expected_uploaded_test_runs is not None and (
489-
expected_test_result_counts.non_skipped_tests > 0) else 1
490-
) if plugin_enabled else 0
491-
)
502+
if plugin_enabled:
503+
expected_get_test_suite_manifest_attempts = (
504+
failed_manifest_requests + (1 if failed_manifest_requests <
505+
_api.NUM_REQUEST_TRIES and manifest is not None else 0)
506+
)
507+
for manifest_attempt in range(expected_get_test_suite_manifest_attempts):
508+
request = requests_mocker.request_history[manifest_attempt]
492509

493-
# Checked expected User-Agent. We do this here instead of using an `additional_matcher` to make
494-
# errors easier to diagnose.
495-
for request in requests_mocker.request_history:
496-
assert_regex(
497-
r'^unflakable-pytest-plugin/.* \(PyTest .*; Python .*; Platform .*\)$',
498-
request.headers.get('User-Agent', '')
510+
assert request.url == (
511+
'https://app.unflakable.com/api/v1/test-suites/MOCK_SUITE_ID/manifest'
512+
)
513+
assert request.method == 'GET'
514+
assert request.body is None
515+
516+
if manifest_attempt > 0:
517+
assert (
518+
sleep.call_args_list[manifest_attempt - 1] == call(2 ** (manifest_attempt - 1))
519+
)
520+
521+
expected_upload_attempts = (
522+
failed_upload_requests + (1 if (
523+
failed_upload_requests < _api.NUM_REQUEST_TRIES
524+
and expected_uploaded_test_runs is not None
525+
and expected_test_result_counts.non_skipped_tests != 0
526+
) else 0)
499527
)
500528

501-
if plugin_enabled and (
502-
expected_uploaded_test_runs is not None and
503-
expected_test_result_counts.non_skipped_tests > 0):
504-
create_test_suite_run_request = requests_mocker.request_history[1]
505-
assert create_test_suite_run_request.url == (
506-
'https://app.unflakable.com/api/v1/test-suites/MOCK_SUITE_ID/runs')
507-
assert create_test_suite_run_request.method == 'POST'
529+
for upload_attempt in range(expected_upload_attempts):
530+
create_test_suite_run_request = requests_mocker.request_history[
531+
expected_get_test_suite_manifest_attempts + upload_attempt
532+
]
533+
assert create_test_suite_run_request.url == (
534+
'https://app.unflakable.com/api/v1/test-suites/MOCK_SUITE_ID/runs')
535+
assert create_test_suite_run_request.method == 'POST'
536+
537+
create_test_suite_run_body: _api.CreateTestSuiteRunRequest = (
538+
create_test_suite_run_request.json()
539+
)
508540

509-
create_test_suite_run_body: _api.CreateTestSuiteRunRequest = (
510-
create_test_suite_run_request.json()
541+
if expected_commit is not None:
542+
assert create_test_suite_run_body['commit'] == expected_commit
543+
else:
544+
assert 'commit' not in create_test_suite_run_body
545+
546+
if expected_branch is not None:
547+
assert create_test_suite_run_body['branch'] == expected_branch
548+
else:
549+
assert 'branch' not in create_test_suite_run_body
550+
551+
if upload_attempt > 0:
552+
assert (
553+
sleep.call_args_list[
554+
max(expected_get_test_suite_manifest_attempts - 1, 0) +
555+
upload_attempt - 1
556+
] == call(2 ** (upload_attempt - 1))
557+
)
558+
559+
assert requests_mocker.call_count == (
560+
expected_get_test_suite_manifest_attempts + expected_upload_attempts
561+
), 'Expected %d total API requests, but received %d' % (
562+
expected_get_test_suite_manifest_attempts + expected_upload_attempts,
563+
requests_mocker.call_count,
511564
)
512565

513-
if expected_commit is not None:
514-
assert create_test_suite_run_body['commit'] == expected_commit
515-
else:
516-
assert 'commit' not in create_test_suite_run_body
566+
# Checked expected User-Agent. We do this here instead of using an `additional_matcher` to
567+
# make errors easier to diagnose.
568+
for request in requests_mocker.request_history:
569+
assert request.headers.get('Authorization', '') == f'Bearer {expected_api_key}'
517570

518-
if expected_branch is not None:
519-
assert create_test_suite_run_body['branch'] == expected_branch
520-
else:
521-
assert 'branch' not in create_test_suite_run_body
571+
assert_regex(
572+
r'^unflakable-pytest-plugin/.* \(PyTest .*; Python .*; Platform .*\)$',
573+
request.headers.get('User-Agent', '')
574+
)
575+
else:
576+
assert requests_mocker.call_count == 0
577+
assert sleep.call_count == 0
522578

523579
assert result.ret == expected_exit_code, (
524580
f'expected exit code {expected_exit_code}, but got {result.ret}')

0 commit comments

Comments
 (0)