feat(workflows): add Durable native Activity retry - #185
Closed
Tsuyoshi Ushio (TsuyoshiUshio) wants to merge 12 commits into
Closed
feat(workflows): add Durable native Activity retry#185Tsuyoshi Ushio (TsuyoshiUshio) wants to merge 12 commits into
Tsuyoshi Ushio (TsuyoshiUshio) wants to merge 12 commits into
Conversation
This was referenced Aug 29, 2026
Tsuyoshi Ushio (TsuyoshiUshio)
marked this pull request as ready for review
September 1, 2026 01:09
Tsuyoshi Ushio (TsuyoshiUshio)
requested a review
from a team
as a code owner
September 1, 2026 01:09
Add the smallest public surface a workflow task needs to opt into Durable native retry: a bounded exponential `retry` declaration, the WorkflowRetryableError / WorkflowTerminalError signals a handler uses to classify its own failure, and a task context exposing a per-instance idempotency key that is stable across attempts. Durable's RetryPolicy has no exception predicate, so the Activity side splits the two outcomes explicitly: retryable failures raise a private versioned marker so Durable schedules the next attempt, terminal ones return a structured outcome so it does not. Models read back out of Durable history ignore unknown keys so a history written by a later runtime still validates. Per-attempt timeout is deliberately absent: Durable native retry does not need it. The only bound is an internal one-hour retry_timeout ceiling. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Schedule a task through call_activity_with_retry when, and only when, the persisted orchestration input carries a frozen retry policy. The decision reads nothing from the currently deployed tool registration, so an orchestration started before a tool declared retry keeps replaying through call_activity and keeps the legacy result envelope. On exhaustion Durable raises one failure for the whole wave; match the sanitized payload back to its node by id so the workflow reports the application's own error code rather than an opaque Durable wrapper. The tool Activity becomes async so a policy-aware delivery can be awaited; synchronous handlers still run off the event loop, as they did when the Functions worker ran the Activity in a thread. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Let a tool author declare its own retry with @workflow_tool(retry=...) and carry it through discovery, the handler catalog, and the frozen per-agent plan policy. A tool declaration is authoritative at submission time and overrides a plan-authored policy, so retryability is a property of the tool rather than of what the model happened to write. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
One module covers the whole path rather than mirroring it per source file: the declaration bounds, the frozen wire shape, the raise-versus-return split, the exhaustion round-trip and everything it must refuse to decode, the orchestrator's driver selection, and submission through start_workflow. The replay case is asserted explicitly: a history with no persisted policy keeps using call_activity even after its tool starts declaring retry. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
An operations agent recovers a delayed order whose inventory reservation is temporarily unavailable. Retry is declared on the tool rather than in the prompt, so the model authors an ordinary three-task DAG and the runtime applies the policy at submission. The Blob-backed incident counter is only a deterministic stand-in for a flaky dependency; it is not part of using retry. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Describe the declaration, the retryable-versus-terminal contract, the idempotency key handlers get instead of an attempt number, and why an in-flight workflow is unaffected by a new retry declaration. Record Decisions 66-73 and both review checkpoints in FRD 0004. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A terminal policy-aware outcome now raises out of result application, so the wave's timer cleanup has to cover that path too rather than only the branch where Durable itself reported the failure. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Drive the retry sample's workflow through Durable's orchestration HTTP API so both cases are deterministic and model-free; what is under test is the runtime's retry behaviour, not an agent's ability to author a plan. Covers the two behaviours that matter: a transiently failing tool is retried and the workflow still completes with the expected result, and a tool that keeps failing exhausts its attempt budget and fails with the application's own sanitized error_code rather than an opaque Durable message. The attempt count is asserted from the sample's own incident counter. Both cases share one host because a second host contends for the task-hub lease. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Review follow-up: every orchestrator test used a fully static plan, so the dynamic scheduler's retry dispatch was never exercised even though it shares the same driver selection. Add a when-guarded plan that asserts the policy-free node keeps call_activity while the node carrying a persisted policy is handed to Durable's retry driver. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The Azure Storage backend surfaces a failed orchestration's error through the status API's output field, but the Durable Task Scheduler backend reports output: null there. Asserting only on output made the test pass or fail based on which backend the sample's host.json points at, rather than on runtime behaviour. Fall back to the host log when output is absent, and assert the attempt budget from the sample's own incident counter first, which is backend-independent. Verified against both backends: DTS emulator and Azurite. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Three gaps where a test asserted the happy path but never proved the guard. The decoder only ever saw payloads it had just written. Cover what it must refuse once past the type guard: malformed JSON, a version it does not know, an unexpected key, a success outcome, and a valid non-retryable one. Each case asserts is_caused_by() first, so a future change cannot make them pass by failing earlier than intended. The persisted schedule validators had no coverage at all. Drive four self-inconsistent histories through the Activity path and assert each fails closed as a terminal contract failure rather than being silently repaired. Finally, prove the task contextvar is reset on both non-returning exits: when a retryable failure leaves as the private marker, and when cancellation propagates unchanged instead of being converted into one. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Integrate native retry failure decoding with the Durable v2 leaf-task wave runner and update the retry tests to drive task_any selections directly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Tsuyoshi Ushio (TsuyoshiUshio)
force-pushed
the
tsuyoshiushio-workflow-native-retry
branch
from
September 2, 2026 17:19
32ae3d9 to
a8b15c8
Compare
This was referenced Sep 2, 2026
Contributor
Author
|
So many changed. So that we replace this PR to https://github.com/Azure/azure-functions-agents-runtime/pull/207/changes#diff-7e4b0d797cf1d925b351ba9c8a0cdb95ec9210f3d5f7828b1336a4dfa6ac49da |
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.
What this adds
A workflow task can opt into Durable Functions native Activity retry. Retry is declared where the knowledge of what is safe to retry actually lives — on the tool:
A plan may also author
execution.retryon a task, but a tool declaration wins, so retryability is not left to what the model happened to write.Design notes
Durable owns retry. There is no orchestrator-managed retry loop, no retry timers, and no attempt state in the scheduler — that would duplicate scheduling Durable already does.
RetryPolicyhas no exception predicate, so the Activity splits the two outcomes itself: a retryable failure raises a private versioned marker carrying a sanitized payload, and a terminal failure returns a structured outcome so Durable treats the attempt as complete. On exhaustion the sanitized failure is decoded back out ofTaskFailedError, so a workflow reports the application's ownerror_coderather than an opaque Durable message. No raw exception text reaches Durable history.Per-attempt timeout is not required by native retry, so it is not here. The only bound is an internal one-hour
retry_timeoutceiling; the authoredexecution.timeoutsurface is PR 2.Replay safety. The retry driver and the result envelope are selected from the persisted orchestration input alone, never from the currently deployed tool registration. A workflow started before its tool declared retry keeps replaying through
call_activitywith the legacy envelope — asserted explicitly by a test.Forward compatibility. Models that read the policy back out of Durable history ignore unknown keys, so PR 2 can add fields without breaking replay of workflows started on this version.
Durable Python 2.x adoption
1.x
RetryOptionscan only express a first interval and an attempt count, so an authored exponential backoff cannot be honored. The bump toazure-functions-durable==2.0.0b2is therefore intrinsic, and it carries four pieces of forced collateral — each one required, and each proven necessary by an observed failure rather than assumed:DurableFunctionsClientbinding type andTask.resultnow raising instead of returning its failure; 2.x trigger decorator typing.durable_client_inputis closed when the invocation returns, but an SSE response generator is consumed after that — a workflow-enabled agent could not start a workflow over the streaming chat route at all. The route now takes the rawdurableClientbinding config, owns one client per stream, and closes it when the stream ends.durabletaskdepends ongrpcioand the Functions Python worker bundles its own. With no customer dependency path the worker resolved a mixedgrpcio/protobufset and segfaulted (exit 139 on Linux,0xC0000005reproduced locally on Windows) while importingfunction_app.py— every E2E app failed to start, includingminimal-httpandbuiltin-endpoints, which never touch workflows. Each app with ahost.jsonnow gets a.python_packagestree pointing at one shared install, which is what a deployed app already looks like.CompositeTasksubclasses never notify their own parent — only leafCompletableTask.complete()propagates. The existingtask_any([cancel_event, task_all(wave)])nests one composite inside another, so the inner completion never reached the outer selection and the orchestrator hung after its first wave: no workflow could make progress at all under 2.x. The engine now selects over the individual wave tasks so every child of the awaited task is a leaf. Wave semantics are unchanged — results stay in wave order, cancellation still restores the wave and cancels pending timers, and the first failure in wave order is surfaced exactly astask_alldid.Extension bundle deliberately unchanged. The worker log confirms the host's
LATESTrelease channel already selects bundle 4.37.1 for the existing[4.*, 5.0.0)and[4.32.0, 5.0.0)ranges, so raising the floor is cosmetic and stays out.Real-host E2E evidence
tests/endtoend/test_workflow_native_retry_e2e.pyboots theworkflow-retry-policysample underfunc startand drives its workflow through Durable's orchestration HTTP API. Going straight to Durable keeps both cases deterministic and model-free: what is under test is the runtime's retry behaviour, not an agent's ability to author a plan. The customer sample itself is unchanged.1 — transient failure is retried, workflow completes
The sample's inventory tool raised
WorkflowRetryableErroron the first two deliveries; Durable re-delivered the Activity and the third attempt succeeded.2 — exhausted budget fails with the application's error code
The sanitized application
error_codesurvives instead of degrading to an opaqueTaskFailedError, and the incident counter shows exactlymax_attempts = 3deliveries — no more, no fewer.Both cases pass in CI on Python 3.13 and 3.14 (
agent-runtime.e2e-testsbuild 301106, 55 passed per leg).Scope
Included: the retry declaration and its bounds;
WorkflowRetryableError/WorkflowTerminalError; the per-delivery task context with an attempt-stable idempotency key; the Durable mapping and failure bridge; policy-aware Activity execution; orchestrator dispatch and replay compatibility;@workflow_tool(retry=...)plumbing; theworkflow-retry-policysample; unit + real-host E2E tests; docs and FRD 0004 Decisions 66-73.Deliberately excluded:
execution.timeout,continue_on_error, workflow-task telemetry, statusschema_version3/4 and the UI status cards, the read-only Skill approval change, and the extension-bundle floor sweep.Dependency lock
uv.lockis relocked because the docs workflow syncs with--locked. Resolution addsdurabletaskand itsgrpcio/protobuf/asynciodependencies, dropsfurlandorderedmultidict, and movesazure-functionsfrom 2.1.0 to 2.3.0 — forced, becauseazure-functions-durable==2.0.0b2requiresazure-functions>=2.3.0b2. Nothing else is upgraded. Artifact URLs for unchanged packages are kept as already committed, because the Microsoft feed proxy hands out a different mirror host on every resolution and would otherwise rewrite all ~900 URLs for no change in content.Validation
ruff check src tests— cleanmypy src(strict) — cleanpytest --cache-clear --cov=./src/azure_functions_agents --cov-report=xml --cov-branch tests— 1134 passed; new modules at 85%+pytest -m e2e tests/endtoend/test_workflow_native_retry_e2e.py— 2 passed, evidence abovepytest -m e2e tests/endtoend/test_apps_start.py— all curated E2E apps startuv lock --checkclean;uv sync --extra docs --no-install-project --lockedanduv run --no-sync mkdocs build --strictboth passagent-runtime.e2e-tests— green on Python 3.13 and 3.14About the red
agent-runtime.public-buildunit jobThose 7
tests/test_trigger_serialization.pyfailures are a pre-existing broken baseline onmain, not a regression from this branch:main@50ae6c1bmain@94f92130(current HEAD)The failing set is identical; this branch only raises the passing count. The trigger is upstream:
azure-functions2.3.0 was published on 2026-08-28 16:14 UTC, and the repo's unconstrained>=2.1.0,<3range picked it up on the next build. 2.3.0 changed the SDK-type trigger serialization surface (body_encodingremoved, Event Hub / Service Bus metadata reshaped). Both builds install the sameazure_functions-2.3.0-py3-none-any.whl, so this PR's Durable pin is not involved.Fixed separately in #184, which this PR deliberately does not absorb; once it lands, a rebase clears these.
Related: #170 (not modified), #184 (independent baseline fix), #186 / #187 (stacked follow-ups).