Skip to content

Read an accepted update's outcome later by update id (#212) - #309

Merged
rmcdaniel merged 2 commits into
mainfrom
fix/212-update-outcome
Nov 13, 2026
Merged

rmcdaniel merged 2 commits into
mainfrom
fix/212-update-outcome

Conversation

@rmcdaniel

Copy link
Copy Markdown
Member

Part of durable-workflow/.github#212 (the sdk-python slice). Builds on the native read route from server#1131 (native 3.6.0), and follows the PHP reference in sdk-php 3.15.0.

What changed

  • Client.update_outcome(workflow_id, update_id, *, wait_for="completed", wait_timeout_seconds=None, result_type=None) reads an accepted update by id with GET /api/workflows/{workflow_id}/updates/{update_id} and returns the decoded result, including None.
    • A pending update (202 update_status: "accepted", with or without wait_timed_out) raises the new UpdateStillPending. It carries workflow_id, run_id, update_name, update_id, request_id, update_status, wait_timed_out and body, and its message says to call update_outcome() again with the update id. A pending update is never returned as None.
    • A failed update (422) raises UpdateFailed. A rejected update keeps its original rejection status and raises UpdateRejected, as submitting it does. A 404 update_not_found raises the new UpdateNotFound, a subclass of UpdateFailed, so it is not mistaken for WorkflowNotFound.
  • Client.update_outcome_receipt(..., wait_for="accepted", ...) returns the raw receipt.
  • Capability gate. Both methods first check capabilities.update_outcome_reads === true in cluster info. If it is missing or false, they raise RuntimeCapabilityUnsupported naming that path. Only the discovery request is made: nothing is read and nothing is resubmitted. Both methods also check that wait_for is accepted or completed and that the update id is not empty, before sending any request.
  • Request details. Path segments are percent-encoded, and wait_for=completed and wait_timeout_seconds go in the query string. The route /workflows/{workflow_id}/updates/{update_id} is added to the bounded metrics route registry.
  • Other entry points. WorkflowHandle.update_outcome() / update_outcome_receipt() and the sync Client / SyncWorkflowHandle wrappers are added.
  • update_workflow. Its return type is unchanged; it still returns the receipt. Its docstring (and WorkflowHandle.update's) now says it returns the receipt and not the decoded result, that the Server answers at accepted when wait_for is omitted, how to tell pending from a completed None, and that update_outcome() reads the result later. No existing code path decoded update results, so no pending-to-None collapse needed fixing.
  • CHANGELOG (Unreleased) and docs/sdk-reference.md are updated.

Tests

tests/test_update_outcome.py uses httpx.MockTransport and covers:

  • completed with a value and with None
  • result_type decoding
  • a pending read after a timed-out wait, a plain pending read, and a re-read that returns the result
  • an accepted and a completed receipt
  • a failed update (422), update_not_found (404), and rejected updates (409/404/422)
  • older Servers: capability missing, false, or no capabilities block, for both methods. Only discovery runs.
  • wait_for and update-id validation, with no requests sent
  • GET method, path and query string
  • the metrics route
  • handle and sync wrappers

Locally in Docker on Python 3.12: ruff, mypy, the Avro benchmark gate, the full unit suite (5945 passed), the regression-corpus policy, and the package build/metadata/smoke checks all pass.

🤖 Generated with Claude Code

Client.update_outcome() and update_outcome_receipt() call
GET /workflows/{workflow_id}/updates/{update_id} (server#1131) on a
Server that advertises capabilities.update_outcome_reads. They never
resubmit the update: without the capability they raise
RuntimeCapabilityUnsupported after discovery. A pending read raises the
new UpdateStillPending (never a None result) with a read-again-by-id
hint; a failed update raises UpdateFailed, a rejected one
UpdateRejected, and 404 update_not_found the new UpdateNotFound
(an UpdateFailed). WorkflowHandle and the sync client/handle get the
same methods. update_workflow's docstring now states that it returns
the receipt and that the Server defaults to wait_for=accepted.

Part of durable-workflow/.github#212.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rmcdaniel

Copy link
Copy Markdown
Member Author

Security PASS at d9d9349, with a recommended follow-up. It's GET only with no body. The transport retries can only repeat the GET, so nothing resubmits the update. It uses the same _request/_headers (bearer, X-Namespace, version) and quote(x, safe='') segment encoding. The capability gate fails closed on a discovery failure or a malformed shape, and a plain 404 never maps to UpdateNotFound, which requires reason == update_not_found.

Recommended, matching the Rust change request: refuse empty, . and .. ids. Today an update id .. collapses to GET /api/workflows/{wf}, the describe route. update_outcome then raises a misleading UpdateStillPending (never a false result, because you require completed or an envelope), but update_outcome_receipt returns the workflow description as if it were the receipt. Also check the receipt's update_id matches.

Non-blocking:

  1. The read keeps the 60 s client timeout regardless of wait_timeout_seconds, so waits of 60 s or more time out and repeat the GET. Lengthen the timeout as Rust and the CLI do.
  2. The cached-cluster-info gap is safe: an older-then-upgraded server gives a false refusal, and a downgraded one gives a non-typed 404. It's fine as is.
  3. _raise_for_update_outcome_read passes the raw workflow id as the context to the generic mapper, so an id containing "workflow" can surface a 404 as WorkflowNotFound. Pass a fixed context string instead.

Refuse an empty, "." or ".." workflow or update id (and an invalid
wait_timeout_seconds) before any request, so ".." cannot resolve onto
the workflow describe route. A 2xx answer must name the requested
update_id, carry a string update_status and match an echoed
workflow_id; update_outcome() returns a value only from a completed
receipt. Anything else raises the new UnrecognizedUpdateOutcome. A
wait_timeout_seconds read extends the HTTP timeout to the wait plus
30 s when that exceeds the client timeout.

Part of durable-workflow/.github#212.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@rmcdaniel

Copy link
Copy Markdown
Member Author

Security PASS at d655753, on green CI. My recommendations are in: empty, . and .. workflow and update ids raise ValueError before any request. The receipt must name the requested update_id, carry a string update_status, and match workflow_id when echoed, otherwise UnrecognizedUpdateOutcome. Unknown statuses no longer pass as pending. The HTTP timeout becomes max(client, wait + 30 s), so long waits aren't cut off and retried.

@rmcdaniel
rmcdaniel merged commit 2174d85 into main Nov 13, 2026
21 checks passed
rmcdaniel added a commit that referenced this pull request Nov 13, 2026
Brings in #308 (typed completion-size 413) and #309 (update outcomes).
Only CHANGELOG.md conflicted; every Unreleased entry is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant