From c6b310440dcd548f75052d21b2c1678449cafded Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Wed, 13 May 2026 15:24:42 -0400 Subject: [PATCH 1/5] feat: AsyncGuardedPayment (v0.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.md | 22 ++ CHANGELOG.md | 21 +- README.md | 20 ++ examples/README.md | 4 + examples/ap2_human_not_present_async.py | 88 ++++++ pyproject.toml | 2 +- runcycles_ap2/__init__.py | 11 +- runcycles_ap2/guard.py | 399 +++++++++++++++++++++++- tests/conftest.py | 19 +- tests/test_async_guard.py | 309 ++++++++++++++++++ 10 files changed, 885 insertions(+), 10 deletions(-) create mode 100644 examples/ap2_human_not_present_async.py create mode 100644 tests/test_async_guard.py diff --git a/AUDIT.md b/AUDIT.md index 31957a5..611f37d 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -2,6 +2,28 @@ Per `CLAUDE.md`: this file records material changes to the repo (server, admin, client). For a client package, that means public API, on-the-wire request shape, and protocol-conformance posture. +## 2026-05-13 — v0.2.0 — AsyncGuardedPayment + +**Author:** v0.2.0 release +**Scope:** new public API surface (async). No wire-shape, exception, or validation changes. + +- Added `runcycles_ap2.AsyncGuardedPayment` — async context manager (`async with`) that mirrors `GuardedPayment` exactly. +- Added `runcycles_ap2.cycles_guard_payment_async(...)` 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. +- Behaviour parity with the sync variant on every documented contract: same `AP2GuardDenied` on DENY / failed reserve, same `AP2DryRunResult` on dry-run, same `AP2GuardCommitUncertain` on post-PSP unknown outcomes (terminal codes / transport / 5xx / uncaught), same `AP2GuardCommitFailed` on 4xx unrecognized commit rejection, same auto-release on exception inside the body, same idempotency-key derivation including the open-mandate consume-once scope. +- New example: `examples/ap2_human_not_present_async.py`. +- README quickstart now includes an "Async variant (v0.2+)" snippet. + +**Public API additions:** +- `AsyncGuardedPayment` class +- `cycles_guard_payment_async(...)` factory + +**Test posture after addition:** +- 128 tests (up from 110), 97.60% coverage. +- 18 new tests in `tests/test_async_guard.py` mirror the sync test surface. + +**No protocol changes. No wire-shape changes.** Existing v0.1.x callers see the sync API entirely unchanged. + ## 2026-05-13 — positioning-review round 4 (ASCII suffix + empty-hash preservation + docstrings) **Author:** strategic/positioning review round 4 (PR #2 still in-flight) diff --git a/CHANGELOG.md b/CHANGELOG.md index 819f240..e0704f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] — 2026-05-13 + +### Added +- `AsyncGuardedPayment` and `cycles_guard_payment_async(...)` — async-context-manager variant for asyncio runtimes (FastAPI, anyio, OpenAI async SDK, etc.). Same exception contract, same idempotency-key derivation (including the open-mandate consume-once scope), and same commit-uncertainty handling as the sync `GuardedPayment`. +- New example `examples/ap2_human_not_present_async.py`. +- README "Async variant (v0.2+)" quickstart snippet. +- 18 new tests in `tests/test_async_guard.py` mirroring the sync test surface; total 128 tests, ≥ 95% coverage, ruff + mypy strict. + +### Unchanged +- No wire-shape changes. No exception or validation changes. Existing v0.1.x sync callers see the API entirely unchanged. + +### Still planned for v0.3 +- Multi-currency. +- `payment.refund` helper. +- Server-verifiable runtime-authority receipt (requires `cycles-protocol` signed-receipt field). + ## [0.1.0] — 2026-05-13 ### Added @@ -26,10 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `AP2Mandate.from_ap2()` preserves an empty upstream `checkout_hash` so the model's `min_length=1` constraint can reject it (was previously masked to `None` by a falsy-`or` short-circuit). - 110 tests, ≥ 95% coverage, ruff + mypy strict. -### Planned for v0.2 -- `AsyncGuardedPayment` (asyncio). +### Planned for v0.3 - Multi-currency. - `payment.refund` helper. - -### Planned for v0.3 - Server-verifiable runtime-authority receipt (requires `cycles-protocol` signed-receipt field). diff --git a/README.md b/README.md index 94f0c35..9226079 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,26 @@ with CyclesClient(config) as client: print(guard.receipt) # client-side runtime-authority receipt ``` +### Async variant (v0.2+) + +Same contract, asyncio I/O. Use this when your agent runtime is async (FastAPI, anyio, the OpenAI async SDK, etc.): + +```python +from runcycles import AsyncCyclesClient, CyclesConfig +from runcycles_ap2 import AP2Mandate, cycles_guard_payment_async + +async def charge(mandate: AP2Mandate) -> None: + config = CyclesConfig.from_env() + async with AsyncCyclesClient(config) as client: + async with cycles_guard_payment_async( + client, mandate=mandate, run_id="run_abc123", tenant="acme", + ) as guard: + psp_receipt = await psp.charge_async(mandate) + guard.attach_receipt_fields(psp_ref=psp_receipt.id) +``` + +`AsyncGuardedPayment` raises the same exceptions (`AP2GuardDenied`, `AP2DryRunResult`, `AP2GuardCommitUncertain`, `AP2GuardCommitFailed`) under the same conditions as the sync variant. + ## From an existing AP2 SDK object If you already hold a `PaymentMandate` (and optional `CheckoutMandate`) shaped per the AP2 public examples, build an `AP2Mandate` adapter in one line. Schema renames in upstream AP2 only touch this adapter — your guard code stays stable. diff --git a/examples/README.md b/examples/README.md index ebb1f8b..7470bd8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,3 +14,7 @@ python examples/ap2_human_not_present.py ``` Set `DRY_RUN=1` to evaluate the policy decision without creating a reservation. Re-run with the same `transaction_id` to demonstrate that the server returns the original reservation (idempotent replay — the double-spend defense). + +## ap2_human_not_present_async.py + +Same demo, async. Use this when your agent runtime is asyncio-based (FastAPI, anyio, the OpenAI async SDK, etc.). Same environment variables and `DRY_RUN` toggle. The mandate in this variant also sets `open_mandate_hash`, so the idempotency lock is keyed on the open mandate (the AP2 §6 consume-once boundary) rather than `transaction_id`. diff --git a/examples/ap2_human_not_present_async.py b/examples/ap2_human_not_present_async.py new file mode 100644 index 0000000..29c64ef --- /dev/null +++ b/examples/ap2_human_not_present_async.py @@ -0,0 +1,88 @@ +"""End-to-end async example: guard an AP2 human-not-present payment with Cycles. + +Mirrors examples/ap2_human_not_present.py exactly except the I/O is awaited and +the client is AsyncCyclesClient. Useful when the agent runtime is asyncio-based +(FastAPI, anyio, the OpenAI async SDK, etc.). + +Usage: + CYCLES_BASE_URL=http://localhost:7878 CYCLES_API_KEY=test CYCLES_TENANT=acme \\ + python examples/ap2_human_not_present_async.py + + # Probe the decision without creating a reservation or moving money: + DRY_RUN=1 python examples/ap2_human_not_present_async.py +""" + +from __future__ import annotations + +import asyncio +import json +import os + +from runcycles import AsyncCyclesClient, CyclesConfig + +from runcycles_ap2 import AP2DryRunResult, AP2Mandate, cycles_guard_payment_async + + +async def fake_psp_charge_async(mandate: AP2Mandate) -> dict[str, str]: + """Stand-in for a real async payment-service-provider call.""" + await asyncio.sleep(0) # simulate an awaitable I/O boundary + return {"id": f"psp_{mandate.transaction_id}", "status": "captured"} + + +async def run_dry_run(client: AsyncCyclesClient, mandate: AP2Mandate, tenant: str) -> None: + """Dry-run is a policy probe — the ``async with`` body never runs.""" + try: + async with cycles_guard_payment_async( + client, + mandate=mandate, + run_id="run_demo_async_001", + tenant=tenant, + agent="checkout-bot", + workflow="ap2-human-not-present", + dry_run=True, + ): + raise AssertionError("dry-run body must not execute") + except AP2DryRunResult as result: + print(f"dry-run decision={result.decision}, reason_code={result.reason_code}") + + +async def run_real(client: AsyncCyclesClient, mandate: AP2Mandate, tenant: str) -> None: + async with cycles_guard_payment_async( + client, + mandate=mandate, + run_id="run_demo_async_001", + tenant=tenant, + agent="checkout-bot", + workflow="ap2-human-not-present", + ) as guard: + print(f"decision={guard.decision}, reservation_id={guard.reservation_id}") + psp = await fake_psp_charge_async(mandate) + guard.attach_receipt_fields(psp_ref=psp["id"]) + + if guard.receipt is not None: + print(json.dumps(guard.receipt.model_dump(by_alias=True), indent=2)) + + +async def main() -> None: + config = CyclesConfig( + base_url=os.environ.get("CYCLES_BASE_URL", "http://localhost:7878"), + api_key=os.environ.get("CYCLES_API_KEY", "test-key"), + tenant=os.environ.get("CYCLES_TENANT", "acme"), + ) + mandate = AP2Mandate( + transaction_id="ap2-tx-demo-async-001", + amount_value="199.00", + currency="USD", + payee_website="merchant.example", + checkout_hash="ch_demo_async_001", + open_mandate_hash="omh_demo_async_001", + ) + async with AsyncCyclesClient(config) as client: + if os.environ.get("DRY_RUN") == "1": + await run_dry_run(client, mandate, config.tenant or "acme") + else: + await run_real(client, mandate, config.tenant or "acme") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 4d94abc..5182aad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "runcycles-ap2" -version = "0.1.0" +version = "0.2.0" description = "Runtime authority guard for AP2 (Agent Payments Protocol) — reserve, commit, release around agent payment mandates to prevent mandate reuse, double-spend, and concurrent checkout attempts. Works with Google's AP2 spec and any AP2-compatible SDK." readme = "README.md" license = "Apache-2.0" diff --git a/runcycles_ap2/__init__.py b/runcycles_ap2/__init__.py index 8647c02..e43fcf9 100644 --- a/runcycles_ap2/__init__.py +++ b/runcycles_ap2/__init__.py @@ -11,10 +11,15 @@ AP2GuardError, AP2MandateError, ) -from runcycles_ap2.guard import GuardedPayment, cycles_guard_payment +from runcycles_ap2.guard import ( + AsyncGuardedPayment, + GuardedPayment, + cycles_guard_payment, + cycles_guard_payment_async, +) from runcycles_ap2.models import AP2Mandate, RuntimeAuthorityReceipt -__version__ = "0.1.0" +__version__ = "0.2.0" __all__ = [ "AP2CurrencyError", @@ -25,8 +30,10 @@ "AP2GuardError", "AP2Mandate", "AP2MandateError", + "AsyncGuardedPayment", "GuardedPayment", "RuntimeAuthorityReceipt", "cycles_guard_payment", + "cycles_guard_payment_async", "__version__", ] diff --git a/runcycles_ap2/guard.py b/runcycles_ap2/guard.py index 384fc91..2e26a0c 100644 --- a/runcycles_ap2/guard.py +++ b/runcycles_ap2/guard.py @@ -7,7 +7,7 @@ from types import TracebackType from typing import Any -from runcycles.client import CyclesClient +from runcycles.client import AsyncCyclesClient, CyclesClient from runcycles.models import Decision, ReservationCreateResponse from runcycles_ap2._constants import DEFAULT_ACTION_KIND, DEFAULT_OVERAGE_POLICY, DEFAULT_TTL_MS @@ -440,3 +440,400 @@ def cycles_guard_payment( metadata=metadata, extra_dimensions=extra_dimensions, ) + + +# --------------------------------------------------------------------------- +# Async variant +# --------------------------------------------------------------------------- +# +# Mirrors :class:`GuardedPayment` exactly. The only differences are: +# - ``client`` is :class:`runcycles.client.AsyncCyclesClient` +# - ``__enter__``/``__exit__`` become ``__aenter__``/``__aexit__`` +# - the three I/O call sites (``create_reservation``, ``commit_reservation``, +# ``release_reservation``) are ``await``ed +# +# All non-I/O state, validation, mapping, and receipt-building helpers are shared +# via module-level functions (`build_reservation_body`, `validate_micros`, etc.), +# so the two classes stay structurally aligned and the same bugs cannot regress in +# one without showing up in the other's tests. + + +class AsyncGuardedPayment: + """Async context manager: reserve on ``__aenter__``, commit/release on ``__aexit__``. + + Behaviour matches :class:`GuardedPayment` exactly — same exception contract + (``AP2GuardDenied``, ``AP2DryRunResult``, ``AP2GuardCommitUncertain``, + ``AP2GuardCommitFailed``), same idempotency-key derivation, same commit-uncertainty + handling. Use this when your transport layer is :class:`AsyncCyclesClient` (FastAPI, + asyncio agents, anyio). + """ + + def __init__( + self, + client: AsyncCyclesClient, + *, + mandate: AP2Mandate, + run_id: str, + tenant: str | None = None, + workspace: str | None = None, + app: str | None = None, + workflow: str | None = None, + agent: str | None = None, + toolset: str | None = None, + action_kind: str = DEFAULT_ACTION_KIND, + ttl_ms: int = DEFAULT_TTL_MS, + overage_policy: str = DEFAULT_OVERAGE_POLICY, + dry_run: bool = False, + emit_receipt: bool = True, + metadata: dict[str, Any] | None = None, + extra_dimensions: dict[str, str] | None = None, + ) -> None: + self._client = client + self._mandate = mandate + self._run_id = run_id + self._tenant = tenant + self._workspace = workspace + self._app = app + self._workflow = workflow + self._agent = agent + self._toolset = toolset + self._action_kind = action_kind + self._ttl_ms = ttl_ms + self._overage_policy = overage_policy + self._dry_run = dry_run + self._emit_receipt = emit_receipt + self._metadata = metadata + self._extra_dimensions = extra_dimensions + + self._reservation_id: str | None = None + self._decision: Decision | None = None + self._actual_micros: int | None = None + self._commit_metadata: dict[str, Any] = {} + self._receipt: RuntimeAuthorityReceipt | None = None + self._committed = False + self._aborted_reason: str | None = None + + # -- public properties (identical to the sync class) ------------------ + + @property + def reservation_id(self) -> str | None: + return self._reservation_id + + @property + def decision(self) -> Decision | None: + return self._decision + + @property + def receipt(self) -> RuntimeAuthorityReceipt | None: + return self._receipt + + @property + def committed(self) -> bool: + return self._committed + + def set_actual_micros(self, amount: int) -> None: + """Override the committed amount. Same validation contract as the sync class.""" + self._actual_micros = validate_micros(amount, field="actual amount") + + def attach_receipt_fields(self, **fields: Any) -> None: + """Attach caller-supplied fields (e.g. PSP reference id) to the commit metadata.""" + self._commit_metadata.update(fields) + + def abort(self, reason: str) -> None: + """Force a release on clean exit (instead of commit).""" + self._aborted_reason = reason[:256] + + # -- async context manager protocol ---------------------------------- + + async def __aenter__(self) -> AsyncGuardedPayment: + body = build_reservation_body( + self._mandate, + run_id=self._run_id, + tenant=self._tenant, + workspace=self._workspace, + app=self._app, + workflow=self._workflow, + agent=self._agent, + toolset=self._toolset, + action_kind=self._action_kind, + ttl_ms=self._ttl_ms, + overage_policy=self._overage_policy, + dry_run=self._dry_run, + metadata=self._metadata, + extra_dimensions=self._extra_dimensions, + ) + response = await self._client.create_reservation(body) + + if not response.is_success: + error = response.get_error_response() + reason_code = error.error if error else None + request_id = error.request_id if error else None + raise AP2GuardDenied( + f"AP2 reservation failed for transaction {self._mandate.transaction_id}", + reason_code=reason_code, + request_id=request_id, + ) + + result = ReservationCreateResponse.model_validate(response.body) + self._decision = result.decision + + if result.is_denied(): + raise AP2GuardDenied( + f"AP2 reservation denied for transaction {self._mandate.transaction_id}: " + f"{result.reason_code or 'no reason'}", + reason_code=result.reason_code, + ) + + if self._dry_run: + logger.info( + "AP2 dry-run evaluated (async): decision=%s, tx=%s", + result.decision, + self._mandate.transaction_id, + ) + raise AP2DryRunResult( + f"AP2 dry-run decision={result.decision.value} for transaction {self._mandate.transaction_id}", + decision=result.decision.value, + reason_code=result.reason_code, + caps=result.caps, + balances=result.balances, + affected_scopes=result.affected_scopes, + ) + + if result.reservation_id is None: + raise AP2GuardDenied( + "AP2 reservation allowed but server returned no reservation_id", + reason_code=result.reason_code, + ) + + self._reservation_id = result.reservation_id + logger.info( + "AP2 reservation created (async): id=%s, tx=%s, decision=%s", + self._reservation_id, + self._mandate.transaction_id, + result.decision, + ) + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + if self._reservation_id is None: + return # denial path already raised; nothing to clean up + + if exc_type is not None: + await self._handle_release( + reason=f"ap2_guard_failed:{exc_type.__name__}", + exc_name=exc_type.__name__, + ) + return + + if self._aborted_reason is not None: + await self._handle_release( + reason=f"ap2_guard_aborted:{self._aborted_reason}", + exc_name="Aborted", + ) + return + + await self._handle_commit() + + # -- async internals ------------------------------------------------- + + async def _handle_commit(self) -> None: + assert self._reservation_id is not None + body = build_commit_body( + self._mandate, + actual_micros=self._actual_micros, + metadata=self._commit_metadata or None, + ) + try: + response = await self._client.commit_reservation(self._reservation_id, body) + except Exception as exc: + logger.exception("AP2 commit raised (async): tx=%s", self._mandate.transaction_id) + raise AP2GuardCommitUncertain( + f"AP2 commit raised before a response was received for transaction " + f"{self._mandate.transaction_id}: {type(exc).__name__}: {exc}. " + "Commit outcome is unknown; no release was attempted.", + error_code="COMMIT_RAISED", + reservation_id=self._reservation_id, + ) from exc + + if response.is_success: + self._committed = True + logger.info( + "AP2 commit successful (async): id=%s, tx=%s", + self._reservation_id, + self._mandate.transaction_id, + ) + if self._emit_receipt: + self._build_receipt() + return + + error = response.get_error_response() + error_code = error.error_code.value if (error and error.error_code) else None + request_id = error.request_id if error else None + + if response.is_transport_error or response.is_server_error: + synthetic_code = error_code or ("TRANSPORT_ERROR" if response.is_transport_error else "SERVER_ERROR") + logger.warning( + "AP2 commit %s failure (async, raising uncertain): id=%s, tx=%s, status=%d, code=%s", + "transport" if response.is_transport_error else "server", + self._reservation_id, + self._mandate.transaction_id, + response.status, + synthetic_code, + ) + raise AP2GuardCommitUncertain( + f"AP2 commit failed at the transport/server layer for transaction " + f"{self._mandate.transaction_id} (status={response.status}, code={synthetic_code}). " + "Commit may have reached Cycles before the failure; no release was attempted.", + error_code=synthetic_code, + request_id=request_id, + reservation_id=self._reservation_id, + ) + + if error_code in ("RESERVATION_FINALIZED", "RESERVATION_EXPIRED", "IDEMPOTENCY_MISMATCH"): + logger.warning( + "AP2 commit returned %s (async, terminal, raising uncertain): id=%s, tx=%s", + error_code, + self._reservation_id, + self._mandate.transaction_id, + ) + raise AP2GuardCommitUncertain( + f"AP2 commit returned {error_code} for transaction {self._mandate.transaction_id}. " + "Reservation is in a terminal state; PSP state may need reconciliation. " + "No release was attempted (a prior commit may already have settled).", + error_code=error_code, + request_id=request_id, + reservation_id=self._reservation_id, + ) + + logger.warning( + "AP2 commit rejected (async, releasing): id=%s, tx=%s, code=%s", + self._reservation_id, + self._mandate.transaction_id, + error_code, + ) + released, release_error = await self._handle_release( + reason=f"ap2_commit_rejected:{error_code or 'UNKNOWN'}", + exc_name="CommitRejected", + ) + status_phrase = ( + "reservation released" + if released + else f"reservation release FAILED ({release_error or 'unknown'}); budget stranded until TTL" + ) + raise AP2GuardCommitFailed( + f"AP2 commit rejected for transaction {self._mandate.transaction_id} " + f"(code={error_code}); {status_phrase}. PSP state may need reconciliation.", + error_code=error_code, + request_id=request_id, + reservation_id=self._reservation_id, + released=released, + release_error=release_error, + ) + + async def _handle_release(self, *, reason: str, exc_name: str) -> tuple[bool, str | None]: + """Async release. Same return contract as the sync variant.""" + assert self._reservation_id is not None + body = build_release_body(self._mandate, reason=reason, exception_type=exc_name) + try: + response = await self._client.release_reservation(self._reservation_id, body) + except Exception as exc: + logger.exception("AP2 release raised (async): id=%s", self._reservation_id) + return False, f"{type(exc).__name__}: {exc}" + + if response.is_success: + logger.info( + "AP2 released (async): id=%s, tx=%s, reason=%s", + self._reservation_id, + self._mandate.transaction_id, + reason, + ) + return True, None + + error = response.get_error_response() + error_code = error.error_code.value if (error and error.error_code) else None + logger.warning( + "AP2 release returned non-success (async): id=%s, status=%d, code=%s", + self._reservation_id, + response.status, + error_code, + ) + detail = f"status={response.status}" + if error_code: + detail = f"{detail}, code={error_code}" + return False, detail + + def _build_receipt(self) -> None: + # Pure logic — identical to the sync variant. Kept as a method (not a free + # function) so attribute access stays clean and the two classes look symmetric. + assert self._reservation_id is not None + assert self._decision is not None + policy_keys = build_action(self._mandate, action_kind=self._action_kind)["policy_keys"] + committed_micros = self._actual_micros if self._actual_micros is not None else self._mandate.amount_micros() + raw_psp_ref = self._commit_metadata.get("psp_ref") + psp_ref = raw_psp_ref if isinstance(raw_psp_ref, str) else None + extra = {k: v for k, v in self._commit_metadata.items() if k != "psp_ref"} or None + self._receipt = build_runtime_authority_receipt( + self._mandate, + decision=self._decision.value, + reservation_id=self._reservation_id, + tenant=self._tenant, + agent=self._agent, + action_kind=self._action_kind, + policy_keys=policy_keys, + amount_micros=committed_micros, + committed=True, + psp_ref=psp_ref, + extra=extra, + issued_at_ms=int(time.time() * 1000), + ) + + +def cycles_guard_payment_async( + client: AsyncCyclesClient, + *, + mandate: AP2Mandate, + run_id: str, + tenant: str | None = None, + workspace: str | None = None, + app: str | None = None, + workflow: str | None = None, + agent: str | None = None, + toolset: str | None = None, + action_kind: str = DEFAULT_ACTION_KIND, + ttl_ms: int = DEFAULT_TTL_MS, + overage_policy: str = DEFAULT_OVERAGE_POLICY, + dry_run: bool = False, + emit_receipt: bool = True, + metadata: dict[str, Any] | None = None, + extra_dimensions: dict[str, str] | None = None, +) -> AsyncGuardedPayment: + """Construct an :class:`AsyncGuardedPayment` for a single AP2 payment moment. + + Use ``async with`` to enter the lifecycle:: + + async with cycles_guard_payment_async(client, mandate=..., run_id=..., tenant=...): + receipt = await psp.charge(mandate) + """ + return AsyncGuardedPayment( + client, + mandate=mandate, + run_id=run_id, + tenant=tenant, + workspace=workspace, + app=app, + workflow=workflow, + agent=agent, + toolset=toolset, + action_kind=action_kind, + ttl_ms=ttl_ms, + overage_policy=overage_policy, + dry_run=dry_run, + emit_receipt=emit_receipt, + metadata=metadata, + extra_dimensions=extra_dimensions, + ) diff --git a/tests/conftest.py b/tests/conftest.py index c0af0ce..daa339d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,10 +4,10 @@ import time from typing import Any -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest -from runcycles.client import CyclesClient +from runcycles.client import AsyncCyclesClient, CyclesClient from runcycles.response import CyclesResponse from runcycles_ap2.models import AP2Mandate @@ -82,6 +82,21 @@ def mock_client() -> MagicMock: return mock +@pytest.fixture +def async_mock_client() -> MagicMock: + """AsyncMock for AsyncCyclesClient — used by AsyncGuardedPayment tests. + + ``MagicMock(spec=AsyncCyclesClient)`` alone returns regular MagicMocks for the + awaitable methods. We replace the three I/O methods with ``AsyncMock`` so they + work under ``await``. + """ + mock = MagicMock(spec=AsyncCyclesClient) + mock.create_reservation = AsyncMock() + mock.commit_reservation = AsyncMock() + mock.release_reservation = AsyncMock() + return mock + + @pytest.fixture def mandate() -> AP2Mandate: return make_mandate() diff --git a/tests/test_async_guard.py b/tests/test_async_guard.py new file mode 100644 index 0000000..d399196 --- /dev/null +++ b/tests/test_async_guard.py @@ -0,0 +1,309 @@ +"""AsyncGuardedPayment — mirrors the sync GuardedPayment test surface. + +Same contract: reserve on __aenter__, commit on clean exit, release on exception, +AP2GuardDenied on DENY, AP2DryRunResult on dry-run, AP2GuardCommitUncertain on +post-PSP unknown outcomes, AP2GuardCommitFailed on 4xx unrecognized commit rejection. +""" + +from __future__ import annotations + +import pytest +from runcycles.response import CyclesResponse + +from runcycles_ap2 import ( + AP2DryRunResult, + AP2GuardCommitFailed, + AP2GuardCommitUncertain, + AP2GuardDenied, + AP2MandateError, + cycles_guard_payment_async, +) +from runcycles_ap2._constants import MAX_USD_MICROS +from runcycles_ap2.mapping import idempotency_key +from tests.conftest import ( + allow_response, + commit_error_response, + commit_success_response, + deny_response, + release_success_response, +) + +# --------------------------------------------------------------------------- +# Clean commit +# --------------------------------------------------------------------------- + + +class TestAsyncCleanCommit: + async def test_commit_called_with_ap2_idempotency_key(self, async_mock_client, mandate) -> None: + async_mock_client.create_reservation.return_value = allow_response("rsv_async_clean") + async_mock_client.commit_reservation.return_value = commit_success_response() + + async with cycles_guard_payment_async( + async_mock_client, mandate=mandate, run_id="run_a", tenant="acme", agent="bot" + ) as guard: + assert guard.reservation_id == "rsv_async_clean" + assert guard.decision is not None + assert guard.decision.value == "ALLOW" + + assert guard.committed is True + async_mock_client.commit_reservation.assert_awaited_once() + called_id, called_body = async_mock_client.commit_reservation.call_args[0] + assert called_id == "rsv_async_clean" + assert called_body["idempotency_key"] == idempotency_key(mandate, "commit") + assert called_body["actual"] == {"unit": "USD_MICROCENTS", "amount": 19_900_000_000} + async_mock_client.release_reservation.assert_not_awaited() + + async def test_actual_micros_override(self, async_mock_client, mandate) -> None: + async_mock_client.create_reservation.return_value = allow_response() + async_mock_client.commit_reservation.return_value = commit_success_response() + + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme") as guard: + guard.set_actual_micros(5_000_000_000) + + body = async_mock_client.commit_reservation.call_args[0][1] + assert body["actual"]["amount"] == 5_000_000_000 + + async def test_attach_receipt_fields_lands_in_metadata_and_receipt(self, async_mock_client, mandate) -> None: + async_mock_client.create_reservation.return_value = allow_response() + async_mock_client.commit_reservation.return_value = commit_success_response() + + async with cycles_guard_payment_async( + async_mock_client, mandate=mandate, run_id="r", tenant="acme", agent="bot" + ) as guard: + guard.attach_receipt_fields(psp_ref="psp_async_1", trace_id="trace-1") + + body = async_mock_client.commit_reservation.call_args[0][1] + assert body["metadata"]["psp_ref"] == "psp_async_1" + assert guard.receipt is not None + assert guard.receipt.psp_ref == "psp_async_1" + assert guard.receipt.extra == {"trace_id": "trace-1"} + assert guard.receipt.committed is True + + async def test_emit_receipt_false_skips_receipt(self, async_mock_client, mandate) -> None: + async_mock_client.create_reservation.return_value = allow_response() + async_mock_client.commit_reservation.return_value = commit_success_response() + + async with cycles_guard_payment_async( + async_mock_client, mandate=mandate, run_id="r", tenant="acme", emit_receipt=False + ) as guard: + pass + + assert guard.receipt is None + assert guard.committed is True + + async def test_reserve_body_carries_policy_keys_and_consume_once_scope(self, async_mock_client) -> None: + from tests.conftest import make_mandate + + async_mock_client.create_reservation.return_value = allow_response() + async_mock_client.commit_reservation.return_value = commit_success_response() + m = make_mandate(open_mandate_hash="omh_async") # forces open_mandate scope + + async with cycles_guard_payment_async(async_mock_client, mandate=m, run_id="r", tenant="acme"): + pass + + body = async_mock_client.create_reservation.call_args[0][0] + assert body["idempotency_key"] == idempotency_key(m, "reserve") + assert ":open_mandate:" in body["idempotency_key"] + assert body["action"]["policy_keys"]["host"] == "merchant.example" + + async def test_set_actual_micros_above_int64_rejected(self, async_mock_client, mandate) -> None: + async_mock_client.create_reservation.return_value = allow_response() + async_mock_client.release_reservation.return_value = release_success_response() + + with pytest.raises(AP2MandateError, match="int64"): + async with cycles_guard_payment_async( + async_mock_client, mandate=mandate, run_id="r", tenant="acme" + ) as guard: + guard.set_actual_micros(MAX_USD_MICROS + 1) + + async_mock_client.commit_reservation.assert_not_awaited() + async_mock_client.release_reservation.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# Release on exception +# --------------------------------------------------------------------------- + + +class TestAsyncReleaseOnException: + async def test_runtime_error_triggers_release(self, async_mock_client, mandate) -> None: + async_mock_client.create_reservation.return_value = allow_response("rsv_async_rel") + async_mock_client.release_reservation.return_value = release_success_response() + + with pytest.raises(RuntimeError, match="psp failure"): + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme"): + raise RuntimeError("psp failure") + + async_mock_client.release_reservation.assert_awaited_once() + called_id, body = async_mock_client.release_reservation.call_args[0] + assert called_id == "rsv_async_rel" + assert body["idempotency_key"] == idempotency_key(mandate, "release", "RuntimeError") + assert body["reason"].startswith("ap2_guard_failed:RuntimeError") + async_mock_client.commit_reservation.assert_not_awaited() + + async def test_abort_releases_on_clean_exit(self, async_mock_client, mandate) -> None: + async_mock_client.create_reservation.return_value = allow_response() + async_mock_client.release_reservation.return_value = release_success_response() + + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme") as guard: + guard.abort("psp_returned_failure") + + async_mock_client.commit_reservation.assert_not_awaited() + async_mock_client.release_reservation.assert_awaited_once() + body = async_mock_client.release_reservation.call_args[0][1] + assert "psp_returned_failure" in body["reason"] + assert guard.committed is False + + async def test_release_failure_does_not_swallow_original_exception(self, async_mock_client, mandate) -> None: + async_mock_client.create_reservation.return_value = allow_response() + async_mock_client.release_reservation.side_effect = ConnectionError("transport down") + + with pytest.raises(RuntimeError, match="boom"): + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme"): + raise RuntimeError("boom") + + +# --------------------------------------------------------------------------- +# Denial / dry-run +# --------------------------------------------------------------------------- + + +class TestAsyncDenial: + async def test_deny_raises_before_psp_call(self, async_mock_client, mandate) -> None: + async_mock_client.create_reservation.return_value = deny_response("BUDGET_EXCEEDED") + sentinel = {"psp_called": False} + + with pytest.raises(AP2GuardDenied) as ei: + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme"): + sentinel["psp_called"] = True + + assert sentinel["psp_called"] is False + assert ei.value.reason_code == "BUDGET_EXCEEDED" + async_mock_client.commit_reservation.assert_not_awaited() + async_mock_client.release_reservation.assert_not_awaited() + + async def test_http_error_on_reserve_raises_guard_denied(self, async_mock_client, mandate) -> None: + async_mock_client.create_reservation.return_value = CyclesResponse.http_error( + 400, + error_message="bad request", + body={"error": "INVALID_REQUEST", "message": "bad", "request_id": "req_1"}, + ) + + with pytest.raises(AP2GuardDenied) as ei: + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme"): + pass + + assert ei.value.reason_code == "INVALID_REQUEST" + assert ei.value.request_id == "req_1" + + +class TestAsyncDryRun: + async def test_dry_run_raises_result_and_body_does_not_run(self, async_mock_client, mandate) -> None: + async_mock_client.create_reservation.return_value = CyclesResponse.success( + 200, + { + "decision": "ALLOW", + "affected_scopes": ["tenant:acme"], + "scope_path": "tenant:acme", + "reserved": {"unit": "USD_MICROCENTS", "amount": 19_900_000_000}, + }, + ) + body_ran = {"value": False} + + with pytest.raises(AP2DryRunResult) as ei: + async with cycles_guard_payment_async( + async_mock_client, mandate=mandate, run_id="r", tenant="acme", dry_run=True + ): + body_ran["value"] = True + + assert body_ran["value"] is False + assert ei.value.decision == "ALLOW" + body = async_mock_client.create_reservation.call_args[0][0] + assert body["dry_run"] is True + async_mock_client.commit_reservation.assert_not_awaited() + async_mock_client.release_reservation.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# Commit-uncertain branches +# --------------------------------------------------------------------------- + + +class TestAsyncCommitUncertain: + async def test_idempotency_mismatch_raises_uncertain_no_release(self, async_mock_client, mandate) -> None: + async_mock_client.create_reservation.return_value = allow_response() + async_mock_client.commit_reservation.return_value = commit_error_response("IDEMPOTENCY_MISMATCH", status=409) + + with pytest.raises(AP2GuardCommitUncertain) as ei: + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme"): + pass + + assert ei.value.error_code == "IDEMPOTENCY_MISMATCH" + async_mock_client.release_reservation.assert_not_awaited() + + async def test_reservation_expired_raises_uncertain_no_release(self, async_mock_client, mandate) -> None: + async_mock_client.create_reservation.return_value = allow_response() + async_mock_client.commit_reservation.return_value = commit_error_response("RESERVATION_EXPIRED", status=409) + + with pytest.raises(AP2GuardCommitUncertain) as ei: + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme"): + pass + + assert ei.value.error_code == "RESERVATION_EXPIRED" + async_mock_client.release_reservation.assert_not_awaited() + + async def test_commit_5xx_raises_uncertain_no_release(self, async_mock_client, mandate) -> None: + async_mock_client.create_reservation.return_value = allow_response() + async_mock_client.commit_reservation.return_value = commit_error_response("INTERNAL_ERROR", status=500) + + with pytest.raises(AP2GuardCommitUncertain) as ei: + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme"): + pass + + assert ei.value.error_code == "INTERNAL_ERROR" + async_mock_client.release_reservation.assert_not_awaited() + + async def test_commit_transport_error_raises_uncertain_no_release(self, async_mock_client, mandate) -> None: + async_mock_client.create_reservation.return_value = allow_response() + async_mock_client.commit_reservation.return_value = CyclesResponse.transport_error( + ConnectionError("network down") + ) + + with pytest.raises(AP2GuardCommitUncertain) as ei: + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme"): + pass + + assert ei.value.error_code == "TRANSPORT_ERROR" + async_mock_client.release_reservation.assert_not_awaited() + + async def test_commit_raises_surfaces_as_uncertain(self, async_mock_client, mandate) -> None: + async_mock_client.create_reservation.return_value = allow_response() + async_mock_client.commit_reservation.side_effect = ConnectionError("low-level boom") + + with pytest.raises(AP2GuardCommitUncertain) as ei: + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme"): + pass + + assert ei.value.error_code == "COMMIT_RAISED" + assert isinstance(ei.value.__cause__, ConnectionError) + async_mock_client.release_reservation.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# Commit-failed (4xx unrecognized) +# --------------------------------------------------------------------------- + + +class TestAsyncCommitFailed: + async def test_unrecognized_4xx_releases_and_raises(self, async_mock_client, mandate) -> None: + async_mock_client.create_reservation.return_value = allow_response() + async_mock_client.commit_reservation.return_value = commit_error_response("INVALID_REQUEST", status=400) + async_mock_client.release_reservation.return_value = release_success_response() + + with pytest.raises(AP2GuardCommitFailed) as ei: + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme"): + pass + + assert ei.value.error_code == "INVALID_REQUEST" + assert ei.value.released is True + async_mock_client.release_reservation.assert_awaited_once() From 3ae0b94ef455b9cf04299d57f6c3203e1ef05081 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Wed, 13 May 2026 16:02:21 -0400 Subject: [PATCH 2/5] tests + docs: close async-surface parity gaps from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- runcycles_ap2/guard.py | 40 +++++-- tests/test_async_guard.py | 223 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+), 7 deletions(-) diff --git a/runcycles_ap2/guard.py b/runcycles_ap2/guard.py index 2e26a0c..8fd9887 100644 --- a/runcycles_ap2/guard.py +++ b/runcycles_ap2/guard.py @@ -1,4 +1,9 @@ -"""Sync context manager wrapping a single AP2 payment moment in a Cycles reservation.""" +"""Context managers (sync and async) wrapping a single AP2 payment moment in a Cycles reservation. + +:class:`GuardedPayment` is the synchronous variant; :class:`AsyncGuardedPayment` is its +asyncio counterpart. The two classes share idempotency, validation, mapping, and +receipt logic via module-level helpers — only their I/O paths differ. +""" from __future__ import annotations @@ -461,11 +466,30 @@ def cycles_guard_payment( class AsyncGuardedPayment: """Async context manager: reserve on ``__aenter__``, commit/release on ``__aexit__``. - Behaviour matches :class:`GuardedPayment` exactly — same exception contract - (``AP2GuardDenied``, ``AP2DryRunResult``, ``AP2GuardCommitUncertain``, - ``AP2GuardCommitFailed``), same idempotency-key derivation, same commit-uncertainty - handling. Use this when your transport layer is :class:`AsyncCyclesClient` (FastAPI, - asyncio agents, anyio). + Behaviour matches :class:`GuardedPayment` exactly; use this when your transport + layer is :class:`AsyncCyclesClient` (FastAPI, asyncio agents, anyio). + + Decision rules: + - Clean exit (no exception) → ``commit_reservation`` with the deterministic AP2 + commit idempotency key (see :func:`runcycles_ap2.mapping.idempotency_key`). + - Exception inside ``async with`` block → ``release_reservation`` with reason + ``ap2_guard_failed:{ExcType}`` and the matching release idempotency key. + - Server ``Decision.DENY`` on enter → raises :class:`AP2GuardDenied`; real money + never moves and no commit/release is issued. + - ``dry_run=True`` → raises :class:`AP2DryRunResult` from ``__aenter__`` so the + ``async with`` body never executes (a body-level PSP call would otherwise move + money with no Cycles record). + - Post-PSP commit unknown-outcome (terminal codes, transport, 5xx, uncaught) → + raises :class:`AP2GuardCommitUncertain`. **No auto-release** — the commit POST + may have reached and settled Cycles before the failure was observed. + - Commit rejected with unrecognized 4xx code → raises + :class:`AP2GuardCommitFailed` after attempting a release; check the exception's + ``released`` / ``release_error`` attributes to know whether budget was recovered. + - Same consume-once key on a retry (``open_mandate_hash`` when present, + otherwise ``transaction_id``) → both attempts hit the same Cycles idempotency + bucket. Same payload replays the original reservation; divergent payload is + rejected by the server with ``IDEMPOTENCY_MISMATCH`` (surfaced as + :class:`AP2GuardDenied`). """ def __init__( @@ -540,7 +564,7 @@ def attach_receipt_fields(self, **fields: Any) -> None: self._commit_metadata.update(fields) def abort(self, reason: str) -> None: - """Force a release on clean exit (instead of commit).""" + """Force a release on clean exit (instead of commit). Use for late-discovered failures.""" self._aborted_reason = reason[:256] # -- async context manager protocol ---------------------------------- @@ -620,6 +644,8 @@ async def __aexit__( exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> None: + # NOTE: dry-run never reaches __aexit__ — __aenter__ raises AP2DryRunResult + # before returning, so the `async with` body never executes. if self._reservation_id is None: return # denial path already raised; nothing to clean up diff --git a/tests/test_async_guard.py b/tests/test_async_guard.py index d399196..a60cee5 100644 --- a/tests/test_async_guard.py +++ b/tests/test_async_guard.py @@ -119,6 +119,30 @@ async def test_set_actual_micros_above_int64_rejected(self, async_mock_client, m async_mock_client.commit_reservation.assert_not_awaited() async_mock_client.release_reservation.assert_awaited_once() + async def test_set_actual_micros_negative_rejected(self, async_mock_client, mandate) -> None: + async_mock_client.create_reservation.return_value = allow_response() + async_mock_client.release_reservation.return_value = release_success_response() + + with pytest.raises(AP2MandateError, match="non-negative"): + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme") as g: + g.set_actual_micros(-1) + + async_mock_client.commit_reservation.assert_not_awaited() + async_mock_client.release_reservation.assert_awaited_once() + + @pytest.mark.parametrize("bad", [True, False, 1.5, 1.0, "100", None, [100]]) + async def test_set_actual_micros_rejects_non_int_types(self, async_mock_client, mandate, bad) -> None: + # Mirrors the sync parametrized test: bool is an int subclass, float compares + # numerically — both must be rejected up front before reaching the wire. + async_mock_client.create_reservation.return_value = allow_response() + async_mock_client.release_reservation.return_value = release_success_response() + + with pytest.raises(AP2MandateError, match="must be an int"): + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme") as g: + g.set_actual_micros(bad) # type: ignore[arg-type] + + async_mock_client.commit_reservation.assert_not_awaited() + # --------------------------------------------------------------------------- # Release on exception @@ -162,6 +186,20 @@ async def test_release_failure_does_not_swallow_original_exception(self, async_m async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme"): raise RuntimeError("boom") + async def test_value_error_uses_exception_type_in_key(self, async_mock_client, mandate) -> None: + # Mirrors sync: the exception class name is sanitized and embedded in the + # release idempotency key, so different exception types get different release + # keys (preserves separate dedup buckets if multiple retries hit). + async_mock_client.create_reservation.return_value = allow_response("rsv_ve") + async_mock_client.release_reservation.return_value = release_success_response() + + with pytest.raises(ValueError): + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme"): + raise ValueError("nope") + + body = async_mock_client.release_reservation.call_args[0][1] + assert body["idempotency_key"] == idempotency_key(mandate, "release", "ValueError") + # --------------------------------------------------------------------------- # Denial / dry-run @@ -196,6 +234,21 @@ async def test_http_error_on_reserve_raises_guard_denied(self, async_mock_client assert ei.value.reason_code == "INVALID_REQUEST" assert ei.value.request_id == "req_1" + async def test_missing_reservation_id_raises_guard_denied(self, async_mock_client, mandate) -> None: + # Protocol-violation path: server returns ALLOW with no reservation_id and + # dry_run was NOT requested. The guard must raise rather than silently proceed. + async_mock_client.create_reservation.return_value = CyclesResponse.success( + 200, + { + "decision": "ALLOW", + "affected_scopes": ["tenant:acme"], + "reserved": {"unit": "USD_MICROCENTS", "amount": 100}, + }, + ) + with pytest.raises(AP2GuardDenied): + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme"): + pass + class TestAsyncDryRun: async def test_dry_run_raises_result_and_body_does_not_run(self, async_mock_client, mandate) -> None: @@ -223,6 +276,56 @@ async def test_dry_run_raises_result_and_body_does_not_run(self, async_mock_clie async_mock_client.commit_reservation.assert_not_awaited() async_mock_client.release_reservation.assert_not_awaited() + async def test_dry_run_deny_raises_guard_denied_not_dry_run_result(self, async_mock_client, mandate) -> None: + # DENY pre-empts the dry-run probe path. Caller sees AP2GuardDenied, not + # AP2DryRunResult, even with dry_run=True — preserves the "deny is deny" + # invariant across both sync and async surfaces. + async_mock_client.create_reservation.return_value = deny_response("BUDGET_EXCEEDED") + + with pytest.raises(AP2GuardDenied) as ei: + async with cycles_guard_payment_async( + async_mock_client, mandate=mandate, run_id="r", tenant="acme", dry_run=True + ): + pass + + assert ei.value.reason_code == "BUDGET_EXCEEDED" + + async def test_dry_run_result_carries_caps_and_scopes(self, async_mock_client, mandate) -> None: + # AP2DryRunResult exposes caps/balances/affected_scopes/reason_code so callers + # can introspect the would-be decision without creating a reservation. + async_mock_client.create_reservation.return_value = CyclesResponse.success( + 200, + { + "decision": "ALLOW_WITH_CAPS", + "affected_scopes": ["tenant:acme", "agent:bot"], + "scope_path": "tenant:acme", + "reserved": {"unit": "USD_MICROCENTS", "amount": 1_000}, + "caps": {"max_tokens": 500}, + "reason_code": "NEAR_LIMIT", + }, + ) + + with pytest.raises(AP2DryRunResult) as ei: + async with cycles_guard_payment_async( + async_mock_client, mandate=mandate, run_id="r", tenant="acme", dry_run=True + ): + pass + + assert ei.value.decision == "ALLOW_WITH_CAPS" + assert ei.value.reason_code == "NEAR_LIMIT" + assert ei.value.affected_scopes == ["tenant:acme", "agent:bot"] + assert ei.value.caps is not None + + async def test_non_dry_run_does_not_set_flag(self, async_mock_client, mandate) -> None: + async_mock_client.create_reservation.return_value = allow_response() + async_mock_client.commit_reservation.return_value = commit_success_response() + + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme"): + pass + + body = async_mock_client.create_reservation.call_args[0][0] + assert "dry_run" not in body + # --------------------------------------------------------------------------- # Commit-uncertain branches @@ -241,6 +344,21 @@ async def test_idempotency_mismatch_raises_uncertain_no_release(self, async_mock assert ei.value.error_code == "IDEMPOTENCY_MISMATCH" async_mock_client.release_reservation.assert_not_awaited() + async def test_reservation_finalized_raises_uncertain(self, async_mock_client, mandate) -> None: + # Third terminal-status code on the same branch. The branch covers all three + # (FINALIZED/EXPIRED/MISMATCH); the parity tests already cover EXPIRED and + # MISMATCH — adding FINALIZED so a future code change can't break one + # without surfacing in the suite. + async_mock_client.create_reservation.return_value = allow_response() + async_mock_client.commit_reservation.return_value = commit_error_response("RESERVATION_FINALIZED", status=409) + + with pytest.raises(AP2GuardCommitUncertain) as ei: + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme"): + pass + + assert ei.value.error_code == "RESERVATION_FINALIZED" + async_mock_client.release_reservation.assert_not_awaited() + async def test_reservation_expired_raises_uncertain_no_release(self, async_mock_client, mandate) -> None: async_mock_client.create_reservation.return_value = allow_response() async_mock_client.commit_reservation.return_value = commit_error_response("RESERVATION_EXPIRED", status=409) @@ -263,6 +381,23 @@ async def test_commit_5xx_raises_uncertain_no_release(self, async_mock_client, m assert ei.value.error_code == "INTERNAL_ERROR" async_mock_client.release_reservation.assert_not_awaited() + async def test_commit_5xx_without_body_uses_synthetic_code(self, async_mock_client, mandate) -> None: + # 5xx with no parseable error body → synthesized error_code="SERVER_ERROR" so + # callers branching on .error_code still get a stable discriminator. + async_mock_client.create_reservation.return_value = allow_response() + async_mock_client.commit_reservation.return_value = CyclesResponse.http_error( + 502, + error_message="bad gateway", + body=None, + ) + + with pytest.raises(AP2GuardCommitUncertain) as ei: + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme"): + pass + + assert ei.value.error_code == "SERVER_ERROR" + async_mock_client.release_reservation.assert_not_awaited() + async def test_commit_transport_error_raises_uncertain_no_release(self, async_mock_client, mandate) -> None: async_mock_client.create_reservation.return_value = allow_response() async_mock_client.commit_reservation.return_value = CyclesResponse.transport_error( @@ -307,3 +442,91 @@ async def test_unrecognized_4xx_releases_and_raises(self, async_mock_client, man assert ei.value.error_code == "INVALID_REQUEST" assert ei.value.released is True async_mock_client.release_reservation.assert_awaited_once() + + async def test_commit_failed_records_release_transport_failure(self, async_mock_client, mandate) -> None: + # Release transport-fails after a 4xx commit rejection. AP2GuardCommitFailed + # must report released=False with the chained exception text in release_error, + # so operators can distinguish "budget recovered" from "budget stranded". + async_mock_client.create_reservation.return_value = allow_response() + async_mock_client.commit_reservation.return_value = commit_error_response("INVALID_REQUEST", status=400) + async_mock_client.release_reservation.side_effect = ConnectionError("network down") + + with pytest.raises(AP2GuardCommitFailed) as ei: + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme"): + pass + + assert ei.value.error_code == "INVALID_REQUEST" + assert ei.value.released is False + assert ei.value.release_error is not None + assert "ConnectionError" in ei.value.release_error + assert "FAILED" in str(ei.value) and "stranded" in str(ei.value) + + async def test_commit_failed_records_release_non_success(self, async_mock_client, mandate) -> None: + # Release returns 5xx after a 4xx commit rejection. released=False and the + # response status code surfaces in release_error. + async_mock_client.create_reservation.return_value = allow_response() + async_mock_client.commit_reservation.return_value = commit_error_response("INVALID_REQUEST", status=400) + async_mock_client.release_reservation.return_value = commit_error_response("INTERNAL_ERROR", status=500) + + with pytest.raises(AP2GuardCommitFailed) as ei: + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme"): + pass + + assert ei.value.released is False + assert ei.value.release_error is not None + assert "500" in ei.value.release_error + + +# --------------------------------------------------------------------------- +# AP2-shape adapter end-to-end through AsyncGuardedPayment +# --------------------------------------------------------------------------- + + +class TestAsyncFromAp2EndToEnd: + """Adapter → async guard → wire: confirms the AP2 sample-type shape flows through + the full async stack (not just the sync one covered by test_ap2_shape_adapter.py).""" + + async def test_from_ap2_shape_flows_through_async_guard(self, async_mock_client) -> None: + from dataclasses import dataclass + + from runcycles_ap2 import AP2Mandate + + @dataclass + class _PaymentAmount: + value: str + currency: str + + @dataclass + class _Payee: + website: str + + @dataclass + class _PaymentMandate: + transaction_id: str + payment_amount: _PaymentAmount + payee: _Payee + + @dataclass + class _CheckoutMandate: + hash: str + + async_mock_client.create_reservation.return_value = allow_response("rsv_async_e2e") + async_mock_client.commit_reservation.return_value = commit_success_response() + + pm = _PaymentMandate( + transaction_id="ap2-tx-async-e2e", + payment_amount=_PaymentAmount(value="12.50", currency="USD"), + payee=_Payee(website="merchant.example"), + ) + cm = _CheckoutMandate(hash="ch_async_e2e") + mandate = AP2Mandate.from_ap2(pm, cm, open_mandate_hash="omh_async_e2e") + + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme") as guard: + assert guard.reservation_id == "rsv_async_e2e" + + body = async_mock_client.create_reservation.call_args[0][0] + assert body["subject"]["dimensions"]["ap2_transaction_id"] == "ap2-tx-async-e2e" + assert body["subject"]["dimensions"]["checkout_hash"] == "ch_async_e2e" + assert body["subject"]["dimensions"]["open_mandate_hash"] == "omh_async_e2e" + # open_mandate_hash present → lock scope shifts to open_mandate (AP2 §6). + assert ":open_mandate:" in body["idempotency_key"] From 788d7087995fb2116a60c1be7a871ce6f03f23f1 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Wed, 13 May 2026 16:17:52 -0400 Subject: [PATCH 3/5] fix(async): wrap CancelledError mid-commit as AP2GuardCommitUncertain (P1) plus refresh stale test counts in CHANGELOG/AUDIT (P3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AUDIT.md | 8 +++++--- CHANGELOG.md | 4 ++-- runcycles_ap2/exceptions.py | 6 ++++++ runcycles_ap2/guard.py | 22 ++++++++++++++++++++++ tests/test_async_guard.py | 24 ++++++++++++++++++++++++ 5 files changed, 59 insertions(+), 5 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index 611f37d..e846302 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -18,9 +18,11 @@ Per `CLAUDE.md`: this file records material changes to the repo (server, admin, - `AsyncGuardedPayment` class - `cycles_guard_payment_async(...)` factory -**Test posture after addition:** -- 128 tests (up from 110), 97.60% coverage. -- 18 new tests in `tests/test_async_guard.py` mirror the sync test surface. +**Test posture after addition (including the audit follow-up commit and the CancelledError fix):** +- 147 tests (up from 110), 99.20% coverage. +- 37 tests in `tests/test_async_guard.py` mirror the sync surface across clean commit, dry-run, denial, release on exception, all five commit-uncertain branches (terminal codes, 5xx with/without body, transport error, uncaught exception, asyncio cancellation), commit-failed branches incl. release-failure recording, and an AP2 sample-type end-to-end through the async path. + +**Cancellation policy on async commit:** an outer `asyncio.CancelledError` during the in-flight commit POST is treated as commit-uncertain. Since `asyncio.CancelledError` inherits from `BaseException`, the generic `except Exception` clause does not catch it; without an explicit handler, the cancellation would escape as raw `CancelledError` with no `reservation_id` or `error_code`, despite the post-PSP unknown-outcome contract. The async `_handle_commit` now explicitly converts it to `AP2GuardCommitUncertain(error_code="COMMIT_CANCELLED")` with the original on `__cause__`. No release is attempted (the commit may have reached and settled Cycles before the cancel landed). Sync code path is unaffected; cancellation only applies to async. **No protocol changes. No wire-shape changes.** Existing v0.1.x callers see the sync API entirely unchanged. diff --git a/CHANGELOG.md b/CHANGELOG.md index e0704f0..2117c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `AsyncGuardedPayment` and `cycles_guard_payment_async(...)` — async-context-manager variant for asyncio runtimes (FastAPI, anyio, OpenAI async SDK, etc.). Same exception contract, same idempotency-key derivation (including the open-mandate consume-once scope), and same commit-uncertainty handling as the sync `GuardedPayment`. - New example `examples/ap2_human_not_present_async.py`. - README "Async variant (v0.2+)" quickstart snippet. -- 18 new tests in `tests/test_async_guard.py` mirroring the sync test surface; total 128 tests, ≥ 95% coverage, ruff + mypy strict. +- 37 tests in `tests/test_async_guard.py` mirroring the sync test surface (clean commit, dry-run, denial, release on exception, commit-uncertain branches incl. cancellation, commit-failed branches incl. release-failure recording, AP2 sample-type adapter end-to-end). Total 147 tests, 99.20% coverage. ruff + mypy strict. ### Unchanged - No wire-shape changes. No exception or validation changes. Existing v0.1.x sync callers see the API entirely unchanged. @@ -30,7 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Deterministic idempotency keys with automatic consume-once scope selection — `ap2:open_mandate:{sha256(open_mandate_hash)[:32]}:{phase}` when the mandate carries an open mandate hash (AP2 spec §6 normative consume-once defense for human-not-present flows), `ap2:tx:{sha256(transaction_id)[:32]}:{phase}` otherwise. Hash is fixed-length (128-bit collision resistance), header-safe, and the phase suffix is always preserved. - `AP2DryRunResult` exception — raised from `__enter__` when `dry_run=True` so the `with` body cannot execute (prevents real PSP calls under dry-run from moving money off the books). - `AP2GuardCommitFailed` exception — raised after a release attempt when the server rejects a commit with an unrecognized code; carries `released: bool` and `release_error: str | None` so the caller can distinguish "budget recovered" from "budget stranded until TTL". -- `AP2GuardCommitUncertain` exception — raised whenever the commit outcome is unknown after the PSP body ran: terminal codes (`RESERVATION_FINALIZED` / `RESERVATION_EXPIRED` / `IDEMPOTENCY_MISMATCH`), transport-level failures (`error_code="TRANSPORT_ERROR"`), 5xx server errors (`SERVER_ERROR` or specific code), and uncaught exceptions during commit (`COMMIT_RAISED`, original chained via `__cause__`). No auto-release in any of these cases — the POST may have mutated Cycles before the failure. +- `AP2GuardCommitUncertain` exception — raised whenever the commit outcome is unknown after the PSP body ran: terminal codes (`RESERVATION_FINALIZED` / `RESERVATION_EXPIRED` / `IDEMPOTENCY_MISMATCH`), transport-level failures (`error_code="TRANSPORT_ERROR"`), 5xx server errors (`SERVER_ERROR` or specific code), uncaught exceptions during commit (`COMMIT_RAISED`, original chained via `__cause__`), and — async only — `asyncio.CancelledError` mid-flight (`COMMIT_CANCELLED`, original chained via `__cause__`). No auto-release in any of these cases — the POST may have mutated Cycles before the failure. - USD-only enforcement; non-USD raises `AP2CurrencyError`. Rejects NaN, +/-Infinity, and amounts with more than 8 decimal places (sub-micro precision); wraps all `decimal` failures as `AP2MandateError`. - Exact integer-tuple conversion in `amount_micros()` — does not depend on the default decimal context, so large valid inputs convert exactly instead of being silently rounded. - Pre-allocation digit-count cap (≤ 19 integer digits, the int64 USD_MICROCENTS ceiling) blocks exponent-notation DoS like `Decimal("1E+1000000000000")` that would otherwise hang allocating a trillion-digit scaling factor. diff --git a/runcycles_ap2/exceptions.py b/runcycles_ap2/exceptions.py index 1502372..5c8ec91 100644 --- a/runcycles_ap2/exceptions.py +++ b/runcycles_ap2/exceptions.py @@ -74,6 +74,12 @@ class AP2GuardCommitUncertain(AP2GuardError): - **Uncaught exception during commit** (``error_code="COMMIT_RAISED"``) — the client code raised before a response was processed; the chained ``__cause__`` is the original exception. May or may not have reached the server. + - **Cancellation mid-commit** (``error_code="COMMIT_CANCELLED"``, async only) — + an outer ``asyncio.CancelledError`` landed while the commit POST was in + flight. Because ``asyncio.CancelledError`` is a ``BaseException``, it must + be handled separately from the ``COMMIT_RAISED`` path; semantics are + otherwise identical. The chained ``__cause__`` is the original + ``CancelledError``. The caller MUST handle this exception — silently returning would let unreconciled payment state propagate. Use ``error_code`` to distinguish the diff --git a/runcycles_ap2/guard.py b/runcycles_ap2/guard.py index 8fd9887..19c7e0e 100644 --- a/runcycles_ap2/guard.py +++ b/runcycles_ap2/guard.py @@ -7,6 +7,7 @@ from __future__ import annotations +import asyncio import logging import time from types import TracebackType @@ -676,6 +677,27 @@ async def _handle_commit(self) -> None: ) try: response = await self._client.commit_reservation(self._reservation_id, body) + except asyncio.CancelledError as exc: + # asyncio.CancelledError is a BaseException on Python 3.8+, so ``except + # Exception`` does NOT catch it. We MUST handle it explicitly here, before + # the generic Exception clause, because cancellation mid-commit has the + # same post-PSP unknown-outcome semantics as any other commit failure: the + # request may have reached Cycles and settled the budget before the cancel + # landed. Auto-releasing could undo a real settle. The caller's + # ``except AP2GuardCommitUncertain`` handler runs as a quick reconciliation + # signal; the original CancelledError stays on ``__cause__`` so anyone + # checking explicitly can detect it, and the task still terminates once + # the handler returns. + logger.warning( + "AP2 commit cancelled mid-flight (async): tx=%s. Outcome unknown.", + self._mandate.transaction_id, + ) + raise AP2GuardCommitUncertain( + f"AP2 commit cancelled mid-flight for transaction {self._mandate.transaction_id}. " + "Commit may have reached Cycles before cancellation; no release was attempted.", + error_code="COMMIT_CANCELLED", + reservation_id=self._reservation_id, + ) from exc except Exception as exc: logger.exception("AP2 commit raised (async): tx=%s", self._mandate.transaction_id) raise AP2GuardCommitUncertain( diff --git a/tests/test_async_guard.py b/tests/test_async_guard.py index a60cee5..7d86ac6 100644 --- a/tests/test_async_guard.py +++ b/tests/test_async_guard.py @@ -423,6 +423,30 @@ async def test_commit_raises_surfaces_as_uncertain(self, async_mock_client, mand assert isinstance(ei.value.__cause__, ConnectionError) async_mock_client.release_reservation.assert_not_awaited() + async def test_commit_cancellation_surfaces_as_uncertain(self, async_mock_client, mandate) -> None: + # P1 regression: asyncio.CancelledError is a BaseException, so ``except + # Exception`` (the COMMIT_RAISED branch) does NOT catch it. Without the + # explicit CancelledError handler, an outer timeout/cancel mid-flight would + # escape as raw CancelledError with no reservation_id or error_code, despite + # the post-PSP unknown-outcome contract saying it must raise + # AP2GuardCommitUncertain. Wrapping converts to the domain exception so the + # caller's reconciliation handler still runs; the original CancelledError + # rides on __cause__ for callers who want to detect it explicitly. + import asyncio + + async_mock_client.create_reservation.return_value = allow_response("rsv_cancel") + async_mock_client.commit_reservation.side_effect = asyncio.CancelledError() + + with pytest.raises(AP2GuardCommitUncertain) as ei: + async with cycles_guard_payment_async(async_mock_client, mandate=mandate, run_id="r", tenant="acme"): + pass + + assert ei.value.error_code == "COMMIT_CANCELLED" + assert ei.value.reservation_id == "rsv_cancel" + assert isinstance(ei.value.__cause__, asyncio.CancelledError) + # CRITICAL: no release attempt — commit may have settled before cancel landed. + async_mock_client.release_reservation.assert_not_awaited() + # --------------------------------------------------------------------------- # Commit-failed (4xx unrecognized) From 3909133fb7ba52bd8230e54306e164f58f37a3f9 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Wed, 13 May 2026 16:23:20 -0400 Subject: [PATCH 4/5] docs: surface async-only COMMIT_CANCELLED in README + soften CHANGELOG 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. --- CHANGELOG.md | 7 +++++-- README.md | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2117c3d..d31f5f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.2.0] — 2026-05-13 ### Added -- `AsyncGuardedPayment` and `cycles_guard_payment_async(...)` — async-context-manager variant for asyncio runtimes (FastAPI, anyio, OpenAI async SDK, etc.). Same exception contract, same idempotency-key derivation (including the open-mandate consume-once scope), and same commit-uncertainty handling as the sync `GuardedPayment`. +- `AsyncGuardedPayment` and `cycles_guard_payment_async(...)` — async-context-manager variant for asyncio runtimes (FastAPI, anyio, OpenAI async SDK, etc.). Same exception classes as sync plus one async-only condition (cancellation mid-commit, see below). Same idempotency-key derivation (including the open-mandate consume-once scope) and same commit-uncertainty handling as the sync `GuardedPayment`. - New example `examples/ap2_human_not_present_async.py`. - README "Async variant (v0.2+)" quickstart snippet. - 37 tests in `tests/test_async_guard.py` mirroring the sync test surface (clean commit, dry-run, denial, release on exception, commit-uncertain branches incl. cancellation, commit-failed branches incl. release-failure recording, AP2 sample-type adapter end-to-end). Total 147 tests, 99.20% coverage. ruff + mypy strict. +### Changed (async-only) +- `AP2GuardCommitUncertain` gains one new `error_code` value, `"COMMIT_CANCELLED"`, raised when an `asyncio.CancelledError` lands while the async commit POST is in flight. The exception class itself is unchanged; only the set of `error_code` discriminators grew. Sync callers see no change. + ### Unchanged -- No wire-shape changes. No exception or validation changes. Existing v0.1.x sync callers see the API entirely unchanged. +- No wire-shape changes. No validation changes. No sync API changes — existing v0.1.x sync callers see the API entirely unchanged. ### Still planned for v0.3 - Multi-currency. diff --git a/README.md b/README.md index 9226079..096973d 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ async def charge(mandate: AP2Mandate) -> None: guard.attach_receipt_fields(psp_ref=psp_receipt.id) ``` -`AsyncGuardedPayment` raises the same exceptions (`AP2GuardDenied`, `AP2DryRunResult`, `AP2GuardCommitUncertain`, `AP2GuardCommitFailed`) under the same conditions as the sync variant. +`AsyncGuardedPayment` raises the same exceptions (`AP2GuardDenied`, `AP2DryRunResult`, `AP2GuardCommitUncertain`, `AP2GuardCommitFailed`) under the same conditions as the sync variant, plus one async-only condition: an `asyncio.CancelledError` landing while the commit POST is in flight is wrapped as `AP2GuardCommitUncertain(error_code="COMMIT_CANCELLED")` with the original cancellation chained via `__cause__`. ## From an existing AP2 SDK object @@ -120,7 +120,7 @@ Required upstream attributes (duck-typed): `payment_mandate.transaction_id`, `pa | `Decision.ALLOW`, body raises | **Release** | Reason `ap2_guard_failed:{ExcType}`, idempotency key includes the exception type | | `Decision.DENY` | **Neither** | `AP2GuardDenied` raised in `__enter__`; real money never moves | | HTTP / transport error on reserve | **Neither** | `AP2GuardDenied` raised; caller can retry — same consume-once scope (`open_mandate_hash` when present, otherwise `transaction_id`) ⇒ same reserve key | -| Commit transport error / 5xx / `RESERVATION_FINALIZED` / `RESERVATION_EXPIRED` / `IDEMPOTENCY_MISMATCH` / uncaught exception | **Raise, no release** | `AP2GuardCommitUncertain` raised. The commit POST may have reached and mutated Cycles before the failure, so auto-release could undo a successful settle. `error_code` distinguishes the flavor (`TRANSPORT_ERROR`, `SERVER_ERROR`, `COMMIT_RAISED`, or the specific code) | +| Commit transport error / 5xx / `RESERVATION_FINALIZED` / `RESERVATION_EXPIRED` / `IDEMPOTENCY_MISMATCH` / uncaught exception / `asyncio.CancelledError` (async only) | **Raise, no release** | `AP2GuardCommitUncertain` raised. The commit POST may have reached and mutated Cycles before the failure, so auto-release could undo a successful settle. `error_code` distinguishes the flavor (`TRANSPORT_ERROR`, `SERVER_ERROR`, `COMMIT_RAISED`, `COMMIT_CANCELLED` *(async only)*, or the specific code) | | Commit returns 4xx with unrecognized code | **Release + raise** | Server explicitly rejected the request (malformed, forbidden, etc.) — release is safe. `AP2GuardCommitFailed` raised with `released` + `release_error` so the caller can still see the reconciliation context | | `guard.abort(reason)` called inside `with` | **Release** | Reason `ap2_guard_aborted:{reason}` | | `dry_run=True` | **Neither** | `__enter__` raises `AP2DryRunResult` carrying the decision payload — the `with` body never runs, so a real PSP call cannot leak under a dry-run probe | @@ -216,7 +216,7 @@ Exception hierarchy: | `AP2GuardError` | Base for all AP2-guard errors | | `AP2GuardDenied` | Cycles returned `DENY` or the reserve POST failed | | `AP2DryRunResult` | Raised from `__enter__` when `dry_run=True` — carries the decision payload; the `with` body never executes | -| `AP2GuardCommitUncertain` | Commit outcome is unknown after the body ran. Covers terminal status codes (`RESERVATION_FINALIZED`, `RESERVATION_EXPIRED`, `IDEMPOTENCY_MISMATCH`), transport-level failures (`error_code="TRANSPORT_ERROR"`), 5xx server errors (`error_code="SERVER_ERROR"` or specific code), and uncaught exceptions during commit (`error_code="COMMIT_RAISED"`, with the original chained via `__cause__`). **No auto-release** — the POST may have mutated Cycles before the failure. Reconcile with PSP | +| `AP2GuardCommitUncertain` | Commit outcome is unknown after the body ran. Covers terminal status codes (`RESERVATION_FINALIZED`, `RESERVATION_EXPIRED`, `IDEMPOTENCY_MISMATCH`), transport-level failures (`error_code="TRANSPORT_ERROR"`), 5xx server errors (`error_code="SERVER_ERROR"` or specific code), uncaught exceptions during commit (`error_code="COMMIT_RAISED"`, original chained via `__cause__`), and — **async only** — `asyncio.CancelledError` mid-flight (`error_code="COMMIT_CANCELLED"`, original chained via `__cause__`). **No auto-release** — the POST may have mutated Cycles before the failure. Reconcile with PSP | | `AP2GuardCommitFailed` | Commit was rejected with an unrecognized code after the body ran. Check `.released` (bool) and `.release_error` (string \| None) on the exception — `released=False` means budget is stranded until TTL; reconcile with PSP either way | | `AP2CurrencyError` | Non-USD mandate in v0.1 (subclass of `ValueError`) | | `AP2MandateError` | Adapter input is malformed — NaN, infinity, sub-micro precision, missing payee, etc. (subclass of `ValueError`) | From 07e93afed9824aafbe7f6c79f84f417d4b62f0ae Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Wed, 13 May 2026 16:29:56 -0400 Subject: [PATCH 5/5] docs: complete COMMIT_CANCELLED coverage in internal docstrings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- runcycles_ap2/exceptions.py | 5 +++-- runcycles_ap2/guard.py | 10 +++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/runcycles_ap2/exceptions.py b/runcycles_ap2/exceptions.py index 5c8ec91..8039284 100644 --- a/runcycles_ap2/exceptions.py +++ b/runcycles_ap2/exceptions.py @@ -117,8 +117,9 @@ class AP2GuardCommitFailed(AP2GuardError): This is NOT the right exception for unknown-outcome failures. Anything where the commit POST might have reached and mutated Cycles before the failure — transport errors, 5xx, terminal reservation statuses (``RESERVATION_FINALIZED`` / - ``RESERVATION_EXPIRED`` / ``IDEMPOTENCY_MISMATCH``), and uncaught exceptions — - is raised as :class:`AP2GuardCommitUncertain` with **no auto-release**. + ``RESERVATION_EXPIRED`` / ``IDEMPOTENCY_MISMATCH``), uncaught exceptions, and + (async only) ``asyncio.CancelledError`` mid-flight — is raised as + :class:`AP2GuardCommitUncertain` with **no auto-release**. """ def __init__( diff --git a/runcycles_ap2/guard.py b/runcycles_ap2/guard.py index 19c7e0e..71239b0 100644 --- a/runcycles_ap2/guard.py +++ b/runcycles_ap2/guard.py @@ -480,9 +480,13 @@ class AsyncGuardedPayment: - ``dry_run=True`` → raises :class:`AP2DryRunResult` from ``__aenter__`` so the ``async with`` body never executes (a body-level PSP call would otherwise move money with no Cycles record). - - Post-PSP commit unknown-outcome (terminal codes, transport, 5xx, uncaught) → - raises :class:`AP2GuardCommitUncertain`. **No auto-release** — the commit POST - may have reached and settled Cycles before the failure was observed. + - Post-PSP commit unknown-outcome (terminal codes, transport, 5xx, uncaught + exception, or an ``asyncio.CancelledError`` landing mid-flight) → raises + :class:`AP2GuardCommitUncertain`. **No auto-release** — the commit POST may + have reached and settled Cycles before the failure was observed. The + cancellation path is async-only and surfaces as + ``error_code="COMMIT_CANCELLED"`` with the original ``CancelledError`` + chained via ``__cause__``. - Commit rejected with unrecognized 4xx code → raises :class:`AP2GuardCommitFailed` after attempting a release; check the exception's ``released`` / ``release_error`` attributes to know whether budget was recovered.