Skip to content

Commit 39ecad5

Browse files
[cross-repo from server#230] TD-S114: PHP activity failures leak PHP internals into Python-facing failure payloads (#67)
1 parent 7e7bfa2 commit 39ecad5

3 files changed

Lines changed: 141 additions & 1 deletion

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,17 @@ Retry and timeout settings are scoped to the layer where you configure them:
7070

7171
Timeout names are also layer-specific. `start_to_close_timeout` limits one activity attempt, `schedule_to_start_timeout` limits queue wait before an activity starts, `schedule_to_close_timeout` limits the whole activity execution including retries, and `heartbeat_timeout` limits the gap between activity heartbeats. For child workflows, `execution_timeout_seconds` covers the overall child workflow execution and `run_timeout_seconds` covers one run.
7272

73+
## Activity failure payloads
74+
75+
When replay raises `ActivityFailed`, the top-level attributes expose the
76+
stable cross-language fields: `activity_type`, `failure_category`,
77+
`exception_type`, `message`, `non_retryable`, and `code`. The
78+
`exception_payload` dictionary is filtered to language-neutral keys such as
79+
`type`, `message`, `details`, `details_payload_codec`, and `non_retryable`.
80+
Runtime diagnostics like PHP or Python exception classes, source file paths,
81+
line numbers, and traces are not included by default unless the history event
82+
contains an explicit `diagnostics` or `runtime_diagnostics` envelope.
83+
7384
## Activity retries and timeouts
7485

7586
Configure per-call activity retries and deadlines from workflow code:

src/durable_workflow/workflow.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1845,9 +1845,35 @@ def _optional_str(value: Any) -> str | None:
18451845
return value if isinstance(value, str) and value != "" else None
18461846

18471847

1848+
_NEUTRAL_EXCEPTION_PAYLOAD_KEYS = (
1849+
"type",
1850+
"message",
1851+
"code",
1852+
"details",
1853+
"details_payload_codec",
1854+
"non_retryable",
1855+
)
1856+
_EXPLICIT_DIAGNOSTICS_KEYS = ("diagnostics", "runtime_diagnostics")
1857+
1858+
1859+
def _neutral_exception_payload(payload: Any) -> dict[str, Any] | None:
1860+
if not isinstance(payload, Mapping):
1861+
return None
1862+
1863+
neutral = {key: payload[key] for key in _NEUTRAL_EXCEPTION_PAYLOAD_KEYS if key in payload}
1864+
1865+
for key in _EXPLICIT_DIAGNOSTICS_KEYS:
1866+
diagnostics = payload.get(key)
1867+
if isinstance(diagnostics, Mapping):
1868+
neutral[key] = dict(diagnostics)
1869+
1870+
return neutral or None
1871+
1872+
18481873
def _activity_failed_from_payload(payload: Mapping[str, Any]) -> ActivityFailed:
18491874
exception_payload = payload.get("exception")
18501875
exception = dict(exception_payload) if isinstance(exception_payload, Mapping) else None
1876+
exposed_exception = _neutral_exception_payload(exception_payload)
18511877
activity_payload = payload.get("activity")
18521878
activity = dict(activity_payload) if isinstance(activity_payload, Mapping) else None
18531879

@@ -1872,7 +1898,7 @@ def _activity_failed_from_payload(payload: Mapping[str, Any]) -> ActivityFailed:
18721898
exception_class=exception_class,
18731899
non_retryable=payload.get("non_retryable") is True,
18741900
code=payload.get("code"),
1875-
exception_payload=exception,
1901+
exception_payload=exposed_exception,
18761902
activity=activity,
18771903
)
18781904

tests/test_replay.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,23 @@ def run(self, ctx: WorkflowContext, order_id: str): # type: ignore[no-untyped-d
6969
return {"charged": True}
7070

7171

72+
@workflow.defn(name="activity-failure-payload-inspector")
73+
class ActivityFailurePayloadInspector:
74+
def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
75+
try:
76+
yield ctx.schedule_activity("polyglot.php.fail", [])
77+
except ActivityFailed as exc:
78+
return {
79+
"activity_type": exc.activity_type,
80+
"failure_category": exc.failure_category,
81+
"exception_type": exc.exception_type,
82+
"non_retryable": exc.non_retryable,
83+
"message": str(exc),
84+
"exception_payload": exc.exception_payload,
85+
}
86+
return {"ok": True}
87+
88+
7289
@workflow.defn(name="two-activities")
7390
class TwoActivities:
7491
def run(self, ctx: WorkflowContext): # type: ignore[no-untyped-def]
@@ -233,6 +250,92 @@ def test_failed_activity_compensation_can_complete(self) -> None:
233250
"exception_class": "payments.PaymentDeclined",
234251
}
235252

253+
def test_php_activity_failure_payload_omits_runtime_diagnostics_by_default(self) -> None:
254+
history = [
255+
{
256+
"event_type": "ActivityFailed",
257+
"payload": {
258+
"activity_type": "polyglot.php.fail",
259+
"failure_category": "activity",
260+
"exception_type": "PolyglotPhpPlannedFailure",
261+
"exception_class": "RuntimeException",
262+
"message": "php activity planned failure",
263+
"non_retryable": True,
264+
"exception": {
265+
"class": "RuntimeException",
266+
"type": "PolyglotPhpPlannedFailure",
267+
"message": "php activity planned failure",
268+
"file": "/app/vendor/durable-workflow/workflow/src/V2/Support/FailureFactory.php",
269+
"line": 571,
270+
"trace": [
271+
{
272+
"file": "/app/vendor/durable-workflow/workflow/src/V2/Support/FailureFactory.php"
273+
}
274+
],
275+
"properties": [],
276+
"details": "encoded-details",
277+
"details_payload_codec": "avro",
278+
"non_retryable": True,
279+
},
280+
},
281+
}
282+
]
283+
284+
outcome = replay(ActivityFailurePayloadInspector, history, [])
285+
286+
assert len(outcome.commands) == 1
287+
cmd = outcome.commands[0]
288+
assert isinstance(cmd, CompleteWorkflow)
289+
assert cmd.result == {
290+
"activity_type": "polyglot.php.fail",
291+
"failure_category": "activity",
292+
"exception_type": "PolyglotPhpPlannedFailure",
293+
"non_retryable": True,
294+
"message": "php activity planned failure",
295+
"exception_payload": {
296+
"type": "PolyglotPhpPlannedFailure",
297+
"message": "php activity planned failure",
298+
"details": "encoded-details",
299+
"details_payload_codec": "avro",
300+
"non_retryable": True,
301+
},
302+
}
303+
304+
def test_activity_failure_payload_preserves_explicit_diagnostics_envelope(self) -> None:
305+
history = [
306+
{
307+
"event_type": "ActivityFailed",
308+
"payload": {
309+
"activity_type": "polyglot.php.fail",
310+
"failure_category": "activity",
311+
"exception_type": "PolyglotPhpPlannedFailure",
312+
"message": "php activity planned failure",
313+
"exception": {
314+
"type": "PolyglotPhpPlannedFailure",
315+
"message": "php activity planned failure",
316+
"runtime_diagnostics": {
317+
"class": "RuntimeException",
318+
"file": "/app/src/TimeoutActivity.php",
319+
},
320+
},
321+
},
322+
}
323+
]
324+
325+
outcome = replay(ActivityFailurePayloadInspector, history, [])
326+
327+
assert len(outcome.commands) == 1
328+
cmd = outcome.commands[0]
329+
assert isinstance(cmd, CompleteWorkflow)
330+
assert cmd.result["exception_payload"] == {
331+
"type": "PolyglotPhpPlannedFailure",
332+
"message": "php activity planned failure",
333+
"runtime_diagnostics": {
334+
"class": "RuntimeException",
335+
"file": "/app/src/TimeoutActivity.php",
336+
},
337+
}
338+
236339
def test_server_command_shape(self) -> None:
237340
outcome = replay(OneActivity, [], ["world"])
238341
cmd = outcome.commands[0]

0 commit comments

Comments
 (0)