Skip to content

Commit aaff540

Browse files
Additional hardening for transient exceptions, fix intercepted exception (try/finally instead of try/catch/reraise)
1 parent fe86af0 commit aaff540

4 files changed

Lines changed: 126 additions & 24 deletions

File tree

tests/integration/test_async_lease_extension.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,39 @@
3838
from conductor.client.http.models.task_def import TaskDef
3939
from conductor.client.http.models.workflow_task import WorkflowTask
4040
from conductor.client.http.models.start_workflow_request import StartWorkflowRequest
41+
from conductor.client.http.rest import ApiException
4142
from conductor.client.orkes.orkes_workflow_client import OrkesWorkflowClient
4243
from conductor.client.orkes.orkes_metadata_client import OrkesMetadataClient
4344

4445
logging.basicConfig(level=logging.INFO)
4546
logger = logging.getLogger(__name__)
4647

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+
4774
# Short response timeout — task must heartbeat to stay alive
4875
RESPONSE_TIMEOUT_SECONDS = 10
4976

@@ -220,9 +247,11 @@ def _register_workflow(self, wf_name, task_names):
220247
))
221248
workflow._tasks = tasks
222249
try:
223-
self.metadata_client.update_workflow_def(workflow, overwrite=True)
250+
_retry_on_transient(self.metadata_client.update_workflow_def,
251+
workflow, overwrite=True)
224252
except Exception:
225-
self.metadata_client.register_workflow_def(workflow, overwrite=True)
253+
_retry_on_transient(self.metadata_client.register_workflow_def,
254+
workflow, overwrite=True)
226255
logger.info("Registered workflow: %s", wf_name)
227256

228257
def _start_workflow(self, wf_name, job_id):
@@ -231,7 +260,8 @@ def _start_workflow(self, wf_name, job_id):
231260
req.name = wf_name
232261
req.version = 1
233262
req.input = {'job_id': job_id}
234-
wf_id = self.workflow_client.start_workflow(start_workflow_request=req)
263+
wf_id = _retry_on_transient(self.workflow_client.start_workflow,
264+
start_workflow_request=req)
235265
logger.info("Started workflow %s: %s", wf_name, wf_id)
236266
return wf_id
237267

@@ -244,11 +274,20 @@ def _wait_for_workflow(self, wf_id, timeout_seconds=90):
244274
bare status mismatch.
245275
"""
246276
for _ in range(timeout_seconds):
247-
wf = self.workflow_client.get_workflow(wf_id, include_tasks=True)
277+
try:
278+
wf = self.workflow_client.get_workflow(wf_id, include_tasks=True)
279+
except ApiException as e:
280+
# A transient blip on a single poll shouldn't abort the wait;
281+
# keep polling until the budget is exhausted.
282+
if _is_transient(e):
283+
time.sleep(1)
284+
continue
285+
raise
248286
if wf.status in self.TERMINAL_STATES:
249287
return wf
250288
time.sleep(1)
251-
wf = self.workflow_client.get_workflow(wf_id, include_tasks=True)
289+
wf = _retry_on_transient(self.workflow_client.get_workflow,
290+
wf_id, include_tasks=True)
252291
if wf.status not in self.TERMINAL_STATES:
253292
print(f" [diag] workflow {wf_id} still {wf.status} "
254293
f"after {timeout_seconds}s")

tests/integration/test_lease_extension.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,39 @@
3636
from conductor.client.http.models.task_def import TaskDef
3737
from conductor.client.http.models.workflow_task import WorkflowTask
3838
from conductor.client.http.models.start_workflow_request import StartWorkflowRequest
39+
from conductor.client.http.rest import ApiException
3940
from conductor.client.orkes.orkes_workflow_client import OrkesWorkflowClient
4041
from conductor.client.orkes.orkes_metadata_client import OrkesMetadataClient
4142

4243
logging.basicConfig(level=logging.INFO)
4344
logger = logging.getLogger(__name__)
4445

46+
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+
4572
# Short response timeout — task must heartbeat to stay alive
4673
RESPONSE_TIMEOUT_SECONDS = 10
4774

@@ -129,9 +156,11 @@ def _register_workflow(self, wf_name, task_name):
129156
)
130157
workflow._tasks = [task]
131158
try:
132-
self.metadata_client.update_workflow_def(workflow, overwrite=True)
159+
_retry_on_transient(self.metadata_client.update_workflow_def,
160+
workflow, overwrite=True)
133161
except Exception:
134-
self.metadata_client.register_workflow_def(workflow, overwrite=True)
162+
_retry_on_transient(self.metadata_client.register_workflow_def,
163+
workflow, overwrite=True)
135164
logger.info("Registered workflow: %s", wf_name)
136165

137166
def _start_workflow(self, wf_name, job_id):
@@ -140,7 +169,8 @@ def _start_workflow(self, wf_name, job_id):
140169
req.name = wf_name
141170
req.version = 1
142171
req.input = {'job_id': job_id}
143-
wf_id = self.workflow_client.start_workflow(start_workflow_request=req)
172+
wf_id = _retry_on_transient(self.workflow_client.start_workflow,
173+
start_workflow_request=req)
144174
logger.info("Started workflow %s: %s", wf_name, wf_id)
145175
return wf_id
146176

@@ -153,11 +183,20 @@ def _wait_for_workflow(self, wf_id, timeout_seconds=60):
153183
bare status mismatch.
154184
"""
155185
for i in range(timeout_seconds):
156-
wf = self.workflow_client.get_workflow(wf_id, include_tasks=True)
186+
try:
187+
wf = self.workflow_client.get_workflow(wf_id, include_tasks=True)
188+
except ApiException as e:
189+
# A transient blip on a single poll shouldn't abort the wait;
190+
# keep polling until the budget is exhausted.
191+
if _is_transient(e):
192+
time.sleep(1)
193+
continue
194+
raise
157195
if wf.status in self.TERMINAL_STATES:
158196
return wf
159197
time.sleep(1)
160-
wf = self.workflow_client.get_workflow(wf_id, include_tasks=True)
198+
wf = _retry_on_transient(self.workflow_client.get_workflow,
199+
wf_id, include_tasks=True)
161200
if wf.status not in self.TERMINAL_STATES:
162201
print(f" [diag] workflow {wf_id} still {wf.status} "
163202
f"after {timeout_seconds}s")

tests/integration/test_workflow_client_intg.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,26 +33,47 @@ def get_configuration():
3333
return configuration
3434

3535

36+
def _first_transient_api_exception(exc):
37+
"""Walk the exception chain (cause/context) and return the first transient
38+
ApiException (status 0 / flagged transient), if any.
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), so we follow that chain.
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+
3656
def _run_tolerating_transient(label, func, *args, retries=3, **kwargs):
3757
"""Run a sub-suite, retrying only on a transient (status 0) transport blip
3858
against the shared dev server.
3959
40-
A `(0)` ApiException is a raw connection/protocol hiccup (stale keep-alive,
41-
HTTP/2 GOAWAY race, client closed by a fork cleanup, etc.) — not a real
42-
assertion or server failure — and was observed flaking the `test-all`
43-
bucket. Retrying absorbs that network noise while still surfacing genuine
44-
failures immediately (any non-transient error, or a transient one on the
45-
final attempt, re-raises).
60+
A `(0)` ApiException is a raw connection/protocol hiccup (read timeout,
61+
stale keep-alive, HTTP/2 GOAWAY race, client closed by a fork cleanup, etc.)
62+
— not a real assertion or server failure — and was observed flaking the
63+
`test-all` bucket. Retrying absorbs that network noise while still surfacing
64+
genuine failures immediately (any non-transient error, or a transient one on
65+
the final attempt, re-raises). We inspect the whole exception chain because
66+
sub-suites may wrap the ApiException in a generic Exception.
4667
"""
4768
for attempt in range(retries):
4869
try:
4970
return func(*args, **kwargs)
50-
except ApiException as e:
51-
transient = getattr(e, 'transient', False) or e.status in (0, None)
52-
if transient and attempt < retries - 1:
71+
except Exception as e:
72+
transient = _first_transient_api_exception(e)
73+
if transient is not None and attempt < retries - 1:
5374
logger.warning(
5475
'transient (%s) API error in %s (attempt %d/%d): %s; retrying',
55-
e.status, label, attempt + 1, retries, e)
76+
transient.status, label, attempt + 1, retries, transient)
5677
time.sleep(2 ** attempt)
5778
continue
5879
raise

tests/integration/workflow/test_workflow_execution.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ def run_workflow_execution_tests(configuration: Configuration, workflow_executor
4545
import_modules=['tests.integration.resources.worker.python.python_worker']
4646
)
4747
task_handler.start_processes()
48+
# Use try/finally (not try/except+re-raise): the sole purpose here is to
49+
# stop the workers on the way out. Re-raising as a bare `Exception` used to
50+
# discard the original type and traceback, which hid transient
51+
# ApiException(status=0) transport blips from the caller's retry logic
52+
# (they'd surface as an opaque generic Exception instead). finally cleans up
53+
# on both success and failure while letting the original error propagate.
4854
try:
4955
scenario_get_workflow_by_correlation_ids(workflow_executor)
5056
logger.debug('finished workflow correlation ids test')
@@ -64,11 +70,8 @@ def run_workflow_execution_tests(configuration: Configuration, workflow_executor
6470
logger.debug('finished execute_workflow error handling tests')
6571
run_signal_tests(configuration, workflow_executor)
6672
logger.debug('finished signal API tests')
67-
68-
except Exception as e:
73+
finally:
6974
task_handler.stop_processes()
70-
raise Exception(f'failed integration tests, reason: {e}')
71-
task_handler.stop_processes()
7275

7376

7477
def generate_tasks_defs():

0 commit comments

Comments
 (0)