Skip to content

Commit 4a590b4

Browse files
feat(durable): add wait_for_approval and the approvals client (#389)
* feat(api): sync durable approval operations Regenerates the internal client from the Hosting spec with the durable approval request, list, get, stats, approve, and deny operations, and requires the operations the SDK calls. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(durable): add durable approvals DurableContext.wait_for_approval registers an approval with Volcano from the runtime's callback submitter and suspends until a person decides. A denial or a timeout returns an ApprovalDecision rather than raising. @durable reads the execution reference from the invocation and passes it to child, map, and parallel contexts. client.durable.approvals lists, reads, summarizes, approves, and denies approvals. HTTP 403 now raises PermissionDeniedError, a subclass of AuthenticationError. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(durable): bound approval registration retries and validate size Retry registration on network errors, timeouts, 404, 429, any 5xx, and 409 approval_not_ready, on the JS SDK's delay schedule within one 30-second deadline that also caps each attempt's timeout. A 409 approval_closed now counts as registered, so an approval whose timeout passed first resumes with the expired decision instead of failing the execution. Refuse text that cannot be encoded as JSON and approvals over 64 KiB (with room for the longest callback id) before the callback opens, and send the exact bytes that were measured. Out-of-range approval timeouts raise TypeError, as ctx.wait does. Correct the guide: approvals have no name filter, and a refusal reaches the handler as the runtime's CallbackSubmitterError. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(durable): drop the approval retry delay the deadline never reaches Co-authored-by: Cursor <cursoragent@cursor.com> * chore(api): sync the durable approval description from hosting Co-authored-by: Cursor <cursoragent@cursor.com> * fix(durable): decide an approval by its status and read its details leniently Co-authored-by: Cursor <cursoragent@cursor.com> * perf(durable): build the runtime wrapper once and read the execution from its context Co-authored-by: Cursor <cursoragent@cursor.com> * fix(durable): end each approval registration attempt by its own deadline Co-authored-by: Cursor <cursoragent@cursor.com> * test(durable): mock the API's own refusal for a project access token Co-authored-by: Cursor <cursoragent@cursor.com> * fix(durable): read decided_at only as an RFC 3339 date-time, as the JavaScript SDK does Co-authored-by: Cursor <cursoragent@cursor.com> * test(durable): pin the approval answer cap and the retry deadline edge Co-authored-by: Cursor <cursoragent@cursor.com> * test(durable): cover anchoring and range edges of the decision time Co-authored-by: Cursor <cursoragent@cursor.com> * chore(openapi): declare 413 on approval decisions Co-authored-by: Cursor <cursoragent@cursor.com> * fix(durable): refuse approval names and text the platform cannot record Names are the runtime's operation name, so they hold to printable ASCII and 237 characters. NUL is refused before the callback opens, details are copied when checked, and a stalled host lookup no longer outlasts its attempt. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(durable): report the attempt's deadline when the client's timeout wins Co-authored-by: Cursor <cursoragent@cursor.com> * test(realtime): give native commands two seconds to arrive A command normally arrives within a millisecond. The 0.2 s wait failed the cancelled-unsubscribe tests whenever a loaded runner stalled the loop, which made mutation stats collection fail on random modules. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b9f40bb commit 4a590b4

59 files changed

Lines changed: 8649 additions & 91 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,24 @@ outcome it could not determine — as finished. `stop()` is accepted rather than
264264
awaited: what it returns is the execution read back after asking, often still
265265
`running`, so poll `get()` to see it reach `stopped`. Repeating a stop is safe.
266266

267+
`durable.approvals` reads and decides the approvals a durable function waits on:
268+
269+
```python
270+
approvals = owner_client.durable.approvals
271+
page = approvals.list(project_id, status="pending", function="order-pipeline")
272+
approval = approvals.approve(project_id, page.approvals[0].id, comment="Looks right")
273+
stats = approvals.stats(project_id)
274+
```
275+
276+
`list()`, `get()` and `stats()` accept the project owner's platform user token or
277+
a project access token. `approve()` and `deny()` are for a person: a project
278+
access token raises `PermissionDeniedError`, a subclass of `AuthenticationError`.
279+
Repeating the same decision returns the approval unchanged; a conflicting one, or
280+
deciding an approval that expired or whose execution ended, raises
281+
`ConflictError` with `code` set to `approval_decided`, `approval_expired`, or
282+
`approval_cancelled`. See the
283+
[functions guide](https://github.com/Kong/volcano-sdk-python/blob/main/docs/functions.md#decide-approvals-from-a-backend).
284+
267285
## Write a durable function
268286

269287
`volcano_sdk.durable_authoring` is what the durable function itself is written
@@ -332,6 +350,7 @@ the clock or a random value.
332350
| `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. |
333351
| `ctx.wait(name, duration)` | Suspends the execution. `"30s"`, `"2h"`, `"1m30s"`, a whole number of seconds, or `{"hours": 2}`. |
334352
| `ctx.wait_until(check, options, name=None)` | Polls your own state until `options.until` holds, suspending between checks. `options.initial_state` is required. |
353+
| `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. |
335354
| `ctx.map(items, fn, name=None, options=None)` | Runs the same work over every item, each in its own child context. |
336355
| `ctx.parallel(branches, name=None, options=None)` | Runs independent branches at the same time. |
337356
| `ctx.child(name, fn)` | Groups operations under one recorded context. |
@@ -372,8 +391,30 @@ volcano durable start order-pipeline --input '{"order_id":"order-9"}'
372391
Local waits resolve immediately by default while preserving checkpoint and replay
373392
behavior. Set `LOCAL_DURABLE_REAL_TIME=true` before `volcano start` when wait
374393
timing must match the deployed function. Local executions persist across
375-
`volcano stop` and `volcano start`. Volcano does not expose externally completed
376-
callbacks; use `ctx.wait_until` to poll application state instead.
394+
`volcano stop` and `volcano start`.
395+
396+
`ctx.wait_for_approval` registers the approval with Volcano and suspends until it
397+
is decided, works the same locally, and replays the recorded decision on resume.
398+
A denial or timeout is a value to branch on:
399+
400+
```python
401+
decision = ctx.wait_for_approval(
402+
"ship-order",
403+
title=f"Ship order {event['order_id']}?",
404+
details={"total": event["total"]},
405+
timeout="24h",
406+
)
407+
if not decision.approved:
408+
return {"shipped": False, "status": decision.status}
409+
```
410+
411+
`decision` carries `approved`, `status` (`approved`, `denied`, or `expired`),
412+
`comment`, `decided_by` (`id` and `email`, or `None`), and `decided_at`. Volcano
413+
sets `VOLCANO_PLATFORM_API_URL` on durable functions; without it the call raises
414+
`RuntimeError`. The function retries registering the approval for up to 30
415+
seconds. If Volcano refuses it, the call raises the durable runtime's
416+
`CallbackSubmitterError` with the SDK error's message. See the
417+
[functions guide](https://github.com/Kong/volcano-sdk-python/blob/main/docs/functions.md#wait-for-an-approval).
377418

378419
`logs.search()` returns an immutable page of retained runtime or deployment log
379420
events. Pass `next_cursor` back as `cursor` to continue a search. `logs.activity()`

‎docs/functions.md‎

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,101 @@ functions:
128128
kind: durable
129129
```
130130
131-
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.
131+
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.
132+
133+
## Wait for an approval
134+
135+
`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):
136+
137+
```python
138+
from volcano_sdk.durable_authoring import durable
139+
140+
141+
@durable
142+
def handler(event, ctx):
143+
order = ctx.step("load-order", lambda scope: load_order(event["order_id"]))
144+
145+
decision = ctx.wait_for_approval(
146+
"ship-order",
147+
title=f"Ship order {order['id']}?",
148+
description="Orders over $500 need a second look.",
149+
details={"order_id": order["id"], "total": order["total"]},
150+
timeout="24h",
151+
)
152+
153+
if not decision.approved:
154+
ctx.step("release-stock", lambda scope: release_stock(order["id"]))
155+
return {"shipped": False, "status": decision.status}
156+
157+
ctx.step("ship", lambda scope: ship_order(order["id"]))
158+
return {"shipped": True, "comment": decision.comment}
159+
```
160+
161+
| Argument | Description |
162+
|---|---|
163+
| `name` | Required. Operation name in the execution's history, up to 237 printable ASCII characters. Keep it short. |
164+
| `title` | Required. What the approver is asked, up to 200 characters. |
165+
| `description` | Optional context, up to 4000 characters. |
166+
| `details` | Optional JSON value shown with the approval. The whole approval, `details` included, must encode to at most 64 KiB of JSON. |
167+
| `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. |
168+
169+
Limits count Unicode characters. A blank `name` or `title` raises `ValueError`, as does a value over its limit, a `name` outside printable ASCII, a NUL character anywhere in the text or `details`, 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.
170+
171+
The call returns an immutable `ApprovalDecision`:
172+
173+
| Field | Approved | Denied | Timed out |
174+
|---|---|---|---|
175+
| `approved` | `True` | `False` | `False` |
176+
| `status` | `"approved"` | `"denied"` | `"expired"` |
177+
| `comment` | The approver's comment, or `""` | The approver's comment, or `""` | `""` |
178+
| `decided_by` | `DurableApprovalDecider` with `id` and `email`, or `None` if the account was deleted before the decision reached the execution | Same as approved | `None` |
179+
| `decided_at` | RFC 3339 timestamp | RFC 3339 timestamp | `None` |
180+
181+
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.
182+
183+
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`.
184+
185+
## Decide approvals from a backend
186+
187+
`client.durable.approvals` lists, reads, and decides a project's approvals:
188+
189+
```python
190+
import os
191+
from datetime import UTC, datetime, timedelta
192+
193+
from volcano_sdk import ConflictError, VolcanoClient
194+
195+
owner_client = VolcanoClient(
196+
anon_key=os.environ["VOLCANO_ANON_KEY"],
197+
api_url=os.environ.get("VOLCANO_API_URL", "https://api.volcano.dev"),
198+
access_token=os.environ["VOLCANO_PLATFORM_TOKEN"],
199+
)
200+
approvals = owner_client.durable.approvals
201+
202+
page = approvals.list(project_id, status="pending", function="charge-order")
203+
for approval in page.approvals:
204+
print(approval.id, approval.title, approval.details, approval.expires_at)
205+
206+
try:
207+
approval = approvals.approve(project_id, approval_id, comment="Address checked")
208+
except ConflictError as error:
209+
print(error.code) # approval_decided, approval_expired, or approval_cancelled
210+
211+
stats = approvals.stats(project_id, from_=datetime.now(UTC) - timedelta(days=7))
212+
print(stats.counts.pending, stats.approval_rate, stats.median_seconds_to_decision)
213+
```
214+
215+
| Method | Returns |
216+
|---|---|
217+
| `list(project_id, status=..., function=..., execution_id=..., from_=..., to=..., page=..., limit=...)` | `DurableApprovalPage` with `approvals`, `page`, `limit`, `total`, and `has_more`. |
218+
| `get(project_id, approval_id)` | `DurableApproval`. |
219+
| `stats(project_id, function=..., from_=..., to=...)` | `DurableApprovalStats` with counts by status, decision times, and per-function and daily counts. |
220+
| `approve(project_id, approval_id, comment=None)` | The decided `DurableApproval`. |
221+
| `deny(project_id, approval_id, comment=None)` | The decided `DurableApproval`. |
222+
223+
`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.
224+
225+
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.
132226

133227
## Run durable functions locally
134228

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

143237
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`.
144238

239+
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.
240+
145241
Running a decorated handler directly in a Python process still needs the optional test runtime: `python -m pip install 'volcano-sdk-python[durable]'`.

0 commit comments

Comments
 (0)