feat: AsyncGuardedPayment (v0.2.0)#4
Conversation
Add an async-context-manager variant of cycles_guard_payment for
asyncio runtimes — FastAPI, anyio, OpenAI async SDK, etc. The sync
GuardedPayment remains unchanged and the entire v0.1.x contract is
preserved.
Public API additions:
- AsyncGuardedPayment class
- cycles_guard_payment_async(client, mandate, ...) factory
The async variant uses runcycles.AsyncCyclesClient for I/O; everything
else (idempotency keying, mapping, validation, exceptions, receipt
construction) is shared via the existing module-level helpers in
mapping.py / _validation.py / receipt.py — so the two classes can't
drift in subtle ways.
Behaviour parity with sync on every documented contract:
- AP2GuardDenied on DENY or failed reserve
- AP2DryRunResult on dry-run (body unreachable)
- AP2GuardCommitUncertain on post-PSP unknown outcomes
(terminal codes / transport / 5xx / uncaught) — NO auto-release
- AP2GuardCommitFailed on 4xx unrecognized commit rejection
(release attempted, released/release_error fields populated)
- Auto-release on exception inside the body
- Idempotency-key derivation includes the open-mandate
consume-once scope (ap2:open_mandate:{...} when present,
ap2:tx:{...} otherwise)
- set_actual_micros() type/range validation identical
- attach_receipt_fields / abort / RuntimeAuthorityReceipt identical
Tests: 18 new in tests/test_async_guard.py mirroring the sync surface
(clean commit, dry-run, denial, release on exception, abort, all four
commit-uncertain branches, 4xx commit-failed, set_actual_micros
validation, open-mandate scope routing). Total 128 tests (110 sync
unchanged + 18 async), 97.60% coverage. ruff + mypy strict clean.
Example: examples/ap2_human_not_present_async.py mirrors the sync
example with AsyncCyclesClient and `async with`. README quickstart
adds an "Async variant (v0.2+)" snippet.
Version bumped to 0.2.0. AUDIT.md and CHANGELOG.md updated.
No protocol changes. No wire-shape changes. No exception or
validation changes. Existing v0.1.x sync callers see the API
entirely unchanged.
Audit of v0.2.0 found the async test surface was thinner than sync —
covered the major paths but missed several regression locks that the
sync tests already enforce. Also found four small doc/comment drifts
between the two classes. None of this is functional; it's all parity.
11 new async regression tests in tests/test_async_guard.py:
TestAsyncCleanCommit:
- set_actual_micros_negative_rejected
- set_actual_micros_rejects_non_int_types
(parametrized: True / False / 1.5 / 1.0 / "100" / None / [100])
TestAsyncReleaseOnException:
- value_error_uses_exception_type_in_key (verifies release key
embeds the sanitized exception class name; sync covers
RuntimeError, async only had RuntimeError too)
TestAsyncDenial:
- missing_reservation_id_raises_guard_denied
(server returns ALLOW with no reservation_id — protocol
violation; must raise rather than silently proceed)
TestAsyncDryRun:
- dry_run_deny_raises_guard_denied_not_dry_run_result
(DENY pre-empts the dry-run probe path)
- dry_run_result_carries_caps_and_scopes
(caps / balances / affected_scopes / reason_code propagate)
- non_dry_run_does_not_set_flag
TestAsyncCommitUncertain:
- reservation_finalized_raises_uncertain
(third terminal code on the same branch; sync covers all three,
async previously covered only EXPIRED + MISMATCH)
- commit_5xx_without_body_uses_synthetic_code
(synthesized SERVER_ERROR when no parseable body)
TestAsyncCommitFailed:
- commit_failed_records_release_transport_failure
(release transport-fails after 4xx commit; .released=False,
.release_error chained)
- commit_failed_records_release_non_success
(release returns 5xx after 4xx commit; .released=False,
status code in .release_error)
TestAsyncFromAp2EndToEnd:
- from_ap2_shape_flows_through_async_guard
(adapter → async guard → wire, with open_mandate scope)
Doc drift fixes in guard.py:
- Module docstring updated to cover both sync and async classes
(was "Sync context manager wrapping...").
- AsyncGuardedPayment class docstring now enumerates the decision
rules directly (was deferring entirely to GuardedPayment), so
`help(AsyncGuardedPayment)` shows the contract.
- AsyncGuardedPayment.abort docstring picks up the
"Use for late-discovered failures" tail that the sync class has.
- AsyncGuardedPayment.__aexit__ picks up the NOTE comment about
dry-run never reaching __aexit__ (parallels sync).
Test posture: 146 tests (up from 128), 99.20% coverage (up from
97.60%). ruff + mypy strict clean. `python -m build` builds 0.2.0
sdist + wheel cleanly.
No public API change. No wire-shape change. No exception or
validation logic change.
Audit follow-up pushed (3ae0b94)Per the self-audit, the async test surface was thinner than sync — covered the major paths but missed several regression locks that the sync tests already enforce. Also found four small doc/comment drifts between the two classes. None functional; all parity. 11 new async regression tests
Doc drift fixes in
|
… (P1) plus refresh stale test counts in CHANGELOG/AUDIT (P3) P1 — async commit cancellation escaped as raw CancelledError ============================================================ asyncio.CancelledError is a BaseException on Python 3.8+, so the generic `except Exception` clause in async _handle_commit did NOT catch it. If an outer timeout or task.cancel() landed while `await self._client.commit_reservation(...)` was in flight, the raw CancelledError propagated to the caller with no reservation_id and no error_code — despite the post-PSP unknown-outcome contract saying all such cases must raise AP2GuardCommitUncertain. The commit POST may have reached and settled Cycles before the cancel landed. Auto-releasing is unsafe (could undo a real settle). Same semantics as transport error / 5xx / uncaught Exception. Fix: explicit `except asyncio.CancelledError` clause ahead of the generic Exception clause. Raises AP2GuardCommitUncertain( error_code="COMMIT_CANCELLED", reservation_id=...) from exc so the original CancelledError rides on __cause__ and the task still terminates once the caller's except AP2GuardCommitUncertain handler returns. Sync path is unaffected — cancellation only applies to async. New regression test: test_commit_cancellation_surfaces_as_uncertain asserts AP2GuardCommitUncertain with error_code COMMIT_CANCELLED, reservation_id populated, __cause__ is CancelledError, no release attempted. Public API addition: new `error_code` value "COMMIT_CANCELLED" on the existing AP2GuardCommitUncertain exception (documented in docstring + CHANGELOG). P3 — stale test counts ====================== CHANGELOG.md and AUDIT.md still cited "18 new tests, 128 total" from the initial v0.2.0 commit, before the audit follow-up commit added 18 more (146) and this commit added 1 more (147). Updated both to current accurate numbers — 147 tests, 37 in test_async_guard.py, 99.20% coverage. Gates: ruff + ruff format clean, mypy strict 0 errors, pytest 147 passed at 99.20% coverage, `python -m build` produces 0.2.0 sdist + wheel cleanly. No wire-shape change. No validation change. New error_code is additive on an existing exception.
P1 + P3 fixes pushed (788d708)Both findings addressed. P1 — async commit cancellation now wrapped as commit-uncertain
Fix: explicit except asyncio.CancelledError as exc:
raise AP2GuardCommitUncertain(
f"AP2 commit cancelled mid-flight ... Commit may have reached Cycles before "
f"cancellation; no release was attempted.",
error_code="COMMIT_CANCELLED",
reservation_id=self._reservation_id,
) from excSame post-PSP unknown semantics as the existing branches — no auto-release (commit may have settled), Sync path unaffected. Public API addition (additive, on an existing exception): new New regression test: P3 — refreshed stale test counts
Gates
No wire-shape change. No validation change. |
…G wording
P3 follow-up: the previous commit added COMMIT_CANCELLED as a new
error_code on AP2GuardCommitUncertain but only documented it in
the class docstring and AUDIT.md. Three public-facing doc spots were
still stale:
- README async note (line 101): "raises the same exceptions under
the same conditions as sync" was technically wrong because
cancellation mid-commit is async-only. Now explicitly calls out
the async-only COMMIT_CANCELLED condition with the chained
__cause__ contract.
- README lifecycle table (line 123): added asyncio.CancelledError
(async only) to the scenarios list and added COMMIT_CANCELLED
(async only) to the error_code flavor enumeration.
- README exception table (line 219): AP2GuardCommitUncertain row
now includes COMMIT_CANCELLED with the async-only marker.
- CHANGELOG (line 11): "Same exception contract" softened to
"Same exception classes plus one async-only condition".
- CHANGELOG (line 17): "No exception or validation changes" was
overbroad. Split into a new "Changed (async-only)" subsection
that records the additive error_code value, and a narrower
"Unchanged" line that scopes the no-change claim to wire shape,
validation, and the sync API.
No code change. ruff + mypy + 147 tests still pass.
Doc parity for COMMIT_CANCELLED pushed (3909133)Three public-facing doc spots were still stale after the previous commit added
No code change. Gates re-verified locally: |
Two internal docstring lists still enumerated the post-PSP unknown-
outcome cases without the async-only cancellation path:
- AsyncGuardedPayment class docstring (guard.py): "Post-PSP commit
unknown-outcome (terminal codes, transport, 5xx, uncaught) →"
Added "or an asyncio.CancelledError landing mid-flight" plus a
sentence noting the error_code value and the chained __cause__.
- AP2GuardCommitFailed class docstring (exceptions.py): the "this
is NOT the right exception for unknown-outcome failures" list
that pointed at AP2GuardCommitUncertain enumerated transport,
5xx, terminal codes, and uncaught exceptions but not async
cancellation. Added it as "(async only) asyncio.CancelledError
mid-flight".
Runtime behavior unchanged; the cancellation path was already
correctly handled and AP2GuardCommitUncertain's own docstring already
listed COMMIT_CANCELLED. These two were the last internal sources of
truth missing the entry. Public README is current.
Gates: ruff clean, mypy strict 0 errors, 147 tests pass.
Docstring parity for COMMIT_CANCELLED pushed (07e93af)Two internal docstrings still enumerated the post-PSP unknown-outcome list without the async cancellation path. Both fixed:
Runtime behavior unchanged. Gates: |
Summary
Adds an async-context-manager variant of
cycles_guard_paymentfor asyncio runtimes (FastAPI, anyio, OpenAI async SDK, etc.). The syncGuardedPaymentremains unchanged and the entire v0.1.x contract is preserved.Public API additions
AsyncGuardedPaymentclasscycles_guard_payment_async(client, mandate, ...)factoryImplementation note
The async variant uses
runcycles.AsyncCyclesClientfor I/O; everything else (idempotency keying, mapping, validation, exceptions, receipt construction) is shared via the existing module-level helpers inmapping.py/_validation.py/receipt.py. So the two classes can't drift in subtle ways.Behaviour parity with sync
AP2GuardDeniedon DENY or failed reserveAP2DryRunResulton dry-run (body unreachable)AP2GuardCommitUncertainon post-PSP unknown outcomes (terminal codes / transport / 5xx / uncaught) — no auto-releaseAP2GuardCommitFailedon 4xx unrecognized commit rejection (release attempted,released+release_errorpopulated)set_actual_microstype/range validationattach_receipt_fields/abort/RuntimeAuthorityReceiptTest plan
tests/test_async_guard.pymirroring the sync surface — clean commit, dry-run, denial, release on exception, abort, all four commit-uncertain branches (5xx with/without body, transport error, exception during commit), 4xx commit-failed,set_actual_microsint64 validation, open-mandate scope routingruff check . && ruff format --check .— cleanmypy --strict runcycles_ap2— 0 errors (8 files)pytest --cov=runcycles_ap2 --cov-fail-under=95— 128 passed, 97.60% coveragepython -m build— sdist + wheel build cleanly asruncycles_ap2-0.2.0Out of v0.2 (still planned for v0.3)
AP2CurrencyErroron non-USD)payment.refundconvenience helpercycles-protocolsigned-receipt field)Release sequence after merge
v0.2.0and push → firespython-publish.ymlworkflowpyproject.tomlversion (0.2.0) matches tag