Skip to content

Commit 6ac2ccd

Browse files
add missing file
1 parent 10dd0f3 commit 6ac2ccd

1 file changed

Lines changed: 102 additions & 0 deletions

File tree

tests/integration/retry_helpers.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Shared transient-retry helper for the aggregate ``test_all`` integration bucket.
2+
3+
The bucket runs against a shared dev server whose transport occasionally blips
4+
(read timeout, "server disconnected without a response", HTTP/2 GOAWAY, stale
5+
keep-alive) and surfaces as ``ApiException(status=0)``. Those are not real test
6+
failures.
7+
8+
Rather than re-running the whole suite on such a blip, ``test_all`` establishes
9+
a single overall wall-clock deadline once, and each *scenario* is retried on a
10+
transient blip until that deadline passes, with capped exponential backoff. Real
11+
errors (assertion failures, genuine 4xx/5xx) raise immediately.
12+
13+
Scenario-level (rather than per-request) granularity is deliberate: a retried
14+
scenario starts from scratch (fresh workflows, fresh signals), which keeps
15+
otherwise non-idempotent operations — notably ``start_workflow`` and the sync
16+
``signal`` calls whose returned ``SignalResponse`` the tests assert on — safe to
17+
re-run without duplicating side effects against a single workflow instance.
18+
"""
19+
20+
import logging
21+
import time
22+
23+
from conductor.client.http.rest import ApiException
24+
25+
logger = logging.getLogger(__name__)
26+
27+
# Default overall budget for the whole aggregate suite (all sub-suites share it).
28+
DEFAULT_OVERALL_DEADLINE_SECONDS = 600 # 10 minutes
29+
30+
# Backoff bounds between scenario retries.
31+
DEFAULT_BASE_DELAY_SECONDS = 1.0
32+
DEFAULT_MAX_DELAY_SECONDS = 30.0
33+
34+
35+
def first_transient_api_exception(exc):
36+
"""Walk the exception chain (``__cause__`` / ``__context__``) and return the
37+
first transient ``ApiException`` (flagged transient, or status 0/None), or
38+
``None`` if there isn't one.
39+
40+
Inner test helpers sometimes catch an ApiException and re-raise it as a bare
41+
``Exception`` (losing the type), so we can't rely on the outermost exception
42+
type alone; implicit chaining still records the original on ``__context__``
43+
(or ``__cause__`` when ``raise ... from`` is used).
44+
"""
45+
seen = set()
46+
cur = exc
47+
while cur is not None and id(cur) not in seen:
48+
seen.add(id(cur))
49+
if isinstance(cur, ApiException) and (
50+
getattr(cur, 'transient', False) or cur.status in (0, None)):
51+
return cur
52+
cur = cur.__cause__ or cur.__context__
53+
return None
54+
55+
56+
def retry_scenario(label, func, *args, deadline=None,
57+
base_delay=DEFAULT_BASE_DELAY_SECONDS,
58+
max_delay=DEFAULT_MAX_DELAY_SECONDS, **kwargs):
59+
"""Run ``func(*args, **kwargs)``, retrying only on a transient (status 0)
60+
transport blip until the shared ``deadline`` passes.
61+
62+
Args:
63+
label: Human-readable scenario name for logs.
64+
func: The scenario callable to run.
65+
deadline: ``time.monotonic()`` value after which we stop retrying. When
66+
``None`` (e.g. the standalone ``main.py`` runner), the scenario runs
67+
exactly once with no transient retry — preserving prior behaviour.
68+
base_delay / max_delay: capped exponential backoff bounds (seconds).
69+
70+
Non-transient errors (assertion failures, genuine 4xx/5xx) raise
71+
immediately. A transient blip at/after the deadline re-raises so the suite
72+
fails cleanly rather than hanging past the CI job timeout.
73+
"""
74+
if deadline is None:
75+
logger.info('running scenario %s (attempt 1, no transient retry)', label)
76+
return func(*args, **kwargs)
77+
78+
attempt = 0
79+
while True:
80+
logger.info('running scenario %s (attempt %d)', label, attempt + 1)
81+
try:
82+
return func(*args, **kwargs)
83+
except Exception as e:
84+
transient = first_transient_api_exception(e)
85+
if transient is None:
86+
raise
87+
now = time.monotonic()
88+
if now >= deadline:
89+
logger.error(
90+
'transient (%s) in %s but overall deadline exceeded by '
91+
'%.0fs; giving up: %s',
92+
transient.status, label, now - deadline, transient)
93+
raise
94+
delay = min(base_delay * (2 ** attempt), max_delay)
95+
delay = min(delay, max(0.0, deadline - now))
96+
logger.warning(
97+
'transient (%s) in %s (attempt %d); retrying in %.1fs '
98+
'(%.0fs left in overall budget): %s',
99+
transient.status, label, attempt + 1, delay,
100+
deadline - now, transient)
101+
time.sleep(delay)
102+
attempt += 1

0 commit comments

Comments
 (0)