Skip to content

Commit abd190c

Browse files
Merge pull request #3766 from VWS-Python/shared-model-retry-policy-20261014
Supply Model Target retry policies from backend adapters
2 parents 0c18557 + 15780d5 commit abd190c

7 files changed

Lines changed: 217 additions & 126 deletions

File tree

‎.github/workflows/test.yml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ on:
1313

1414
# We share Vuforia credentials and therefore Vuforia databases across
1515
# workflows. We therefore want to run only one workflow at a time.
16-
concurrency: vuforia_credentials
16+
# Queue pending runs so updates to other PRs do not cancel their tests.
17+
concurrency:
18+
group: vuforia_credentials
19+
queue: max
1720

1821
permissions: {}
1922

‎prek.toml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,9 @@ hooks = [
117117
{
118118
id = "actionlint",
119119
name = "actionlint",
120-
entry = "uv run --group=dev actionlint",
120+
# actionlint does not yet recognize GitHub's concurrency.queue key.
121+
# Remove this exact diagnostic filter when rhysd/actionlint#657 is fixed.
122+
entry = "uv run --group=dev actionlint -ignore 'unexpected key \"queue\" for \"concurrency\" section'",
121123
language = "python",
122124
pass_filenames = false,
123125
types_or = ["yaml"],
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Retrying transient failures from the real Model Target Web API.
2+
3+
The real Model Target Web API sits behind a load balancer which
4+
occasionally answers a perfectly good request with a gateway error, or
5+
drops the connection. That has nothing to do with the contract under
6+
test, so a bounded number of retries for requests which are safe to
7+
repeat keeps a one-off gateway error from failing CI.
8+
9+
Only requests to the real backend are retried. The mock backends
10+
answer immediately, and some tests configure a mock to return a 5xx
11+
deliberately, so retrying those would be both pointless and wrong.
12+
The policy is therefore switched on for the duration of a test against
13+
the real Model Target backend and is off at all other times.
14+
15+
Only ``GET`` requests are retried. Repeating a dataset creation
16+
``POST`` can create a second dataset and can consume the account's
17+
Model Target training allowance, and the outcome of a repeat is
18+
ambiguous, so mutating requests are sent exactly once.
19+
"""
20+
21+
from http import HTTPStatus
22+
23+
import requests
24+
from beartype import beartype
25+
from tenacity import RetryCallState, Retrying
26+
from tenacity.retry import retry_if_exception_type, retry_if_result
27+
from tenacity.stop import stop_after_attempt
28+
from tenacity.wait import wait_exponential_jitter
29+
from vws.response import Response
30+
31+
# Status codes which mean "the gateway could not get an answer from
32+
# Vuforia", rather than "Vuforia answered, and this is the answer".
33+
TRANSIENT_MODEL_TARGET_STATUS_CODES = frozenset(
34+
{
35+
HTTPStatus.BAD_GATEWAY,
36+
HTTPStatus.SERVICE_UNAVAILABLE,
37+
HTTPStatus.GATEWAY_TIMEOUT,
38+
},
39+
)
40+
41+
# Transport failures which mean the request may never have been
42+
# answered.
43+
TRANSIENT_MODEL_TARGET_EXCEPTIONS = (
44+
requests.exceptions.ConnectionError,
45+
requests.exceptions.Timeout,
46+
)
47+
48+
# The total number of times a safe request is sent, including the first
49+
# attempt. This is deliberately small: a persistent upstream failure
50+
# must still fail the test quickly.
51+
MODEL_TARGET_RETRY_ATTEMPTS = 3
52+
53+
54+
@beartype
55+
def _is_transient(response: Response | requests.Response) -> bool:
56+
"""Whether a response is a transient gateway failure."""
57+
return response.status_code in TRANSIENT_MODEL_TARGET_STATUS_CODES
58+
59+
60+
@beartype
61+
def _last_outcome(retry_state: RetryCallState) -> Response | requests.Response:
62+
"""Give up with the outcome of the last attempt.
63+
64+
A transient failure on the last attempt is not hidden: a response is
65+
returned as it is, so that the caller's assertion reports the real
66+
status and body, and a transport failure propagates.
67+
"""
68+
assert retry_state.outcome is not None
69+
outcome = retry_state.outcome.result()
70+
assert isinstance(outcome, Response | requests.Response)
71+
return outcome
72+
73+
74+
REAL_MODEL_TARGET_RETRY_POLICY = Retrying(
75+
retry=(
76+
retry_if_exception_type(
77+
exception_types=TRANSIENT_MODEL_TARGET_EXCEPTIONS,
78+
)
79+
| retry_if_result(predicate=_is_transient)
80+
),
81+
stop=stop_after_attempt(max_attempt_number=MODEL_TARGET_RETRY_ATTEMPTS),
82+
wait=wait_exponential_jitter(initial=1, max=10),
83+
retry_error_callback=_last_outcome,
84+
)

‎tests/mock_vws/fixtures/vuforia_backends.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,16 @@
4343
get_real_inactive_vumark_database,
4444
get_real_vumark_database,
4545
)
46+
from tests.mock_vws.fixtures.real_model_target_retries import (
47+
REAL_MODEL_TARGET_RETRY_POLICY,
48+
)
4649
from tests.mock_vws.utils.backend_capabilities import BackendCapabilities
4750
from tests.mock_vws.utils.cloud_databases import AddCloudDatabase
4851
from tests.mock_vws.utils.model_target_response_policy import (
4952
handling_unexpected_model_target_responses,
5053
)
5154
from tests.mock_vws.utils.model_target_retries import (
52-
retrying_transient_real_backend_failures,
55+
retrying_model_target_requests,
5356
)
5457
from tests.mock_vws.utils.recognition_counts import SeedRecognitionCounts
5558
from tests.mock_vws.utils.request_timeout_policy import (
@@ -410,7 +413,9 @@ def _enable_use_real_model_target_vuforia(
410413
del base_vws_url
411414
assert bool(monkeypatch)
412415
with (
413-
retrying_transient_real_backend_failures(),
416+
retrying_model_target_requests(
417+
retrying=REAL_MODEL_TARGET_RETRY_POLICY,
418+
),
414419
handling_request_timeouts(handler=_handle_real_request_timeout),
415420
handling_unexpected_model_target_responses(
416421
handler=_handle_real_model_target_response,

‎tests/mock_vws/test_model_target_retries.py‎

Lines changed: 102 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,26 @@
44
layered on it, which cannot be provoked against a backend on demand.
55
"""
66

7-
import time
87
from http import HTTPMethod, HTTPStatus
98

109
import pytest
1110
import requests
1211
import responses
1312
from beartype import beartype
13+
from tenacity import Retrying
14+
from tenacity.retry import retry_if_exception_type
15+
from tenacity.stop import stop_after_attempt
16+
from tenacity.wait import wait_none
1417

18+
from tests.mock_vws.fixtures.real_model_target_retries import (
19+
MODEL_TARGET_RETRY_ATTEMPTS,
20+
REAL_MODEL_TARGET_RETRY_POLICY,
21+
)
22+
from tests.mock_vws.fixtures.vuforia_backends import VuforiaBackend
1523
from tests.mock_vws.utils import ModelTargetEndpoint
1624
from tests.mock_vws.utils.model_target_retries import (
17-
MODEL_TARGET_RETRY_ATTEMPTS,
1825
model_target_get,
19-
retrying_transient_real_backend_failures,
26+
retrying_model_target_requests,
2027
send_with_transient_retries,
2128
)
2229

@@ -31,25 +38,25 @@ def _response(*, status_code: HTTPStatus) -> requests.Response:
3138
return response
3239

3340

34-
@beartype
35-
def _do_not_sleep(seconds: float) -> None:
36-
"""Do not sleep."""
37-
del seconds
41+
@pytest.fixture(name="retry_policy")
42+
def fixture_retry_policy() -> Retrying:
43+
"""Use the real retry criteria without waiting between attempts."""
44+
return REAL_MODEL_TARGET_RETRY_POLICY.copy(wait=wait_none())
3845

3946

40-
@pytest.fixture(autouse=True, name="no_sleep")
41-
def fixture_no_sleep(*, monkeypatch: pytest.MonkeyPatch) -> None:
42-
"""Do not wait between attempts."""
43-
monkeypatch.setattr(target=time, name="sleep", value=_do_not_sleep)
47+
@pytest.fixture(name="_model_target_backend")
48+
def fixture_model_target_backend() -> VuforiaBackend:
49+
"""Select the real policy without credential lookup or requests."""
50+
return VuforiaBackend.REAL
4451

4552

46-
def test_transient_response_is_retried() -> None:
53+
def test_transient_response_is_retried(*, retry_policy: Retrying) -> None:
4754
"""A transient gateway response is followed by another attempt,
4855
and the response to that attempt is returned.
4956
"""
5057
remaining = [HTTPStatus.BAD_GATEWAY, HTTPStatus.OK]
5158

52-
with retrying_transient_real_backend_failures():
59+
with retrying_model_target_requests(retrying=retry_policy):
5360
response = send_with_transient_retries(
5461
method=HTTPMethod.GET,
5562
send=lambda: _response(status_code=remaining.pop(0)),
@@ -58,7 +65,7 @@ def test_transient_response_is_retried() -> None:
5865
assert response.status_code == HTTPStatus.OK
5966

6067

61-
def test_persistent_transient_response() -> None:
68+
def test_persistent_transient_response(*, retry_policy: Retrying) -> None:
6269
"""The last response is returned rather than hidden, so that the
6370
caller's assertion reports the real status and body.
6471
"""
@@ -70,7 +77,7 @@ def send() -> requests.Response:
7077
attempts += 1
7178
return _response(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
7279

73-
with retrying_transient_real_backend_failures():
80+
with retrying_model_target_requests(retrying=retry_policy):
7481
response = send_with_transient_retries(
7582
method=HTTPMethod.GET,
7683
send=send,
@@ -80,7 +87,7 @@ def send() -> requests.Response:
8087
assert attempts == MODEL_TARGET_RETRY_ATTEMPTS
8188

8289

83-
def test_persistent_transport_failure() -> None:
90+
def test_persistent_transport_failure(*, retry_policy: Retrying) -> None:
8491
"""A transport failure on the last attempt is not swallowed."""
8592

8693
def send() -> requests.Response:
@@ -93,7 +100,7 @@ def send() -> requests.Response:
93100
raise requests.exceptions.ConnectionError(msg)
94101

95102
with (
96-
retrying_transient_real_backend_failures(),
103+
retrying_model_target_requests(retrying=retry_policy),
97104
pytest.raises(
98105
expected_exception=requests.exceptions.ConnectionError,
99106
match=r"Connection aborted\.",
@@ -102,7 +109,7 @@ def send() -> requests.Response:
102109
_ = send_with_transient_retries(method=HTTPMethod.GET, send=send)
103110

104111

105-
def test_error_response_is_not_retried() -> None:
112+
def test_error_response_is_not_retried(*, retry_policy: Retrying) -> None:
106113
"""A response which came from Vuforia rather than from the
107114
gateway is returned as it is, however unsuccessful it is.
108115
"""
@@ -114,13 +121,13 @@ def send() -> requests.Response:
114121
attempts += 1
115122
return _response(status_code=HTTPStatus.UNAUTHORIZED)
116123

117-
with retrying_transient_real_backend_failures():
124+
with retrying_model_target_requests(retrying=retry_policy):
118125
_ = send_with_transient_retries(method=HTTPMethod.GET, send=send)
119126

120127
assert attempts == 1
121128

122129

123-
def test_mutating_request_is_not_retried() -> None:
130+
def test_mutating_request_is_not_retried(*, retry_policy: Retrying) -> None:
124131
"""A request which is not safe to repeat is sent exactly once,
125132
even when it fails transiently.
126133
@@ -135,13 +142,13 @@ def send() -> requests.Response:
135142
attempts += 1
136143
return _response(status_code=HTTPStatus.BAD_GATEWAY)
137144

138-
with retrying_transient_real_backend_failures():
145+
with retrying_model_target_requests(retrying=retry_policy):
139146
_ = send_with_transient_retries(method=HTTPMethod.POST, send=send)
140147

141148
assert attempts == 1
142149

143150

144-
def test_configured_failure_is_not_retried() -> None:
151+
def test_configured_failure_is_not_retried(*, retry_policy: Retrying) -> None:
145152
"""A 5xx response is returned immediately outside the context.
146153
147154
Tests which configure a mock to fail with a 5xx expect exactly
@@ -157,7 +164,7 @@ def send() -> requests.Response:
157164

158165
# Enter and leave the context first, to check it is switched off
159166
# again for a mock backend test which runs after a real one.
160-
with retrying_transient_real_backend_failures():
167+
with retrying_model_target_requests(retrying=retry_policy):
161168
pass
162169

163170
response = send_with_transient_retries(
@@ -170,7 +177,7 @@ def send() -> requests.Response:
170177

171178

172179
@responses.activate
173-
def test_endpoint_send() -> None:
180+
def test_endpoint_send(*, retry_policy: Retrying) -> None:
174181
"""A ``GET`` endpoint retries a transient failure.
175182
176183
This is the path which the cross-cutting Model Target endpoint tests
@@ -193,7 +200,7 @@ def test_endpoint_send() -> None:
193200
takes_json_body=False,
194201
)
195202

196-
with retrying_transient_real_backend_failures():
203+
with retrying_model_target_requests(retrying=retry_policy):
197204
response = endpoint.send()
198205

199206
assert response.status_code == HTTPStatus.UNAUTHORIZED
@@ -202,7 +209,7 @@ def test_endpoint_send() -> None:
202209

203210

204211
@responses.activate
205-
def test_model_target_get() -> None:
212+
def test_model_target_get(*, retry_policy: Retrying) -> None:
206213
"""``model_target_get`` retries a transient failure."""
207214
gateway_response = responses.Response(
208215
method=responses.GET, url=_URL, status=HTTPStatus.GATEWAY_TIMEOUT
@@ -213,9 +220,77 @@ def test_model_target_get() -> None:
213220
assert responses.add(gateway_response) is gateway_response
214221
assert responses.add(dataset_response) is dataset_response
215222

216-
with retrying_transient_real_backend_failures():
223+
with retrying_model_target_requests(retrying=retry_policy):
217224
response = model_target_get(url=_URL, headers={}, timeout=30)
218225

219226
assert response.content == b"dataset"
220227
assert gateway_response.call_count == 1
221228
assert dataset_response.call_count == 1
229+
230+
231+
@pytest.mark.usefixtures("verify_model_target_mock_vuforia")
232+
def test_real_adapter_installs_retry_policy() -> None:
233+
"""Real adapter setup installs its policy without account access."""
234+
remaining = [HTTPStatus.BAD_GATEWAY, HTTPStatus.OK]
235+
response = send_with_transient_retries(
236+
method=HTTPMethod.GET,
237+
send=lambda: _response(status_code=remaining.pop(0)),
238+
)
239+
assert response.status_code == HTTPStatus.OK
240+
assert not bool(remaining)
241+
242+
243+
def test_nested_retry_policies() -> None:
244+
"""Nested policies restore the outer policy after a raised
245+
exception.
246+
"""
247+
outer = Retrying(
248+
retry=retry_if_exception_type(exception_types=ValueError),
249+
stop=stop_after_attempt(max_attempt_number=2),
250+
wait=wait_none(),
251+
reraise=True,
252+
)
253+
inner = outer.copy(
254+
retry=retry_if_exception_type(exception_types=KeyError),
255+
)
256+
error = ValueError("Controlled failure")
257+
response = _response(status_code=HTTPStatus.OK)
258+
remaining: list[ValueError | requests.Response] = [
259+
error,
260+
response,
261+
error,
262+
error,
263+
response,
264+
error,
265+
]
266+
267+
def send() -> requests.Response:
268+
"""Return the next controlled response or raise its exception."""
269+
outcome = remaining.pop(0)
270+
if isinstance(outcome, ValueError):
271+
raise outcome
272+
return outcome
273+
274+
with retrying_model_target_requests(retrying=outer):
275+
assert (
276+
send_with_transient_retries(method=HTTPMethod.GET, send=send)
277+
is response
278+
)
279+
with (
280+
pytest.raises(
281+
expected_exception=ValueError, match="Controlled failure"
282+
) as exc,
283+
retrying_model_target_requests(retrying=inner),
284+
):
285+
_ = send_with_transient_retries(method=HTTPMethod.GET, send=send)
286+
assert exc.value is error
287+
assert (
288+
send_with_transient_retries(method=HTTPMethod.GET, send=send)
289+
is response
290+
)
291+
with pytest.raises(
292+
expected_exception=ValueError, match="Controlled failure"
293+
) as exc:
294+
_ = send_with_transient_retries(method=HTTPMethod.GET, send=send)
295+
assert exc.value is error
296+
assert not bool(remaining)

0 commit comments

Comments
 (0)