Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
22a94b2
feat(api): sync durable approval operations
subnetmarco Oct 7, 2026
1b3381a
feat(durable): add durable approvals
subnetmarco Oct 7, 2026
16c21c8
fix(durable): bound approval registration retries and validate size
subnetmarco Oct 7, 2026
4c983dd
fix(durable): drop the approval retry delay the deadline never reaches
subnetmarco Oct 7, 2026
adf77d3
chore(deps): merge main into durable approvals
subnetmarco Oct 7, 2026
ecb6c7e
chore(api): sync the durable approval description from hosting
subnetmarco Oct 7, 2026
ea761ba
fix(durable): decide an approval by its status and read its details l…
subnetmarco Oct 7, 2026
52dac05
perf(durable): build the runtime wrapper once and read the execution …
subnetmarco Oct 7, 2026
747a158
fix(durable): end each approval registration attempt by its own deadline
subnetmarco Oct 7, 2026
0be2acf
test(durable): mock the API's own refusal for a project access token
subnetmarco Oct 7, 2026
c7c5e93
fix(durable): read decided_at only as an RFC 3339 date-time, as the J…
subnetmarco Oct 7, 2026
7acd400
Merge remote-tracking branch 'origin/main' into feat/durable-approvals
subnetmarco Oct 7, 2026
2979c81
test(durable): pin the approval answer cap and the retry deadline edge
subnetmarco Oct 7, 2026
424d6f4
test(durable): cover anchoring and range edges of the decision time
subnetmarco Oct 7, 2026
c8291f6
chore(openapi): declare 413 on approval decisions
subnetmarco Oct 8, 2026
9587b69
fix(durable): refuse approval names and text the platform cannot record
subnetmarco Oct 8, 2026
ad1c81a
Merge remote-tracking branch 'origin/main' into merge-appr
subnetmarco Oct 8, 2026
9f9bd42
fix(durable): report the attempt's deadline when the client's timeout…
subnetmarco Oct 8, 2026
e874397
test(realtime): give native commands two seconds to arrive
subnetmarco Oct 8, 2026
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
45 changes: 43 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,24 @@ outcome it could not determine — as finished. `stop()` is accepted rather than
awaited: what it returns is the execution read back after asking, often still
`running`, so poll `get()` to see it reach `stopped`. Repeating a stop is safe.

`durable.approvals` reads and decides the approvals a durable function waits on:

```python
approvals = owner_client.durable.approvals
page = approvals.list(project_id, status="pending", function="order-pipeline")
approval = approvals.approve(project_id, page.approvals[0].id, comment="Looks right")
stats = approvals.stats(project_id)
```

`list()`, `get()` and `stats()` accept the project owner's platform user token or
a project access token. `approve()` and `deny()` are for a person: a project
access token raises `PermissionDeniedError`, a subclass of `AuthenticationError`.
Repeating the same decision returns the approval unchanged; a conflicting one, or
deciding an approval that expired or whose execution ended, raises
`ConflictError` with `code` set to `approval_decided`, `approval_expired`, or
`approval_cancelled`. See the
[functions guide](https://github.com/Kong/volcano-sdk-python/blob/main/docs/functions.md#decide-approvals-from-a-backend).

## Write a durable function

`volcano_sdk.durable_authoring` is what the durable function itself is written
Expand Down Expand Up @@ -332,6 +350,7 @@ the clock or a random value.
| `ctx.step(name, fn, retry=..., at_most_once=...)` | Runs one atomic operation and records its result. `retry=False` fails on the first error; `RetryOptions` sets attempts and backoff. |
| `ctx.wait(name, duration)` | Suspends the execution. `"30s"`, `"2h"`, `"1m30s"`, a whole number of seconds, or `{"hours": 2}`. |
| `ctx.wait_until(check, options, name=None)` | Polls your own state until `options.until` holds, suspending between checks. `options.initial_state` is required. |
| `ctx.wait_for_approval(name, title=..., description=None, details=None, timeout=None)` | Suspends until a person approves or denies, and returns an `ApprovalDecision`. A timeout returns `status="expired"` rather than raising. |
| `ctx.map(items, fn, name=None, options=None)` | Runs the same work over every item, each in its own child context. |
| `ctx.parallel(branches, name=None, options=None)` | Runs independent branches at the same time. |
| `ctx.child(name, fn)` | Groups operations under one recorded context. |
Expand Down Expand Up @@ -372,8 +391,30 @@ volcano durable start order-pipeline --input '{"order_id":"order-9"}'
Local waits resolve immediately by default while preserving checkpoint and replay
behavior. Set `LOCAL_DURABLE_REAL_TIME=true` before `volcano start` when wait
timing must match the deployed function. Local executions persist across
`volcano stop` and `volcano start`. Volcano does not expose externally completed
callbacks; use `ctx.wait_until` to poll application state instead.
`volcano stop` and `volcano start`.

`ctx.wait_for_approval` registers the approval with Volcano and suspends until it
is decided, works the same locally, and replays the recorded decision on resume.
A denial or timeout is a value to branch on:

```python
decision = ctx.wait_for_approval(
"ship-order",
title=f"Ship order {event['order_id']}?",
details={"total": event["total"]},
timeout="24h",
)
if not decision.approved:
return {"shipped": False, "status": decision.status}
```

`decision` carries `approved`, `status` (`approved`, `denied`, or `expired`),
`comment`, `decided_by` (`id` and `email`, or `None`), and `decided_at`. Volcano
sets `VOLCANO_PLATFORM_API_URL` on durable functions; without it the call raises
`RuntimeError`. The function retries registering the approval for up to 30
seconds. If Volcano refuses it, the call raises the durable runtime's
`CallbackSubmitterError` with the SDK error's message. See the
[functions guide](https://github.com/Kong/volcano-sdk-python/blob/main/docs/functions.md#wait-for-an-approval).

`logs.search()` returns an immutable page of retained runtime or deployment log
events. Pass `next_cursor` back as `cursor` to continue a search. `logs.activity()`
Expand Down
98 changes: 97 additions & 1 deletion docs/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,101 @@ functions:
kind: durable
```

Volcano records each context operation. Resumed executions replay recorded results instead of repeating completed work. Keep changing decisions inside `ctx.step()`. Use `ctx.wait_until()` to poll application state. Volcano does not expose externally completed callbacks.
Volcano records each context operation. Resumed executions replay recorded results instead of repeating completed work. Keep changing decisions inside `ctx.step()`. Use `ctx.wait_until()` to poll application state.

## Wait for an approval

`ctx.wait_for_approval()` pauses the execution until a person approves or denies it from the dashboard, the CLI, or the [owner client](#decide-approvals-from-a-backend):

```python
from volcano_sdk.durable_authoring import durable


@durable
def handler(event, ctx):
order = ctx.step("load-order", lambda scope: load_order(event["order_id"]))

decision = ctx.wait_for_approval(
"ship-order",
title=f"Ship order {order['id']}?",
description="Orders over $500 need a second look.",
details={"order_id": order["id"], "total": order["total"]},
timeout="24h",
)

if not decision.approved:
ctx.step("release-stock", lambda scope: release_stock(order["id"]))
return {"shipped": False, "status": decision.status}

ctx.step("ship", lambda scope: ship_order(order["id"]))
return {"shipped": True, "comment": decision.comment}
```

| Argument | Description |
|---|---|
| `name` | Required. Operation name in the execution's history, up to 255 characters. |
| `title` | Required. What the approver is asked, up to 200 characters. |
| `description` | Optional context, up to 4000 characters. |
| `details` | Optional JSON value shown with the approval. The whole approval, `details` included, must encode to at most 64 KiB of JSON. |
| `timeout` | Optional duration in the `ctx.wait()` format, from one second to 366 days. Without it, the approval stays open as long as the execution runs. |

Limits count Unicode characters. A blank `name` or `title` raises `ValueError`, as does a value over its limit or an approval over 64 KiB. A value that is not a string, or that cannot be encoded as JSON, raises `TypeError`. A timeout out of range raises `TypeError`, as it does for `ctx.wait()`. These checks run before anything is recorded.

The call returns an immutable `ApprovalDecision`:

| Field | Approved | Denied | Timed out |
|---|---|---|---|
| `approved` | `True` | `False` | `False` |
| `status` | `"approved"` | `"denied"` | `"expired"` |
| `comment` | The approver's comment, or `""` | The approver's comment, or `""` | `""` |
| `decided_by` | `DurableApprovalDecider` with `id` and `email`, or `None` if the account was deleted before the decision reached the execution | Same as approved | `None` |
| `decided_at` | RFC 3339 timestamp | RFC 3339 timestamp | `None` |

A denial or a timeout is a value to branch on, not an exception. The execution costs nothing while it waits, and a resumed execution replays the recorded decision without asking again.

Volcano sets `VOLCANO_PLATFORM_API_URL` on deployed and local durable functions. The function sends the approval there without a credential. Volcano accepts it only from the execution that is waiting. When Volcano does not answer, has not seen the execution or its approval yet, throttles the request, or fails, the function retries for up to 30 seconds. An approval whose timeout passes before Volcano records it returns the `expired` decision. If Volcano refuses the approval, or keeps failing for 30 seconds, `wait_for_approval()` raises the durable runtime's `CallbackSubmitterError`, carrying the SDK error's message. Unless the handler catches it, the execution fails. Calling `wait_for_approval()` without `VOLCANO_PLATFORM_API_URL` raises `RuntimeError`.

## Decide approvals from a backend

`client.durable.approvals` lists, reads, and decides a project's approvals:

```python
import os
from datetime import UTC, datetime, timedelta

from volcano_sdk import ConflictError, VolcanoClient

owner_client = VolcanoClient(
anon_key=os.environ["VOLCANO_ANON_KEY"],
api_url=os.environ.get("VOLCANO_API_URL", "https://api.volcano.dev"),
access_token=os.environ["VOLCANO_PLATFORM_TOKEN"],
)
approvals = owner_client.durable.approvals

page = approvals.list(project_id, status="pending", function="charge-order")
for approval in page.approvals:
print(approval.id, approval.title, approval.details, approval.expires_at)

try:
approval = approvals.approve(project_id, approval_id, comment="Address checked")
except ConflictError as error:
print(error.code) # approval_decided, approval_expired, or approval_cancelled

stats = approvals.stats(project_id, from_=datetime.now(UTC) - timedelta(days=7))
print(stats.counts.pending, stats.approval_rate, stats.median_seconds_to_decision)
```

| Method | Returns |
|---|---|
| `list(project_id, status=..., function=..., execution_id=..., from_=..., to=..., page=..., limit=...)` | `DurableApprovalPage` with `approvals`, `page`, `limit`, `total`, and `has_more`. |
| `get(project_id, approval_id)` | `DurableApproval`. |
| `stats(project_id, function=..., from_=..., to=...)` | `DurableApprovalStats` with counts by status, decision times, and per-function and daily counts. |
| `approve(project_id, approval_id, comment=None)` | The decided `DurableApproval`. |
| `deny(project_id, approval_id, comment=None)` | The decided `DurableApproval`. |

`status` is one of `pending`, `approved`, `denied`, `expired`, or `cancelled`. An approval is `cancelled` when its execution ends first. `function` takes a durable function's id or name. `from_` and `to` must be timezone-aware datetimes. Stats default to the last 30 days and cover at most 366 days. A comment is up to 2000 characters.

Read approvals with the project owner's platform user token or a project access token. Only a person decides: `approve()` and `deny()` need a platform user token, and a project access token raises `PermissionDeniedError`. Repeating the same decision returns the approval unchanged. A conflicting decision, or deciding an expired or cancelled approval, raises `ConflictError`. An unknown approval raises `NotFoundError`. `PermissionDeniedError` subclasses `AuthenticationError`, so existing handlers still catch it.

## Run durable functions locally

Expand All @@ -142,4 +236,6 @@ volcano durable start charge-order --input '{"order_id":"order-9"}'

Local waits resolve immediately by default while preserving checkpoint and replay behavior. Set `LOCAL_DURABLE_REAL_TIME=true` before `volcano start` when wait timing must match the deployed function. Local executions persist across `volcano stop` and `volcano start`.

Approvals work the same locally. Decide one with `volcano durable approvals approve` or `volcano durable approvals deny`, or point the owner client at the local server. Approval timeouts run in real time.

Running a decorated handler directly in a Python process still needs the optional test runtime: `python -m pip install 'volcano-sdk-python[durable]'`.
Loading
Loading