Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `Client.update_outcome()` and `Client.update_outcome_receipt()` read an accepted update's outcome later by its update id with `GET /api/workflows/{workflow_id}/updates/{update_id}`, and never submit the update again (durable-workflow/.github#212, server#1131). `update_outcome()` waits for the outcome by default and returns the decoded result, including `None`, with optional `result_type` decoding. A still-pending update raises the new `UpdateStillPending`, which carries `workflow_id`, `run_id`, `update_name`, `update_id`, `request_id` and `update_status` and tells the caller to read again by update id; it is never returned as `None`. A failed update raises `UpdateFailed`, a rejected one `UpdateRejected`, and an unknown or pruned id the new `UpdateNotFound` (a subclass of `UpdateFailed`). `update_outcome_receipt()` returns the raw receipt and answers at once by default. Both need a Server that advertises `capabilities.update_outcome_reads` (native 3.6.0 and later); on an older Server they raise `RuntimeCapabilityUnsupported` after discovery, without reading or resubmitting anything. `WorkflowHandle` and the synchronous client and handle have the same methods.

- Update outcome reads check the request and answer. An empty, `.` or `..` workflow or update id, an unknown `wait_for` or a non-positive `wait_timeout_seconds` raises `ValueError` before any request, including discovery, so `..` can never resolve onto the workflow's describe route. A successful answer must name the requested `update_id`, carry a string `update_status` and, when it echoes a `workflow_id`, match it. `update_outcome()` returns a value only for a completed receipt. Any other answer raises the new `UnrecognizedUpdateOutcome` with `workflow_id`, `update_id` and `body`, and never returns a value. With `wait_timeout_seconds`, the HTTP timeout becomes that wait plus 30 seconds whenever that is longer than the client's `timeout`, so a long Server-side wait is not cut off.

### Changed

- The `Client.update_workflow()` and `WorkflowHandle.update()` docstrings now say what they always did: they return the Server receipt, not the decoded result, and the Server answers at `wait_for="accepted"` when it is omitted. A receipt with `update_status: "accepted"` is a pending update even though its `result` is `None`.

## [2.16.0] - 2026-11-12

### Added
Expand Down
53 changes: 53 additions & 0 deletions docs/sdk-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,59 @@ workers, and unsupported capability paths raise `UpdateValidationFailed` with
the Server's typed reason and retryability. `wait_for="completed"` additionally
waits for the accepted update handler to reach its terminal outcome.

`Client.update_workflow()` (and `WorkflowHandle.update()`) returns the Server
receipt, not the decoded handler result. When `wait_for` is omitted the Server
answers at `accepted`. Read the receipt's `update_status`: `"completed"` carries
the decoded value in `result`, which may be `None`; `"accepted"` means the
update is still pending and has no result yet, even though `result` is `None`.
`wait_timed_out` is `True` when a `wait_for="completed"` wait ran out first.

To read the outcome later, keep the receipt's `update_id` and call
`Client.update_outcome()` (or `WorkflowHandle.update_outcome()`). It reads the
update by id and never submits it again, so it cannot admit a second update:

```python
from durable_workflow import UpdateNotFound, UpdateStillPending

receipt = await client.update_workflow("order-1", "rename", args=["new name"])
update_id = receipt["update_id"]

try:
result = await client.update_outcome("order-1", update_id, wait_timeout_seconds=30)
except UpdateStillPending as pending:
... # still running: call update_outcome() again with pending.update_id
except UpdateNotFound:
... # no retained update has this id, for example after pruning
```

`update_outcome()` waits for the outcome by default and returns the decoded
result, including `None` (`result_type=` decodes it to an annotation). A
pending update raises `UpdateStillPending` with `workflow_id`, `run_id`,
`update_name`, `update_id`, `request_id` and `update_status`; it is never
returned as `None`. A failed update raises `UpdateFailed`, a rejected one
`UpdateRejected`, and an unknown or pruned id `UpdateNotFound` (a subclass of
`UpdateFailed`; it does not prove the update was never admitted).
`update_outcome_receipt()` returns the raw receipt instead and answers at once
unless you pass `wait_for="completed"`. The read follows the update across
continue-as-new: the receipt's `run_id` names the run that owns it now.

Both methods refuse an empty, `.` or `..` workflow or update id, an unknown
`wait_for` and a non-positive `wait_timeout_seconds` with `ValueError` before
sending anything. They accept an answer only when it names the requested
`update_id`, carries a string `update_status` and, if it echoes a
`workflow_id`, names the requested workflow. `update_outcome()` returns a value
only from a completed receipt. Any other answer raises `UnrecognizedUpdateOutcome`
(with `workflow_id`, `update_id` and `body`) instead of a value. When you pass
`wait_timeout_seconds`, the HTTP timeout for that read becomes the wait plus 30
seconds if that is longer than the client's `timeout`, so the client does not
cut off a long Server-side wait.

Both need a Server that advertises `capabilities.update_outcome_reads: true`
in `GET /api/cluster/info` (native Server 3.6.0 and later). On an older Server
they raise `RuntimeCapabilityUnsupported` after discovery, without reading or
resubmitting the update. The synchronous `durable_workflow.sync.Client` and
its workflow handles offer the same methods.

Malformed signal and query payloads are reported as typed client errors with
the server's documented reason and status preserved:

Expand Down
6 changes: 6 additions & 0 deletions src/durable_workflow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,11 @@
SignalFailed,
TypedValueError,
Unauthorized,
UnrecognizedUpdateOutcome,
UpdateFailed,
UpdateNotFound,
UpdateRejected,
UpdateStillPending,
UpdateValidationFailed,
WorkflowAlreadyStarted,
WorkflowCancelled,
Expand Down Expand Up @@ -411,8 +414,11 @@
"SignalFailed",
"TransportRetryPolicy",
"Unauthorized",
"UnrecognizedUpdateOutcome",
"UpdateFailed",
"UpdateNotFound",
"UpdateRejected",
"UpdateStillPending",
"UpdateValidationFailed",
"WorkflowAlreadyStarted",
"WorkflowCancelled",
Expand Down
1 change: 1 addition & 0 deletions src/durable_workflow/_metric_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
*(f"/workflows/{{workflow_id}}/{action}"
for action in ("archive", "cancel", "repair", "terminate", "request-cancellation", "runs")),
*(f"/workflows/{{workflow_id}}/{action}/{{name}}" for action in ("signal", "query", "update")),
"/workflows/{workflow_id}/updates/{update_id}",
"/workflows/{workflow_id}/message-streams/{name}/messages",
"/workflows/{workflow_id}/runs/{run_id}",
*(f"/workflows/{{workflow_id}}/runs/{{run_id}}/{action}"
Expand Down
Loading
Loading