fix(temporal): surface plugin failures for retries (supersedes #131) - #135
Open
trohitg wants to merge 7 commits into
Open
fix(temporal): surface plugin failures for retries (supersedes #131)#135trohitg wants to merge 7 commits into
trohitg wants to merge 7 commits into
Conversation
The activity failure boundary relies on ApplicationError details, activity.info().retry_policy, and temporalio.testing.ActivityEnvironment. All three exist in 1.30, but the merged dependabot bump (#111) never reached the lockfile because server/uv.lock is gitignored; pin the floor so a fresh `uv sync` resolves the version the change was verified on. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…etryable lists TEMPORAL_PLUGIN_FAILURE_RETRIES (default true) gates the new activity failure contract from the activity side, the same posture as the WORKFLOW_CONTROL_PAUSE_ON_FAILURE knobs, so flipping it never touches a recorded workflow command. false restores the pre-fix behaviour where a failure envelope is returned as a successful completion and never retried. OutputValidationError joins both non-retryable lists (an Output-contract violation is a plugin bug; re-running re-bills the work and fails the same way) and the plugin-side default also refuses Cancelled. SINGLE_ATTEMPT_RETRY is the one-attempt policy effective_retry_policy hands to mutating nodes and triggers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
annotations.readonly is about to mean "safe to re-execute on a transient failure" (BaseNode.effective_retry_policy). gmaps_create creates a record, apify_actor starts a paid actor run, and vertex_cloud_tool provisions resources, so none of them may claim readonly; emailRead only reads mail and may keep three attempts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…errors
BaseNode.as_activity returned the structured {success: False} envelope as
a successful activity completion, so Temporal never saw a failure and no
RetryPolicy applied; the legacy execute_node_activity did the same.
Every per-plugin retry_policy was dead configuration on the deployed
path.
- services/plugin/retryability.py classifies at the source, where the
exception object still exists: NodeUserError, validation, credential,
invalid-parameters, output-contract and 4xx (except 408/425/429) are
permanent; 5xx, timeouts, connection errors and unknown exceptions are
transient; a boolean `retryable` attribute on the exception or its
__cause__ wins (a NodeUserError from the LLM unifier wrapping a
rate-limited LLMError still retries). _wrap_error stamps the verdict
as `retryable`; NodeExecutor classifies whatever escapes the plugin,
and _wrap_success now runs inside _execute_body's guard so the
payload-size NodeUserError is classified instead of escaping untyped.
- services/temporal/_failures.py raises ApplicationError(type=error_type,
non_retryable=<verdict or last attempt>, details=[envelope]). Temporal
vetoes by type name before it reads non_retryable, so a retryable
failure carrying a non-retryable name is raised as "<type>.retryable".
The activity self-caps from activity.info(): min(scheduled policy,
plugin effective policy), with a pre-body refusal past the cap so a
re-dispatch after a crash never re-runs a one-attempt node.
- BaseNode.effective_retry_policy: a class-declared retry_policy wins;
triggers and mutating nodes (destructive / readonly False / no
annotations) get one attempt; readonly nodes keep three. A snapshot
fixture pins the attempt count per registered node type.
- Only the final attempt broadcasts "error"; earlier attempts stay
"executing" with attempt / max_attempts / last_error. NodeContext gains
attempt and idempotency_key (f"{workflow_run_id}-{activity_id}").
- The observability interceptor logs the plugin error_type and the retry
verdict, and demotes terminal user-correctable failures to INFO.
The boundary suite runs the real activity callable inside
temporalio.testing.ActivityEnvironment with abstract=True stubs, replacing
the first version's MagicMock patch of temporalio.activity and the stub
classes that registered themselves into the live node registry.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t the errors list str(ActivityError) is "Activity task failed"; the plugin's envelope rides on the cause. MachinaWorkflow._wait_any_complete and the AgentWorkflow tool-call handler now unwrap it with activity_failure_envelope, so errors[], the pause-on-failure reason, and the LLM tool message keep the plugin's text (the tool message also goes through _serialise_tool_result instead of an unescaped f-string JSON literal). The AgentWorkflow tool call and the taskManager preflight were scheduled with no retry policy at all, which is Temporal's unlimited default; a raised failure would have looped forever. Both now carry the tool plugin's effective policy, and MachinaWorkflow schedules node activities with effective_retry_policy, gated by machina-plugin-failure-retries-v1 so pre-patch histories replay the exact command they recorded. TemporalExecutor read result["error"] while the workflow returns errors; failed runs reported no errors at all. The SDK replay gate gains a tool-call scenario that records the new retry policy into a real history and replays it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cy, and idempotency keys Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #131 by @GammaFunds, whose commit
fix(temporal): surface plugin failures for retriesis the first commit on this branch with authorship preserved. It could not be extended in place: the PR branch predates main's rewrite of the same documentation and its fork refuses pushes that carry main's workflow-file changes, so this branch rebases the same change onto current main and adds the fix-ups the review on #131 asked for.Problem
BaseNode.as_activityreturned a plugin's structured{success: False, error_type, ...}envelope as a successful Temporal activity completion, so Temporal never saw a failure and noRetryPolicyapplied on the deployed path. Every per-pluginretry_policy, theNON_RETRYABLE_ERROR_TYPESlist, and the orchestrator's "after all retries" handling described behaviour that never ran; the in-process executor did retry, so canvas Run and deploy disagreed. The legacyexecute_node_activityhad the same gap,TemporalExecutorread a result key the workflow never returns (failed runs reportederrors: []), and exceptions escaping the plugin (the payload-size guard) reached the executor with no error type at all.What #131 got right and what it missed
The original change raises
ApplicationError(error, type=error_type)at the boundary. Three things blocked it as-is:AgentWorkflowtool calls were scheduled with no retry policy, which is Temporal's unlimited default (docs.temporal.io/encyclopedia/retry-policies), so a raised tool failure would loop forever instead of reaching the handler that hands the error to the model.str(ActivityError)is "Activity task failed"; the plugin's text lives on the cause.MachinaWorkflow._wait_any_completeand the agent loop both stringified the wrapper, losing the message inerrors[], the pause-on-failure reason, and the LLM tool message (which was also built with an unescaped f-string into a JSON literal).abstract=Truewas missing; a class-body_abstract = Trueis overwritten by__init_subclass__) and mockedtemporalio.activity, soactivity.info()was never exercised. Collected withtests/test_node_spec.pyit failed twice.And one unmanaged behaviour change: retries went live for all 148 registered node classes at once, including mutating nodes and deterministic failures.
Design (docs.temporal.io/develop/python/failure-detection, encyclopedia/retry-policies)
services/plugin/retryability.pydecidesretryablefrom the real exception andBaseNode._wrap_errorstamps it into the envelope:NodeUserError, validation, credential, invalid-parameters, output-contract and 4xx (except 408 / 425 / 429) are permanent; 5xx, timeouts, connection errors and unknown exceptions are transient; a booleanretryableattribute on the exception or its__cause__wins, which is how aNodeUserErrorfrom the LLM unifier wrapping a rate-limitedLLMErrorstill retries.NodeExecutorclassifies whatever escapes the plugin the same way, and_wrap_successnow runs inside_execute_body's guard.services/temporal/_failures.pyraisesApplicationError(type=error_type, non_retryable=..., details=[envelope]). Temporal stops retrying when either thetypeis in the schedulednon_retryable_error_typesor the error was raisednon_retryable, so a retryable failure carrying a non-retryable name is raised as<type>.retryable. The activity self-caps fromactivity.info()(min(scheduled policy, plugin effective policy)) with a pre-body refusal past the cap, so a re-dispatch after a crash or timeout never re-runs a one-attempt node, and histories already in flight with no policy are bounded too.BaseNode.effective_retry_policy(): a class-declaredretry_policywins; triggers and mutating nodes (destructive,readonly: False, or no annotations) get one attempt; read-only nodes keep three.annotations.readonlytherefore means "safe to re-execute on a transient failure"; four misannotated nodes were corrected andtests/fixtures/effective_retry_attempts_snapshot.jsonpins the result per node type (36 nodes keep three attempts, 112 get one).ActivityErrorcause withactivity_failure_envelope; the model receives the failure envelope as JSON through_serialise_tool_result. The tool call and the taskManager preflight carry the tool plugin's policy, and MachinaWorkflow schedules the effective policy, all underworkflow.patched("machina-plugin-failure-retries-v1")so pre-patch histories replay the exact command they recorded.error; earlier attempts stayexecutingwithattempt/max_attempts/last_error(no new status string, no client change).NodeContext.attemptandNodeContext.idempotency_key(f"{workflow_run_id}-{activity_id}", per the Temporal Python docs) are available to plugins that opt into retries.TEMPORAL_PLUGIN_FAILURE_RETRIES=false(newSettingsfield, default on, evaluated activity-side) returns the envelope as a successful completion, the pre-fix behaviour.temporaliofloor raised to 1.31.0 (server/uv.lockis gitignored, so the floor is the tracked artifact).Tests
tests/temporal/test_activity_failure_boundary.pyrewritten ontemporalio.testing.ActivityEnvironmentwithabstract=Truestubs: success and ToolNode flat results unchanged, pre-executed and disabled passthrough, typed failures withdetails[0], the.retryabletype suffix,retry_aftertonext_retry_delay, flag off, single terminal broadcast, executing-with-last_erroron non-final attempts, infra exceptions re-raised identically, heartbeats, the self-cap matrix, the pre-body refusal, and extras forwarding.tests/test_failure_classifier.py,tests/test_effective_retry_policy.py(with the registry snapshot),tests/temporal/test_machina_failure_unwrap.py(cause unwrapping,errors[], the pause reason end to end, patch-open and patch-closed policy resolution),tests/temporal/test_agent_tool_retry_policy.py(tool policy under the patch, the pre-patch command without one, the envelope the model sees),tests/temporal/test_executor_result_errors.py, plus extensions totest_dispatch.py(legacy activity),test_retry_policies.py,test_output_contract.py,test_plugin_contract.py.tests/temporal/test_agent_workflow_replay.py) gains a tool-call scenario that records the new retry policy into a real history and replays it.Verification on the merged tree: full default backend suite 3522 passed, 2 skipped (the
test_tikhub.pyfile, run separately, 74 passed); touched files clean underruff --select E,F,W;git diff --checkclean. No client files change.Release notes
Retries are now live on the deployed path for read-only nodes; mutating nodes and triggers get one attempt unless they declare a policy. Expect more
activity_retryWARN lines, since they now cover retryable plugin failures and not only worker crashes. A Reset is not required (the patch marker keeps pre-patch histories replayable) but is recommended for long-lived deployments so their tool calls pick up bounded retries. Rollback isTEMPORAL_PLUGIN_FAILURE_RETRIES=false.🤖 Generated with Claude Code