Skip to content

Commit 8c0453f

Browse files
consolidate retry helper stuff
1 parent 6ac2ccd commit 8c0453f

4 files changed

Lines changed: 62 additions & 80 deletions

File tree

tests/integration/retry_helpers.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
"""Shared transient-retry helper for the aggregate ``test_all`` integration bucket.
1+
"""Shared transient-retry / flakiness helpers for the integration suites.
22
3-
The bucket runs against a shared dev server whose transport occasionally blips
3+
Originally scoped to the aggregate ``test_all`` bucket, this module is now the
4+
single home for the retry/poll primitives shared across the integration tests:
5+
``is_transient`` (the one definition of "transient transport blip"),
6+
``TERMINAL_WORKFLOW_STATES``, the per-request ``retry_on_transient``, and the
7+
scenario-level ``retry_scenario``.
8+
9+
The suites run against a shared dev server whose transport occasionally blips
410
(read timeout, "server disconnected without a response", HTTP/2 GOAWAY, stale
511
keep-alive) and surfaces as ``ApiException(status=0)``. Those are not real test
612
failures.
@@ -31,6 +37,19 @@
3137
DEFAULT_BASE_DELAY_SECONDS = 1.0
3238
DEFAULT_MAX_DELAY_SECONDS = 30.0
3339

40+
# Canonical set of terminal workflow statuses, shared by the poll-to-terminal
41+
# helpers across the integration suites so the set can't drift file to file.
42+
TERMINAL_WORKFLOW_STATES = ('COMPLETED', 'FAILED', 'TIMED_OUT', 'TERMINATED')
43+
44+
45+
def is_transient(exc):
46+
"""True when ``exc`` is a transient transport blip against the shared dev
47+
server (read timeout, connection reset, HTTP/2 GOAWAY, stale keep-alive)
48+
rather than a real failure. status 0/None means no HTTP response arrived.
49+
"""
50+
return isinstance(exc, ApiException) and (
51+
getattr(exc, 'transient', False) or exc.status in (0, None))
52+
3453

3554
def first_transient_api_exception(exc):
3655
"""Walk the exception chain (``__cause__`` / ``__context__``) and return the
@@ -46,13 +65,34 @@ def first_transient_api_exception(exc):
4665
cur = exc
4766
while cur is not None and id(cur) not in seen:
4867
seen.add(id(cur))
49-
if isinstance(cur, ApiException) and (
50-
getattr(cur, 'transient', False) or cur.status in (0, None)):
68+
if is_transient(cur):
5169
return cur
5270
cur = cur.__cause__ or cur.__context__
5371
return None
5472

5573

74+
def retry_on_transient(func, *args, retries=4, base_delay=1, **kwargs):
75+
"""Retry ``func(*args, **kwargs)`` on a transient (status 0) transport blip
76+
with capped-free exponential backoff. Non-transient errors (real 4xx/5xx,
77+
assertion failures) raise immediately.
78+
79+
This is *per-request* retry, suited to idempotent calls (get/register).
80+
For non-idempotent scenarios (start_workflow, signal) use ``retry_scenario``,
81+
which re-runs the whole scenario from scratch instead.
82+
"""
83+
for attempt in range(retries):
84+
try:
85+
return func(*args, **kwargs)
86+
except ApiException as e:
87+
if is_transient(e) and attempt < retries - 1:
88+
logger.warning(
89+
'transient (%s) API error (attempt %d/%d): %s; retrying',
90+
e.status, attempt + 1, retries, e)
91+
time.sleep(base_delay * (2 ** attempt))
92+
continue
93+
raise
94+
95+
5696
def retry_scenario(label, func, *args, deadline=None,
5797
base_delay=DEFAULT_BASE_DELAY_SECONDS,
5898
max_delay=DEFAULT_MAX_DELAY_SECONDS, **kwargs):

tests/integration/test_async_lease_extension.py

Lines changed: 7 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -42,35 +42,15 @@
4242
from conductor.client.orkes.orkes_workflow_client import OrkesWorkflowClient
4343
from conductor.client.orkes.orkes_metadata_client import OrkesMetadataClient
4444

45+
from tests.integration.retry_helpers import (
46+
TERMINAL_WORKFLOW_STATES,
47+
is_transient as _is_transient,
48+
retry_on_transient as _retry_on_transient,
49+
)
50+
4551
logging.basicConfig(level=logging.INFO)
4652
logger = logging.getLogger(__name__)
4753

48-
49-
def _is_transient(e):
50-
"""A transient (status 0) ApiException is a raw transport blip against the
51-
shared dev server (read timeout, connection reset, HTTP/2 GOAWAY, stale
52-
keep-alive) — not a real failure. status 0 means no HTTP response was
53-
received at all."""
54-
return isinstance(e, ApiException) and (
55-
getattr(e, 'transient', False) or e.status in (0, None))
56-
57-
58-
def _retry_on_transient(func, *args, retries=4, base_delay=1, **kwargs):
59-
"""Retry a workflow/metadata client call on a transient (0) transport blip.
60-
These `(0)` errors flake the lease suites on a loaded shared server; retrying
61-
absorbs the noise. Non-transient errors (real 4xx/5xx) raise immediately."""
62-
for attempt in range(retries):
63-
try:
64-
return func(*args, **kwargs)
65-
except ApiException as e:
66-
if _is_transient(e) and attempt < retries - 1:
67-
logger.warning(
68-
'transient (%s) API error (attempt %d/%d): %s; retrying',
69-
e.status, attempt + 1, retries, e)
70-
time.sleep(base_delay * (2 ** attempt))
71-
continue
72-
raise
73-
7454
# Short response timeout — task must heartbeat to stay alive
7555
RESPONSE_TIMEOUT_SECONDS = 10
7656

@@ -284,7 +264,7 @@ def _start_workflow(self, wf_name, job_id):
284264
logger.info("Started workflow %s: %s", wf_name, wf_id)
285265
return wf_id
286266

287-
TERMINAL_STATES = ('COMPLETED', 'FAILED', 'TIMED_OUT', 'TERMINATED')
267+
TERMINAL_STATES = TERMINAL_WORKFLOW_STATES
288268

289269
def _wait_for_workflow(self, wf_id, timeout_seconds=POLL_TIMEOUT_SECONDS,
290270
poll_interval=POLL_INTERVAL_SECONDS):

tests/integration/test_lease_extension.py

Lines changed: 7 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -40,35 +40,16 @@
4040
from conductor.client.orkes.orkes_workflow_client import OrkesWorkflowClient
4141
from conductor.client.orkes.orkes_metadata_client import OrkesMetadataClient
4242

43+
from tests.integration.retry_helpers import (
44+
TERMINAL_WORKFLOW_STATES,
45+
is_transient as _is_transient,
46+
retry_on_transient as _retry_on_transient,
47+
)
48+
4349
logging.basicConfig(level=logging.INFO)
4450
logger = logging.getLogger(__name__)
4551

4652

47-
def _is_transient(e):
48-
"""A transient (status 0) ApiException is a raw transport blip against the
49-
shared dev server (read timeout, connection reset, HTTP/2 GOAWAY, stale
50-
keep-alive) — not a real failure. status 0 means no HTTP response was
51-
received at all."""
52-
return isinstance(e, ApiException) and (
53-
getattr(e, 'transient', False) or e.status in (0, None))
54-
55-
56-
def _retry_on_transient(func, *args, retries=4, base_delay=1, **kwargs):
57-
"""Retry a workflow/metadata client call on a transient (0) transport blip.
58-
These `(0)` errors flake the lease suites on a loaded shared server; retrying
59-
absorbs the noise. Non-transient errors (real 4xx/5xx) raise immediately."""
60-
for attempt in range(retries):
61-
try:
62-
return func(*args, **kwargs)
63-
except ApiException as e:
64-
if _is_transient(e) and attempt < retries - 1:
65-
logger.warning(
66-
'transient (%s) API error (attempt %d/%d): %s; retrying',
67-
e.status, attempt + 1, retries, e)
68-
time.sleep(base_delay * (2 ** attempt))
69-
continue
70-
raise
71-
7253
# Short response timeout — task must heartbeat to stay alive
7354
RESPONSE_TIMEOUT_SECONDS = 10
7455

@@ -194,7 +175,7 @@ def _start_workflow(self, wf_name, job_id):
194175
logger.info("Started workflow %s: %s", wf_name, wf_id)
195176
return wf_id
196177

197-
TERMINAL_STATES = ('COMPLETED', 'FAILED', 'TIMED_OUT', 'TERMINATED')
178+
TERMINAL_STATES = TERMINAL_WORKFLOW_STATES
198179

199180
def _wait_for_workflow(self, wf_id, timeout_seconds=POLL_TIMEOUT_SECONDS,
200181
poll_interval=POLL_INTERVAL_SECONDS):

tests/integration/workflow/test_workflow_execution.py

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from conductor.client.workflow.executor.workflow_executor import WorkflowExecutor
1313
from conductor.client.workflow.task.simple_task import SimpleTask
1414
from tests.integration.resources.worker.python.python_worker import *
15-
from tests.integration.retry_helpers import retry_scenario
15+
from tests.integration.retry_helpers import retry_scenario, TERMINAL_WORKFLOW_STATES
1616

1717
WORKFLOW_NAME = "sdk_python_integration_test_workflow"
1818
WORKFLOW_DESCRIPTION = "Python SDK Integration Test"
@@ -213,7 +213,6 @@ def _wait_for_workflow_terminal(workflow_id, workflow_executor, deadline,
213213
is logged and retried. Returns the last observed status; the caller still
214214
runs validate_workflow_status for the actual assertion.
215215
"""
216-
terminal = ('COMPLETED', 'FAILED', 'TIMED_OUT', 'TERMINATED')
217216
start = time.time()
218217
status = None
219218
while True:
@@ -224,7 +223,7 @@ def _wait_for_workflow_terminal(workflow_id, workflow_executor, deadline,
224223
except Exception as e: # transient blip against the shared server
225224
logger.warning('error polling workflow %s (%.0fs elapsed): %s',
226225
workflow_id, time.time() - start, e)
227-
if status in terminal:
226+
if status in TERMINAL_WORKFLOW_STATES:
228227
logger.info('workflow %s reached %s after %.0fs',
229228
workflow_id, status, time.time() - start)
230229
return status
@@ -475,7 +474,7 @@ def _wait_for_workflow_completion(workflow_executor: WorkflowExecutor, workflow_
475474
while time.time() - start_time < max_wait_seconds:
476475
workflow = workflow_executor.get_workflow(workflow_id, True)
477476

478-
if workflow.status in ['COMPLETED', 'FAILED', 'TERMINATED', 'TIMED_OUT']:
477+
if workflow.status in TERMINAL_WORKFLOW_STATES:
479478
logger.debug(f'Workflow {workflow_id} finished with status: {workflow.status}')
480479
return workflow
481480

@@ -888,22 +887,4 @@ def scenario_signal_to_dict_fix(workflow_executor: WorkflowExecutor):
888887

889888
_wait_for_workflow_completion(workflow_executor, workflow_id)
890889

891-
logger.info('to_dict() method test completed')
892-
893-
894-
def _wait_for_workflow_completion(workflow_executor: WorkflowExecutor, workflow_id: str, timeout: int = 10):
895-
"""Wait for workflow to complete with timeout"""
896-
max_iterations = timeout * 10 # Check every 0.1 seconds
897-
898-
for i in range(max_iterations):
899-
try:
900-
workflow = workflow_executor.get_workflow(workflow_id, include_tasks=False)
901-
if workflow.status in ["COMPLETED", "FAILED", "TERMINATED"]:
902-
logger.debug(f'Workflow {workflow_id} completed with status: {workflow.status}')
903-
return
904-
except Exception as e:
905-
logger.warning(f'Error checking workflow status: {e}')
906-
907-
time.sleep(0.1)
908-
909-
logger.warning(f'Workflow {workflow_id} did not complete within {timeout} seconds')
890+
logger.info('to_dict() method test completed')

0 commit comments

Comments
 (0)