Skip to content

feat: AsyncGuardedPayment (v0.2.0)#4

Merged
amavashev merged 5 commits into
mainfrom
feat/v0.2.0-async-guarded-payment
May 13, 2026
Merged

feat: AsyncGuardedPayment (v0.2.0)#4
amavashev merged 5 commits into
mainfrom
feat/v0.2.0-async-guarded-payment

Conversation

@amavashev
Copy link
Copy Markdown
Contributor

@amavashev amavashev commented May 13, 2026

Summary

Adds 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
from runcycles import AsyncCyclesClient, CyclesConfig
from runcycles_ap2 import AP2Mandate, cycles_guard_payment_async

async def charge(mandate: AP2Mandate) -> None:
    async with AsyncCyclesClient(CyclesConfig.from_env()) 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)

Implementation note

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

Contract Same path
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 populated)
Auto-release on exception inside the body
Idempotency-key derivation incl. open-mandate consume-once scope
set_actual_micros type/range validation
attach_receipt_fields / abort / RuntimeAuthorityReceipt

Test plan

  • 18 new async tests in tests/test_async_guard.py mirroring 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_micros int64 validation, open-mandate scope routing
  • All 110 sync tests still pass — no regressions
  • ruff check . && ruff format --check . — clean
  • mypy --strict runcycles_ap2 — 0 errors (8 files)
  • pytest --cov=runcycles_ap2 --cov-fail-under=95128 passed, 97.60% coverage
  • python -m build — sdist + wheel build cleanly as runcycles_ap2-0.2.0
  • CI green before merge

Out of v0.2 (still planned for v0.3)

  • Multi-currency (still raises AP2CurrencyError on non-USD)
  • payment.refund convenience helper
  • Server-verifiable runtime-authority receipt (requires cycles-protocol signed-receipt field)

Release sequence after merge

  1. Tag v0.2.0 and push → fires python-publish.yml workflow
  2. Build job verifies pyproject.toml version (0.2.0) matches tag
  3. Publish to PyPI (trusted-publisher OIDC, already configured for v0.1.0)
  4. Create GitHub release from CHANGELOG body

amavashev added 2 commits May 13, 2026 15:24
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.
@amavashev
Copy link
Copy Markdown
Contributor Author

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

Test class New tests
TestAsyncCleanCommit set_actual_micros_negative_rejected, set_actual_micros_rejects_non_int_types (parametrized: bool/float/str/None/list)
TestAsyncReleaseOnException value_error_uses_exception_type_in_key
TestAsyncDenial missing_reservation_id_raises_guard_denied (protocol-violation path)
TestAsyncDryRun dry_run_deny_raises_guard_denied_not_dry_run_result, dry_run_result_carries_caps_and_scopes, non_dry_run_does_not_set_flag
TestAsyncCommitUncertain reservation_finalized_raises_uncertain (third terminal code), commit_5xx_without_body_uses_synthetic_code
TestAsyncCommitFailed commit_failed_records_release_transport_failure, commit_failed_records_release_non_success
TestAsyncFromAp2EndToEnd (new) from_ap2_shape_flows_through_async_guard

Doc drift fixes in guard.py

  • Module docstring now covers both sync and async classes (was "Sync context manager wrapping...")
  • AsyncGuardedPayment class docstring now enumerates the decision rules directly so help(AsyncGuardedPayment) shows the contract
  • AsyncGuardedPayment.abort docstring picks up the "Use for late-discovered failures" tail
  • AsyncGuardedPayment.__aexit__ picks up the NOTE comment about dry-run never reaching __aexit__

Gates after follow-up

  • ruff check . && ruff format --check . — clean
  • mypy --strict runcycles_ap2 — 0 errors (8 files)
  • pytest --cov=runcycles_ap2 --cov-fail-under=95146 passed, 99.20% coverage (up from 128/97.60%)
  • python -m build — sdist + wheel build cleanly

No public API change. No wire-shape change. No exception or validation logic change.

PR #4 should now be ready for merge; CI re-run on the new commit.

… (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.
@amavashev
Copy link
Copy Markdown
Contributor Author

P1 + P3 fixes pushed (788d708)

Both findings addressed.

P1 — async commit cancellation now wrapped as commit-uncertain

asyncio.CancelledError is a BaseException on Python 3.8+, so the generic except Exception clause in async _handle_commit did NOT catch it. Reviewer reproduced this with an AsyncMock raising CancelledError: the raw exception escaped, no release was attempted, and the caller had no reservation_id / error_code despite the post-PSP unknown-outcome contract.

Fix: explicit except asyncio.CancelledError clause ahead of the generic Exception clause:

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 exc

Same post-PSP unknown semantics as the existing branches — no auto-release (commit may have settled), __cause__ chains the original CancelledError, caller's except AP2GuardCommitUncertain runs as a quick reconciliation step and the task still terminates after.

Sync path unaffected.

Public API addition (additive, on an existing exception): new error_code value "COMMIT_CANCELLED" on AP2GuardCommitUncertain. Documented in the class docstring + CHANGELOG entry.

New regression test: test_commit_cancellation_surfaces_as_uncertain — asserts the conversion, the chained __cause__, and that release is NOT attempted.

P3 — refreshed stale test counts

CHANGELOG.md and AUDIT.md v0.2.0 entries still cited the initial-commit numbers (18 new tests, 128 total). Updated to current: 147 total, 37 in test_async_guard.py, 99.20% coverage.

Gates

  • ruff check . && ruff format --check . — clean
  • mypy --strict runcycles_ap2 — 0 errors (8 files)
  • pytest --cov=runcycles_ap2 --cov-fail-under=95147 passed, 99.20% coverage
  • python -m build — sdist + wheel build cleanly as runcycles_ap2-0.2.0

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.
@amavashev
Copy link
Copy Markdown
Contributor Author

Doc parity for COMMIT_CANCELLED pushed (3909133)

Three public-facing doc spots were still stale after the previous commit added COMMIT_CANCELLED. All fixed:

Spot Fix
README async note (line 101) "raises the same exceptions under the same conditions as sync" → now adds "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__"
README lifecycle table (line 123) Scenario column includes asyncio.CancelledError (async only); error_code enumeration includes COMMIT_CANCELLED (async only)
README exception table (line 219) AP2GuardCommitUncertain row covers COMMIT_CANCELLED (async only) with the chained __cause__ contract
CHANGELOG line 11 "Same exception contract" → "Same exception classes plus one async-only condition (cancellation mid-commit, see below)"
CHANGELOG line 17 "No exception or validation changes" was overbroad. Split into a new Changed (async-only) subsection recording the additive error_code value, plus a narrower Unchanged line scoped to wire shape, validation, and the sync API

No code change. Gates re-verified locally: ruff check clean, mypy --strict 0 errors, 147 tests pass.

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.
@amavashev
Copy link
Copy Markdown
Contributor Author

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:

Spot Fix
AsyncGuardedPayment.__doc__ (guard.py) — Decision rules bullet on post-PSP unknown outcomes Now reads "terminal codes, transport, 5xx, uncaught exception, or an asyncio.CancelledError landing mid-flight" plus a sentence noting error_code="COMMIT_CANCELLED" and the chained __cause__
AP2GuardCommitFailed.__doc__ (exceptions.py) — the "this is NOT the right exception for unknown-outcome failures" paragraph pointing readers at AP2GuardCommitUncertain List now includes "(async only) asyncio.CancelledError mid-flight"

Runtime behavior unchanged. AP2GuardCommitUncertain.__doc__ already documented COMMIT_CANCELLED from the original fix; these two were the last internal sources of truth missing the entry. Public README is current.

Gates: ruff check clean, mypy --strict 0 errors, 147 tests pass.

@amavashev amavashev merged commit 76123ea into main May 13, 2026
6 checks passed
@amavashev amavashev deleted the feat/v0.2.0-async-guarded-payment branch May 13, 2026 20:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant