|
| 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