From 998015e23f5b4edc1eeef5602d4c3645ad20f7c0 Mon Sep 17 00:00:00 2001 From: Parv Ahuja <17094219+parvahuja@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:15:32 -0700 Subject: [PATCH 01/11] ci: validate stacked pull requests --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 495c877..616f48d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [main, master] pull_request: - branches: [main, master] permissions: contents: read From fea43f215fb0f31397fb2361ac67dbeb6eee7859 Mon Sep 17 00:00:00 2001 From: Parv Ahuja <17094219+parvahuja@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:08:13 -0700 Subject: [PATCH 02/11] feat: add shared payment runtime --- .changelog/payment-runtime.md | 7 + .github/workflows/ci.yml | 2 + README.md | 18 + src/mpp/runtime.py | 752 +++++++++++++++++++++++++++++++ tests/test_runtime.py | 804 ++++++++++++++++++++++++++++++++++ tests/test_tempo.py | 48 ++ 6 files changed, 1631 insertions(+) create mode 100644 .changelog/payment-runtime.md create mode 100644 src/mpp/runtime.py create mode 100644 tests/test_runtime.py diff --git a/.changelog/payment-runtime.md b/.changelog/payment-runtime.md new file mode 100644 index 0000000..684f319 --- /dev/null +++ b/.changelog/payment-runtime.md @@ -0,0 +1,7 @@ +--- +pympp: minor +--- + +Added an owned-loop `PaymentRuntime` with method factories, sync and async +lifecycle contexts, shared events, and sync/async credential creation for +custom integrations. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 616f48d..b6b0917 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -234,9 +234,11 @@ jobs: import mpp import mpp.extensions.mcp as mcp import mpp.methods.tempo as tempo + from mpp.runtime import PaymentRuntime assert tempo.CHAIN_ID == 4217 assert mcp.CODE_PAYMENT_REQUIRED == -32042 + with PaymentRuntime(): pass for expr, hint in ( ("tempo.ChargeIntent", "pympp[tempo]"), diff --git a/README.md b/README.md index b0d0070..5d6421a 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,24 @@ async with Client(methods=[tempo(account=account, intents={"charge": ChargeInten response = await client.get("https://mpp.dev/api/ping/paid") ``` +### Shared payment-method lifecycle + +Custom integrations can use one owned event loop for loop-bound payment methods: + +```python +from mpp.runtime import PaymentRuntime + +method_factory = ... +challenges = ... + +async with PaymentRuntime(method_factories=[method_factory]) as runtime: + challenge, method = runtime.match_challenge(challenges) + credential = await runtime.create_credential(challenge, method) +``` + +Factories run once on the owned loop. Async context-manager results are closed +on that same loop; direct `methods=` are borrowed and must be loop-independent. + ## Examples | Example | Description | diff --git a/src/mpp/runtime.py b/src/mpp/runtime.py new file mode 100644 index 0000000..996f389 --- /dev/null +++ b/src/mpp/runtime.py @@ -0,0 +1,752 @@ +"""Shared lifecycle for client-side payment methods.""" + +from __future__ import annotations + +import asyncio +import inspect +import threading +from collections.abc import Awaitable, Callable, Coroutine, Mapping, Sequence +from contextlib import AbstractAsyncContextManager, AsyncExitStack, contextmanager +from contextvars import ContextVar, copy_context +from dataclasses import dataclass +from typing import Any, Protocol, Self, TypeVar, runtime_checkable + +from mpp import Challenge, Credential +from mpp.events import ( + CHALLENGE_RECEIVED, + CREDENTIAL_CREATED, + EventDispatcher, + EventPayload, +) + +_T = TypeVar("_T") + + +@dataclass(slots=True) +class _Lifecycle: + active: bool = True + + +@dataclass(slots=True) +class _RuntimeLease: + owners: set[object] + + @property + def active(self) -> bool: + return bool(self.owners) + + +_RUNTIME_LEASES: ContextVar[dict[int, _RuntimeLease] | None] = ContextVar( + "mpp_runtime_leases", + default=None, +) +_LIFECYCLE: ContextVar[_Lifecycle | None] = ContextVar("mpp_runtime_lifecycle", default=None) + + +@contextmanager +def _lifecycle(): + lifecycle = _Lifecycle() + token = _LIFECYCLE.set(lifecycle) + try: + yield + finally: + lifecycle.active = False + _LIFECYCLE.reset(token) + + +def _lifecycle_active() -> bool: + lifecycle = _LIFECYCLE.get() + return lifecycle is not None and lifecycle.active + + +def _operation_owner() -> object: + try: + return asyncio.current_task() or threading.current_thread() + except RuntimeError: + return threading.current_thread() + + +async def _wait_for_task(task: asyncio.Task[_T]) -> _T: + """Wait for completion while preserving an already-raised cancellation.""" + while not task.done(): + try: + await asyncio.shield(task) + except asyncio.CancelledError: + continue + return task.result() + + +@runtime_checkable +class Method(Protocol): + """Client-side payment method.""" + + name: str + + async def create_credential(self, challenge: Challenge, /) -> Credential: + """Create a credential for a challenge.""" + ... + + +_MethodFactoryResult = Method | AbstractAsyncContextManager[Method] +MethodFactory = Callable[ + [], + _MethodFactoryResult | Awaitable[_MethodFactoryResult], +] + + +class _AsyncBridge: + """Own one lazy event loop for payment-method work.""" + + def __init__(self) -> None: + self._closed = False + self._lock = threading.Lock() + self._loop: asyncio.AbstractEventLoop | None = None + self._ready = threading.Event() + self._stopped = threading.Event() + self._start_error: BaseException | None = None + self._stop_error: BaseException | None = None + self._thread: threading.Thread | None = None + + def _record_stop_error(self, error: BaseException) -> None: + if self._stop_error is None: + self._stop_error = error + + @staticmethod + def _drain_loop(loop: asyncio.AbstractEventLoop) -> BaseException | None: + error: BaseException | None = None + try: + pending = asyncio.all_tasks(loop) + except BaseException as cause: + pending = set() + error = cause + for task in pending: + try: + task.cancel() + except BaseException as cause: + if error is None: + error = cause + + async def drain_pending() -> None: + await asyncio.gather(*pending, return_exceptions=True) + + cleanup = ( + *((drain_pending,) if pending else ()), + loop.shutdown_asyncgens, + loop.shutdown_default_executor, + ) + for action in cleanup: + try: + loop.run_until_complete(action()) + except BaseException as cause: + if error is None: + error = cause + return error + + def _submit(self, coroutine: Coroutine[Any, Any, _T]) -> Any: + with self._lock: + if self._closed: + raise RuntimeError("PaymentRuntime is closed") + if self._thread is None: + thread = threading.Thread( + target=self._run, + name="pympp-payment-runtime", + daemon=True, + ) + self._thread = thread + try: + thread.start() + except BaseException as error: + self._start_error = error + self._ready.set() + self._stopped.set() + raise RuntimeError("PaymentRuntime background loop failed to start") from error + self._ready.wait() + with self._lock: + if self._closed: + raise RuntimeError("PaymentRuntime is closed") + if self._start_error is not None: + raise RuntimeError("PaymentRuntime background loop failed to start") from ( + self._start_error + ) + if self._loop is None: + raise RuntimeError("PaymentRuntime background loop failed to start") + if threading.current_thread() is self._thread: + raise RuntimeError("Cannot block the PaymentRuntime background loop") + return copy_context().run( + asyncio.run_coroutine_threadsafe, + coroutine, + self._loop, + ) + + def _run(self) -> None: + initialized = False + stop_error: BaseException | None = None + try: + with asyncio.Runner() as runner: + self._loop = runner.get_loop() + initialized = True + self._ready.set() + try: + self._loop.run_forever() + except BaseException as error: + stop_error = error + cleanup_error = self._drain_loop(self._loop) + if stop_error is None: + stop_error = cleanup_error + except BaseException as error: + if not initialized: + self._start_error = error + elif stop_error is None: + stop_error = error + finally: + if stop_error is not None: + self._record_stop_error(stop_error) + self._ready.set() + self._stopped.set() + + def run(self, coroutine: Coroutine[Any, Any, _T]) -> _T: + """Run async work from synchronous code.""" + try: + future = self._submit(coroutine) + except BaseException: + coroutine.close() + raise + try: + return future.result() + except BaseException: + future.cancel() + raise + + async def run_async(self, coroutine: Coroutine[Any, Any, _T]) -> _T: + """Run async work without blocking the caller's event loop.""" + if asyncio.get_running_loop() is self._loop: + return await coroutine + try: + future = self._submit(coroutine) + except BaseException: + coroutine.close() + raise + try: + return await asyncio.wrap_future(future) + except BaseException: + future.cancel() + raise + + async def _cancel_pending(self) -> None: + current = asyncio.current_task() + pending = [task for task in asyncio.all_tasks() if task is not current] + for task in pending: + task.cancel() + if pending: + await asyncio.gather(*pending, return_exceptions=True) + + def is_current_thread(self) -> bool: + return threading.current_thread() is self._thread + + def cancel_pending(self) -> None: + """Cancel and drain runtime-loop work without stopping the loop.""" + thread, loop = self._thread, self._loop + if thread is None or loop is None or not thread.is_alive(): + return + if self.is_current_thread(): + raise RuntimeError("Cannot synchronously cancel the PaymentRuntime loop") + if loop.is_running(): + asyncio.run_coroutine_threadsafe(self._cancel_pending(), loop).result() + + def close(self) -> None: + """Stop the runtime loop, if it was started.""" + with self._lock: + if self._closed: + already_closed = True + wait = threading.current_thread() is not self._thread + thread = self._thread + else: + already_closed = False + self._closed = True + wait = False + thread = self._thread + if already_closed: + if wait: + self._stopped.wait() + if self._stop_error is not None: + raise self._stop_error + return + if thread is None: + self._stopped.set() + return + if self._loop is None: + self._ready.wait() + loop = self._loop + if loop is None: + if thread.ident is not None: + thread.join() + return + if threading.current_thread() is thread: + loop.stop() + return + error: BaseException | None = None + if thread.is_alive() and loop.is_running(): + try: + future = asyncio.run_coroutine_threadsafe(self._cancel_pending(), loop) + future.result() + except BaseException as cause: + error = cause + try: + loop.call_soon_threadsafe(loop.stop) + except BaseException as cause: + if error is None: + error = cause + if thread.ident is not None: + thread.join() + if error is not None: + raise error + if self._stop_error is not None: + raise self._stop_error + + +class PaymentRuntime: + """Own one event loop and lifecycle for client-side payment methods. + + Direct ``methods`` are borrowed and must be loop-independent. Use + ``method_factories`` for loop-bound methods: factories are called on the + owned loop, and async context-manager results are exited there on close. + """ + + def __init__( + self, + methods: Sequence[Method] | None = None, + *, + method_factories: Sequence[MethodFactory] = (), + events: EventDispatcher | None = None, + ) -> None: + if methods is not None and method_factories: + raise ValueError("Pass either methods or method_factories, not both") + self.methods = tuple(methods or ()) + if any(not _is_method(method) for method in self.methods): + raise TypeError("methods must contain payment Methods") + self._method_factories = tuple(method_factories) + self._method_stack: AsyncExitStack | None = None + self.events = events or EventDispatcher() + self._bridge = _AsyncBridge() + self._lifecycle = threading.Condition() + self._active_operations = 0 + self._state = "new" + self._start_error: BaseException | None = None + self._finalizing = False + self._deferred_close = False + + async def _initialize_methods(self) -> None: + stack = AsyncExitStack() + methods: list[Method] = [] + with _lifecycle(): + try: + for factory in self._method_factories: + value: Any = factory() + if inspect.isawaitable(value): + value = await value + if hasattr(value, "__aenter__") and hasattr(value, "__aexit__"): + value = await stack.enter_async_context(value) + if not _is_method(value): + raise TypeError("Method factory must return a payment Method") + methods.append(value) + except BaseException: + try: + await stack.aclose() + except BaseException: + pass + raise + if self._method_factories: + self.methods = tuple(methods) + self._method_stack = stack + + async def _teardown_methods(self) -> None: + stack, self._method_stack = self._method_stack, None + if stack is not None: + with _lifecycle(): + await stack.aclose() + + def start(self) -> Self: + """Start the owned loop and initialize method factories once.""" + with self._lifecycle: + while self._state == "starting": + if _lifecycle_active(): + raise RuntimeError("Cannot use PaymentRuntime while method factories start") + self._lifecycle.wait() + if self._state == "open": + return self + if self._state in ("closing", "closed"): + if self._start_error is not None: + raise RuntimeError("PaymentRuntime failed to start") from self._start_error + raise RuntimeError("PaymentRuntime is closed") + self._state = "starting" + + try: + self._bridge.run(self._initialize_methods()) + except BaseException as error: + try: + self._bridge.close() + except BaseException: + pass + with self._lifecycle: + self._start_error = error + self._state = "closed" + self._lifecycle.notify_all() + raise + + with self._lifecycle: + self._state = "open" + self._lifecycle.notify_all() + return self + + async def astart(self) -> Self: + """Asynchronously start the runtime without blocking the caller loop.""" + with self._lifecycle: + if self._state == "open": + return self + start = asyncio.create_task(asyncio.to_thread(self.start)) + try: + await asyncio.shield(start) + except asyncio.CancelledError: + await _wait_for_task(start) + raise + return self + + def __enter__(self) -> Self: + return self.start() + + def __exit__(self, *_args: Any) -> None: + self.close() + + async def __aenter__(self) -> Self: + try: + return await self.astart() + except BaseException: + await self.aclose() + raise + + async def __aexit__(self, *_args: Any) -> None: + await self.aclose() + + @contextmanager + def _runtime_operation(self, *, started: bool = False): + key = id(self) + leases = _RUNTIME_LEASES.get() or {} + lease = leases.get(key) + owner = _operation_owner() + with self._lifecycle: + inherited = lease is not None and lease.active + joined = inherited and lease is not None and owner not in lease.owners + if lease is not None and joined: + lease.owners.add(owner) + if inherited: + assert lease is not None + try: + yield + finally: + if joined: + self._release_runtime_lease(lease, owner) + return + + if not started: + self.start() + with self._lifecycle: + if self._state != "open": + raise RuntimeError("PaymentRuntime is closed") + self._active_operations += 1 + lease = _RuntimeLease({owner}) + token = _RUNTIME_LEASES.set({**leases, key: lease}) + try: + yield + finally: + _RUNTIME_LEASES.reset(token) + self._release_runtime_lease(lease, owner) + + def _release_runtime_lease(self, lease: _RuntimeLease, owner: object) -> None: + with self._lifecycle: + lease.owners.discard(owner) + if lease.active: + return + self._active_operations -= 1 + should_close = self._deferred_close and self._active_operations == 0 + if self._active_operations == 0: + self._lifecycle.notify_all() + if should_close: + if self._bridge.is_current_thread(): + return + self._finish_close() + + def _has_active_runtime_lease(self) -> bool: + lease = (_RUNTIME_LEASES.get() or {}).get(id(self)) + return lease is not None and lease.active + + def _finish_close(self) -> None: + with self._lifecycle: + if self._state == "closed": + return + if self._finalizing: + while self._state != "closed": + self._lifecycle.wait() + return + self._finalizing = True + + error: BaseException | None = None + + def cleanup(action: Callable[[], Any]) -> None: + nonlocal error + try: + action() + except BaseException as cause: + if error is None: + error = cause + + try: + cleanup(self._bridge.cancel_pending) + if self._method_stack is not None: + cleanup(lambda: self._bridge.run(self._teardown_methods())) + cleanup(self._bridge.close) + finally: + with self._lifecycle: + self._state = "closed" + self._finalizing = False + self._deferred_close = False + self._lifecycle.notify_all() + if error is not None: + raise error + + async def _finish_close_async(self) -> None: + with self._lifecycle: + if ( + self._state != "closing" + or not self._deferred_close + or self._active_operations + or self._finalizing + ): + return + self._finalizing = True + + error: BaseException | None = None + try: + try: + await self._bridge._cancel_pending() + except BaseException as cause: + error = cause + if self._method_stack is not None: + try: + await self._teardown_methods() + except BaseException as cause: + if error is None: + error = cause + try: + self._bridge.close() + except BaseException as cause: + if error is None: + error = cause + finally: + with self._lifecycle: + self._state = "closed" + self._finalizing = False + self._deferred_close = False + self._lifecycle.notify_all() + if error is not None: + raise error + + def match_challenge( + self, + challenges: Sequence[Any], + *, + prefer_method_order: bool = True, + allow_name_only: bool = False, + ) -> tuple[Any, Method]: + """Match payment challenges against configured methods.""" + pairs = ( + ((challenge, method) for method in self.methods for challenge in challenges) + if prefer_method_order + else ((challenge, method) for challenge in challenges for method in self.methods) + ) + for challenge, method in pairs: + if challenge.method != method.name: + continue + intents = _intent_names(method) + if not allow_name_only and challenge.intent not in ( + intents if intents is not None else {"charge"} + ): + continue + return challenge, method + + available = [challenge.method for challenge in challenges] + installed = [method.name for method in self.methods] + raise ValueError( + f"No compatible payment method. Server offered: {available}, client has: {installed}" + ) + + async def create_credential( + self, + challenge: Challenge, + method: Method, + *, + event_payload: dict[str, Any] | None = None, + ) -> Credential: + """Create a credential on the runtime-owned event loop.""" + return await self.run_async( + self._create_credential( + challenge, + method, + event_payload=event_payload, + ) + ) + + def create_credential_sync( + self, + challenge: Challenge, + method: Method, + *, + event_payload: dict[str, Any] | None = None, + ) -> Credential: + """Synchronously create a credential on the runtime-owned event loop.""" + return self.run_sync( + self._create_credential( + challenge, + method, + event_payload=event_payload, + ) + ) + + def run_sync(self, coroutine: Coroutine[Any, Any, _T]) -> _T: + """Run a coroutine on the owned loop and block for its result.""" + entered = False + try: + with self._runtime_operation(): + entered = True + return self._bridge.run(coroutine) + except BaseException: + if not entered: + coroutine.close() + raise + + async def run_async(self, coroutine: Coroutine[Any, Any, _T]) -> _T: + """Run a coroutine on the owned loop without blocking.""" + entered = False + try: + if not self._has_active_runtime_lease(): + await self.astart() + with self._runtime_operation(started=True): + entered = True + return await self._bridge.run_async(coroutine) + except BaseException: + if not entered: + coroutine.close() + raise + finally: + if entered and self._bridge.is_current_thread(): + await self._finish_close_async() + + async def _create_credential( + self, + challenge: Challenge, + method: Method, + *, + event_payload: dict[str, Any] | None = None, + ) -> Credential: + payload = { + **(event_payload or {}), + "challenge": challenge, + "challenges": [challenge], + "method": method, + } + event_credential = await self._emit_event( + CHALLENGE_RECEIVED, + payload, + first_result=True, + ) + credential = ( + event_credential + if isinstance(event_credential, Credential) + else await method.create_credential(challenge) + ) + await self._emit_event( + CREDENTIAL_CREATED, + {**payload, "credential": credential}, + ) + return credential + + async def emit_event(self, name: str, payload: EventPayload) -> Any: + """Emit an event on the runtime-owned loop.""" + return await self.run_async(self._emit_event(name, payload)) + + async def _emit_event( + self, + name: str, + payload: EventPayload, + *, + first_result: bool = False, + ) -> Any: + return await self.events.emit(name, payload, first_result=first_result) + + def emit_event_sync(self, name: str, payload: EventPayload) -> Any: + """Synchronously emit an event on the runtime-owned loop.""" + return self.run_sync(self._emit_event(name, payload)) + + def close(self) -> None: + """Close method resources and the owned event loop.""" + active_here = self._has_active_runtime_lease() + on_runtime_thread = self._bridge.is_current_thread() + wait_for_bridge = False + with self._lifecycle: + while self._state == "starting": + if _lifecycle_active(): + raise RuntimeError("Cannot close PaymentRuntime while method factories start") + self._lifecycle.wait() + if self._state == "closed": + wait_for_bridge = not on_runtime_thread + elif self._state == "closing": + if active_here or on_runtime_thread or _lifecycle_active(): + return + while self._state != "closed": + self._lifecycle.wait() + wait_for_bridge = True + else: + if on_runtime_thread and not active_here: + raise RuntimeError( + "Cannot close PaymentRuntime from unmanaged runtime-loop work" + ) + self._state = "closing" + if active_here: + self._deferred_close = True + return + if wait_for_bridge: + self._bridge.close() + return + self._finish_close() + + async def aclose(self) -> None: + """Asynchronously close method resources and the owned event loop.""" + if self._bridge.is_current_thread(): + self.close() + return + close = asyncio.create_task(asyncio.to_thread(self.close)) + try: + await asyncio.shield(close) + except asyncio.CancelledError: + await _wait_for_task(close) + raise + + +def _is_method(value: Any) -> bool: + if not isinstance(getattr(value, "name", None), str): + return False + if not callable(getattr(value, "create_credential", None)): + return False + intents = getattr(value, "intents", None) + return intents is None or isinstance(intents, Mapping) + + +def _intent_names(method: Method) -> set[str] | None: + intents = getattr(method, "intents", None) + if intents is not None: + if not isinstance(intents, Mapping): + raise TypeError("Method intents must be a Mapping") + return set(intents) + legacy_intents = getattr(method, "_intents", None) + if isinstance(legacy_intents, Mapping): + return set(legacy_intents) + return None diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..cc41ddb --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,804 @@ +"""Tests for the shared payment runtime.""" + +from __future__ import annotations + +import asyncio +import threading +from collections.abc import AsyncIterator +from concurrent.futures import ThreadPoolExecutor +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from types import MappingProxyType +from typing import Any + +import pytest + +from mpp import Challenge, Credential +from mpp.runtime import Method, PaymentRuntime + + +def challenge(identifier: str = "test-id", *, intent: str = "charge") -> Challenge: + return Challenge( + id=identifier, + method="tempo", + intent=intent, + request={}, + ) + + +class MockMethod: + name = "tempo" + intents = MappingProxyType({"charge": object()}) + + def __init__(self) -> None: + self.loops: list[asyncio.AbstractEventLoop] = [] + + async def create_credential(self, value: Challenge) -> Credential: + self.loops.append(asyncio.get_running_loop()) + return Credential(challenge=value.to_echo(), payload={"ok": True}) + + +class TestRuntimeLifecycle: + @pytest.mark.asyncio + async def test_factory_lifecycle_uses_one_owned_loop_for_sync_and_async(self) -> None: + loops: list[asyncio.AbstractEventLoop] = [] + events: list[str] = [] + + class LoopBoundMethod(MockMethod): + def __init__(self) -> None: + super().__init__() + self.loop = asyncio.get_running_loop() + self.ready = self.loop.create_future() + self.loop.call_soon(self.ready.set_result, None) + + async def create_credential(self, value: Challenge) -> Credential: + loops.append(asyncio.get_running_loop()) + await self.ready + return await super().create_credential(value) + + @asynccontextmanager + async def managed() -> AsyncIterator[Method]: + events.append("enter") + method = LoopBoundMethod() + loops.append(method.loop) + try: + yield method + finally: + loops.append(asyncio.get_running_loop()) + events.append("exit") + + async def factory() -> AbstractAsyncContextManager[Method]: + return managed() + + caller_loop = asyncio.get_running_loop() + async with PaymentRuntime(method_factories=[factory]) as runtime: + method = runtime.methods[0] + await runtime.create_credential(challenge("async"), method) + await asyncio.to_thread( + runtime.create_credential_sync, + challenge("sync"), + method, + ) + thread = runtime._bridge._thread + + assert events == ["enter", "exit"] + assert len(set(loops)) == 1 + assert loops[0] is not caller_loop + assert thread is not None and not thread.is_alive() + with pytest.raises(RuntimeError, match="closed"): + runtime.start() + + def test_borrowed_methods_are_not_entered_or_closed(self) -> None: + events: list[str] = [] + + class BorrowedMethod(MockMethod): + async def __aenter__(self) -> BorrowedMethod: + events.append("enter") + return self + + async def __aexit__(self, *_args: Any) -> None: + events.append("exit") + + method = BorrowedMethod() + with PaymentRuntime([method]) as runtime: + runtime.create_credential_sync(challenge(), method) + + assert events == [] + + @pytest.mark.asyncio + async def test_concurrent_start_is_single_flight_and_cancellation_safe(self) -> None: + calls = 0 + + async def factory() -> MockMethod: + nonlocal calls + calls += 1 + await asyncio.sleep(0.05) + return MockMethod() + + runtime = PaymentRuntime(method_factories=[factory]) + first = asyncio.create_task(runtime.astart()) + second = asyncio.create_task(runtime.astart()) + await asyncio.sleep(0.01) + first.cancel() + + with pytest.raises(asyncio.CancelledError): + await first + assert await second is runtime + assert calls == 1 + await runtime.aclose() + + @pytest.mark.asyncio + async def test_cancelled_async_context_entry_closes_started_runtime(self) -> None: + events: list[str] = [] + entered = threading.Event() + + @asynccontextmanager + async def factory() -> AsyncIterator[Method]: + events.append("enter") + entered.set() + await asyncio.sleep(0.05) + try: + yield MockMethod() + finally: + events.append("exit") + + runtime = PaymentRuntime(method_factories=[factory]) + + async def use_runtime() -> None: + async with runtime: + raise AssertionError("cancelled entry reached the context body") + + task = asyncio.create_task(use_runtime()) + assert await asyncio.to_thread(entered.wait, 1) + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + assert events == ["enter", "exit"] + assert runtime._state == "closed" + assert runtime._bridge._thread is not None + assert not runtime._bridge._thread.is_alive() + + @pytest.mark.asyncio + async def test_cancelled_async_context_exit_finishes_close(self) -> None: + events: list[str] = [] + exit_started = threading.Event() + + @asynccontextmanager + async def factory() -> AsyncIterator[Method]: + events.append("enter") + try: + yield MockMethod() + finally: + exit_started.set() + await asyncio.sleep(0.05) + events.append("exit") + + runtime = PaymentRuntime(method_factories=[factory]) + + async def use_runtime() -> None: + async with runtime: + pass + + task = asyncio.create_task(use_runtime()) + assert await asyncio.to_thread(exit_started.wait, 1) + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + assert events == ["enter", "exit"] + assert runtime._state == "closed" + assert runtime._bridge._thread is not None + assert not runtime._bridge._thread.is_alive() + + @pytest.mark.asyncio + async def test_cancelled_aclose_finishes_close(self) -> None: + exit_started = threading.Event() + + @asynccontextmanager + async def factory() -> AsyncIterator[Method]: + try: + yield MockMethod() + finally: + exit_started.set() + await asyncio.sleep(0.05) + + runtime = await PaymentRuntime(method_factories=[factory]).astart() + close = asyncio.create_task(runtime.aclose()) + assert await asyncio.to_thread(exit_started.wait, 1) + close.cancel() + + with pytest.raises(asyncio.CancelledError): + await close + assert runtime._state == "closed" + assert runtime._bridge._thread is not None + assert not runtime._bridge._thread.is_alive() + + def test_sync_context_manager_and_close_before_start(self) -> None: + calls = 0 + + def factory() -> MockMethod: + nonlocal calls + calls += 1 + return MockMethod() + + unused = PaymentRuntime(method_factories=[factory]) + unused.close() + assert calls == 0 + + with PaymentRuntime(method_factories=[factory]) as runtime: + assert runtime.methods + assert calls == 1 + assert runtime._bridge._thread is not None + assert not runtime._bridge._thread.is_alive() + + def test_factory_failure_unwinds_entered_methods_and_stops_loop(self) -> None: + events: list[str] = [] + + @asynccontextmanager + async def managed() -> AsyncIterator[Method]: + events.append("enter") + try: + yield MockMethod() + finally: + events.append("exit") + + async def fail() -> MockMethod: + raise ValueError("factory failed") + + runtime = PaymentRuntime(method_factories=[managed, fail]) + with pytest.raises(ValueError, match="factory failed"): + runtime.start() + + assert events == ["enter", "exit"] + assert runtime._bridge._thread is not None + assert not runtime._bridge._thread.is_alive() + with pytest.raises(RuntimeError, match="failed to start"): + runtime.start() + + def test_factory_reentry_through_worker_fails_without_deadlock(self) -> None: + runtime_holder: dict[str, PaymentRuntime] = {} + + async def factory() -> MockMethod: + runtime = runtime_holder["runtime"] + with pytest.raises(RuntimeError, match="while method factories start"): + await asyncio.wait_for(asyncio.to_thread(runtime.start), 1) + with pytest.raises(RuntimeError, match="while method factories start"): + await asyncio.wait_for(asyncio.to_thread(runtime.close), 1) + return MockMethod() + + runtime = PaymentRuntime(method_factories=[factory]) + runtime_holder["runtime"] = runtime + with runtime: + pass + + def test_invalid_factory_result_unwinds_and_stops_loop(self) -> None: + runtime = PaymentRuntime(method_factories=[lambda: object()]) # type: ignore[list-item] + + with pytest.raises(TypeError, match="payment Method"): + runtime.start() + assert runtime._state == "closed" + assert runtime._bridge._thread is not None + assert not runtime._bridge._thread.is_alive() + + def test_methods_and_factories_are_mutually_exclusive(self) -> None: + with pytest.raises(ValueError, match="either methods or method_factories"): + PaymentRuntime([], method_factories=[MockMethod]) + + def test_invalid_borrowed_method_is_rejected(self) -> None: + with pytest.raises(TypeError, match="payment Methods"): + PaymentRuntime([object()]) # type: ignore[list-item] + + @pytest.mark.parametrize("async_close", [False, True]) + @pytest.mark.asyncio + async def test_close_from_owned_loop_event_is_deferred(self, async_close: bool) -> None: + events: list[str] = [] + + @asynccontextmanager + async def factory() -> AsyncIterator[Method]: + events.append("enter") + try: + yield MockMethod() + finally: + events.append("exit") + + runtime = await PaymentRuntime(method_factories=[factory]).astart() + + if async_close: + + async def async_close_from_event(_payload: Any) -> None: + events.append("close") + await runtime.aclose() + events.append("closed-callback") + + close_from_event = async_close_from_event + else: + + def sync_close_from_event(_payload: Any) -> None: + events.append("close") + runtime.close() + events.append("closed-callback") + + close_from_event = sync_close_from_event + + runtime.events.on("challenge.received", close_from_event) + credential = await asyncio.wait_for( + runtime.create_credential(challenge("close"), runtime.methods[0]), + 1, + ) + + assert credential.payload == {"ok": True} + assert events == ["enter", "close", "closed-callback", "exit"] + assert runtime._state == "closed" + assert runtime._bridge._thread is not None + assert not runtime._bridge._thread.is_alive() + + def test_external_close_cancels_method_before_lifecycle_exit(self) -> None: + events: list[str] = [] + started = threading.Event() + + class BlockingMethod(MockMethod): + async def create_credential(self, value: Challenge) -> Credential: + events.append("credential-start") + started.set() + try: + await asyncio.Event().wait() + finally: + events.append("credential-finally") + raise AssertionError(f"unexpected release for {value.id}") + + @asynccontextmanager + async def factory() -> AsyncIterator[Method]: + events.append("enter") + try: + yield BlockingMethod() + finally: + events.append("exit") + + runtime = PaymentRuntime(method_factories=[factory]).start() + errors: list[BaseException] = [] + + def create() -> None: + try: + runtime.create_credential_sync(challenge("cancel"), runtime.methods[0]) + except BaseException as error: + errors.append(error) + + worker = threading.Thread(target=create) + worker.start() + assert started.wait(1) + runtime.close() + worker.join(timeout=1) + + assert not worker.is_alive() + assert len(errors) == 1 + assert type(errors[0]).__name__ == "CancelledError" + assert events == ["enter", "credential-start", "credential-finally", "exit"] + + @pytest.mark.parametrize("threaded_close", [False, True]) + def test_close_during_method_exit_does_not_deadlock(self, threaded_close: bool) -> None: + events: list[str] = [] + runtime_holder: dict[str, PaymentRuntime] = {} + + class ManagedMethod(MockMethod): + async def __aenter__(self) -> ManagedMethod: + events.append("enter") + return self + + async def __aexit__(self, *_args: Any) -> None: + events.append("exit-start") + runtime = runtime_holder["runtime"] + if threaded_close: + await asyncio.to_thread(runtime.close) + else: + runtime.close() + events.append("exit-end") + + runtime = PaymentRuntime(method_factories=[ManagedMethod]) + runtime_holder["runtime"] = runtime + runtime.start() + errors: list[BaseException] = [] + + def close() -> None: + try: + runtime.close() + except BaseException as error: + errors.append(error) + + worker = threading.Thread(target=close, daemon=True) + worker.start() + worker.join(timeout=1) + + assert not worker.is_alive() + assert not errors + assert events == ["enter", "exit-start", "exit-end"] + assert runtime._state == "closed" + + def test_concurrent_close_waits_for_method_exit(self) -> None: + exit_started = threading.Event() + release_exit = threading.Event() + second_done = threading.Event() + errors: list[BaseException] = [] + exits = 0 + + @asynccontextmanager + async def factory() -> AsyncIterator[Method]: + nonlocal exits + try: + yield MockMethod() + finally: + exit_started.set() + await asyncio.to_thread(release_exit.wait) + exits += 1 + + runtime = PaymentRuntime(method_factories=[factory]).start() + + def close(*, done: threading.Event | None = None) -> None: + try: + runtime.close() + except BaseException as error: + errors.append(error) + finally: + if done is not None: + done.set() + + first = threading.Thread(target=close) + second = threading.Thread(target=close, kwargs={"done": second_done}) + first.start() + assert exit_started.wait(1) + second.start() + assert not second_done.wait(0.05) + release_exit.set() + first.join(timeout=1) + second.join(timeout=1) + + assert not errors + assert not first.is_alive() and not second.is_alive() + assert exits == 1 + + @pytest.mark.asyncio + async def test_async_finalizer_yields_to_in_progress_close( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + runtime = PaymentRuntime([]) + runtime._state = "closing" + runtime._deferred_close = True + runtime._finalizing = True + called = False + + async def cancel_pending() -> None: + nonlocal called + called = True + + monkeypatch.setattr(runtime._bridge, "_cancel_pending", cancel_pending) + await runtime._finish_close_async() + + assert not called + + def test_method_exit_failure_still_stops_runtime(self) -> None: + @asynccontextmanager + async def factory() -> AsyncIterator[Method]: + try: + yield MockMethod() + finally: + raise ValueError("method exit failed") + + runtime = PaymentRuntime(method_factories=[factory]).start() + with pytest.raises(ValueError, match="method exit failed"): + runtime.close() + + assert runtime._state == "closed" + assert runtime._method_stack is None + assert runtime._bridge._thread is not None + assert not runtime._bridge._thread.is_alive() + + @pytest.mark.asyncio + async def test_deferred_close_waits_for_inherited_runtime_lease_child(self) -> None: + runtime = PaymentRuntime([]) + entered = asyncio.Event() + release = asyncio.Event() + + async def child() -> None: + with runtime._runtime_operation(): + entered.set() + await release.wait() + + with runtime._runtime_operation(): + task = asyncio.create_task(child()) + await asyncio.wait_for(entered.wait(), 1) + runtime.close() + + assert runtime._state == "closing" + assert runtime._active_operations == 1 + release.set() + await asyncio.wait_for(task, 1) + assert runtime._state == "closed" + + def test_detached_owned_loop_close_finishes_cleanly(self) -> None: + runtime_holder: dict[str, PaymentRuntime] = {} + release: asyncio.Event | None = None + done = threading.Event() + errors: list[BaseException] = [] + lifecycle: list[str] = [] + + class SpawningMethod(MockMethod): + async def create_credential(self, value: Challenge) -> Credential: + nonlocal release + release = asyncio.Event() + + async def detached() -> None: + assert release is not None + await release.wait() + + async def close_inside_operation() -> None: + runtime_holder["runtime"].close() + + try: + await runtime_holder["runtime"].run_async(close_inside_operation()) + except BaseException as error: + errors.append(error) + finally: + done.set() + + asyncio.create_task(detached()) + return await super().create_credential(value) + + @asynccontextmanager + async def factory() -> AsyncIterator[Method]: + lifecycle.append("enter") + try: + yield SpawningMethod() + finally: + lifecycle.append("exit") + + runtime = PaymentRuntime(method_factories=[factory]).start() + runtime_holder["runtime"] = runtime + runtime.create_credential_sync(challenge(), runtime.methods[0]) + + async def wake_detached() -> None: + assert release is not None + release.set() + + runtime.run_sync(wake_detached()) + assert done.wait(1) + assert runtime._bridge._thread is not None + runtime.close() + assert errors == [] + assert lifecycle == ["enter", "exit"] + assert runtime._method_stack is None + assert not runtime._bridge._thread.is_alive() + + +class TestRuntimeBridge: + def test_concurrent_sync_calls_share_one_method_loop(self) -> None: + method = MockMethod() + runtime = PaymentRuntime([method]) + try: + with ThreadPoolExecutor(max_workers=4) as pool: + list( + pool.map( + lambda _: runtime.create_credential_sync(challenge(), method), + range(4), + ) + ) + finally: + runtime.close() + + assert len(method.loops) == 4 + assert len({id(loop) for loop in method.loops}) == 1 + + @pytest.mark.asyncio + async def test_sync_async_and_events_share_runtime_loop(self) -> None: + caller_loop = asyncio.get_running_loop() + method = MockMethod() + runtime = PaymentRuntime([method]) + event_loops: list[asyncio.AbstractEventLoop] = [] + runtime.events.on("*", lambda _: event_loops.append(asyncio.get_running_loop())) + try: + await runtime.create_credential(challenge("async"), method) + await asyncio.to_thread( + runtime.create_credential_sync, + challenge("sync"), + method, + ) + finally: + runtime.close() + + assert len(method.loops) == 2 + assert method.loops[0] is method.loops[1] + assert method.loops[0] is not caller_loop + assert len(event_loops) == 4 + assert set(event_loops) == {method.loops[0]} + + def test_bridge_rejects_same_thread_blocking(self) -> None: + runtime = PaymentRuntime([]) + + async def block_bridge() -> None: + with pytest.raises(RuntimeError, match="Cannot block"): + runtime._bridge.run(asyncio.sleep(0)) + + try: + runtime._bridge.run(block_bridge()) + finally: + runtime.close() + + def test_thread_start_failure_closes_runtime_without_waiting( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + runtime = PaymentRuntime([]) + + def fail_start(_thread: threading.Thread) -> None: + raise OSError("thread unavailable") + + monkeypatch.setattr(threading.Thread, "start", fail_start) + with pytest.raises(RuntimeError, match="background loop failed") as exc_info: + runtime.start() + + assert isinstance(exc_info.value.__cause__, OSError) + assert runtime._state == "closed" + assert runtime._bridge._ready.is_set() + assert runtime._bridge._stopped.is_set() + + def test_shutdown_runs_all_cleanup_and_reraises_first_error( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + runtime = PaymentRuntime([]).start() + original_close = runtime._bridge.close + closed = False + + def fail_cancel() -> None: + raise ValueError("first cleanup failure") + + def close_then_fail() -> None: + nonlocal closed + original_close() + closed = True + raise RuntimeError("later cleanup failure") + + monkeypatch.setattr(runtime._bridge, "cancel_pending", fail_cancel) + monkeypatch.setattr(runtime._bridge, "close", close_then_fail) + + with pytest.raises(ValueError, match="first cleanup failure"): + runtime.close() + + assert closed + assert runtime._state == "closed" + assert runtime._bridge._stopped.is_set() + assert runtime._bridge._thread is not None + assert not runtime._bridge._thread.is_alive() + + def test_loop_cleanup_failure_is_published_after_thread_stops( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + runtime = PaymentRuntime([]).start() + runtime.run_sync(asyncio.to_thread(lambda: None)) + loop = runtime._bridge._loop + assert loop is not None + shutdown_default_executor = loop.shutdown_default_executor + executor_shutdowns = 0 + + async def fail_shutdown(_loop: Any) -> None: + raise OSError("async generator cleanup failed") + + async def record_executor_shutdown(_loop: Any) -> None: + nonlocal executor_shutdowns + executor_shutdowns += 1 + await shutdown_default_executor() + + monkeypatch.setattr(type(loop), "shutdown_asyncgens", fail_shutdown) + monkeypatch.setattr(type(loop), "shutdown_default_executor", record_executor_shutdown) + with pytest.raises(OSError, match="async generator cleanup failed"): + runtime.close() + + assert executor_shutdowns == 1 + assert runtime._state == "closed" + assert runtime._bridge._stopped.is_set() + assert runtime._bridge._thread is not None + assert not runtime._bridge._thread.is_alive() + + def test_close_is_idempotent(self) -> None: + method = MockMethod() + runtime = PaymentRuntime([method]) + runtime.create_credential_sync(challenge(), method) + + runtime.close() + runtime.close() + + with pytest.raises(RuntimeError, match="closed"): + runtime.create_credential_sync(challenge(), method) + + +class TestRuntimeMethods: + @pytest.mark.asyncio + async def test_challenge_handler_can_supply_credential(self) -> None: + method = MockMethod() + supplied = Credential(challenge=challenge("supplied").to_echo(), payload={"event": True}) + created: list[dict[str, Any]] = [] + runtime = PaymentRuntime([method]) + runtime.events.on("challenge.received", lambda _payload: supplied) + runtime.events.on("credential.created", created.append) + + try: + result = await runtime.create_credential( + challenge("requested"), + method, + event_payload={"challenge": "cannot override", "source": "adapter"}, + ) + finally: + await runtime.aclose() + + assert result is supplied + assert method.loops == [] + assert created[0]["credential"] is supplied + assert created[0]["challenge"].id == "requested" + assert created[0]["source"] == "adapter" + + def test_matching_uses_public_intents_mapping(self) -> None: + class SubscriptionMethod(MockMethod): + intents = MappingProxyType({"subscription": object()}) + + method = SubscriptionMethod() + runtime = PaymentRuntime([method]) + subscription = challenge("subscription", intent="subscription") + + assert runtime.match_challenge([subscription]) == (subscription, method) + with pytest.raises(ValueError, match="No compatible payment method"): + runtime.match_challenge([challenge("charge")]) + runtime.close() + + def test_legacy_method_defaults_to_charge(self) -> None: + class LegacyMethod: + name = "tempo" + + async def create_credential(self, value: Challenge) -> Credential: + return Credential(challenge=value.to_echo(), payload={}) + + method = LegacyMethod() + runtime = PaymentRuntime([method]) + + assert runtime.match_challenge([challenge()]) == (challenge(), method) + with pytest.raises(ValueError, match="No compatible payment method"): + runtime.match_challenge([challenge(intent="subscription")]) + assert ( + runtime.match_challenge( + [challenge(intent="subscription")], + allow_name_only=True, + )[1] + is method + ) + runtime.close() + + def test_invalid_intents_capability_is_rejected(self) -> None: + class InvalidMethod(MockMethod): + intents: Any = ["charge"] + + with pytest.raises(TypeError, match="payment Methods"): + PaymentRuntime([InvalidMethod()]) + + def test_matching_order_is_explicit(self) -> None: + class StripeMethod(MockMethod): + name = "stripe" + + stripe = StripeMethod() + tempo = MockMethod() + stripe_challenge = Challenge( + id="stripe", + method="stripe", + intent="charge", + request={}, + ) + tempo_challenge = challenge("tempo") + runtime = PaymentRuntime([stripe, tempo]) + + assert runtime.match_challenge([tempo_challenge, stripe_challenge])[1] is stripe + assert ( + runtime.match_challenge( + [tempo_challenge, stripe_challenge], + prefer_method_order=False, + )[1] + is tempo + ) + runtime.close() diff --git a/tests/test_tempo.py b/tests/test_tempo.py index 6fd2b8a..08579f1 100644 --- a/tests/test_tempo.py +++ b/tests/test_tempo.py @@ -1,5 +1,6 @@ """Tests for Tempo payment method.""" +import asyncio import json import os import re @@ -49,6 +50,7 @@ Split, TransactionCredentialPayload, ) +from mpp.runtime import PaymentRuntime from mpp.server.intent import VerificationError from tests import make_credential @@ -233,6 +235,52 @@ async def fake_rpc_call( assert rpc_methods.count("eth_getTransactionCount") == 2 assert rpc_methods.count("eth_gasPrice") == 2 + @pytest.mark.asyncio + async def test_runtime_shares_tempo_method_across_sync_and_async_calls(self) -> None: + caller_loop = asyncio.get_running_loop() + account = TempoAccount.from_key(TEST_PRIVATE_KEY) + method = tempo( + account=account, + rpc_url="https://rpc.test", + intents={"charge": ChargeIntent()}, + ) + runtime = PaymentRuntime([method]) + challenge = Challenge( + id="mixed-runtime", + method="tempo", + intent="charge", + realm="test.example.com", + request={ + "amount": "1000000", + "currency": "0x20c0000000000000000000000000000000000000", + "recipient": "0x742d35Cc6634c0532925a3b844bC9e7595F8fE00", + }, + ) + rpc_loops: list[asyncio.AbstractEventLoop] = [] + + async def fake_rpc_call(*_args: object, **_kwargs: object) -> str: + rpc_loops.append(asyncio.get_running_loop()) + await asyncio.sleep(0.05) + return "0x1079" + + build = AsyncMock(return_value=("0xdeadbeef", 4217)) + try: + with ( + patch("mpp.methods.tempo.client._rpc_call", side_effect=fake_rpc_call), + patch.object(method, "_build_tempo_transfer", build), + ): + credentials = await asyncio.gather( + runtime.create_credential(challenge, method), + asyncio.to_thread(runtime.create_credential_sync, challenge, method), + ) + finally: + runtime.close() + + assert len(credentials) == 2 + assert len(rpc_loops) == 1 + assert rpc_loops[0] is not caller_loop + assert build.await_count == 2 + @pytest.mark.asyncio async def test_create_credential_reuses_cached_rpc_chain_id_for_rejected_switch(self) -> None: """Should reject switched chains without refetching an already-cached RPC chain ID.""" From 54b8bba6b32478511e4e62038af4933b270ca363 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 28 Jul 2026 02:09:14 +0000 Subject: [PATCH 03/11] chore: add changelog --- .changelog/quick-crows-swim.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changelog/quick-crows-swim.md diff --git a/.changelog/quick-crows-swim.md b/.changelog/quick-crows-swim.md new file mode 100644 index 0000000..4f5429e --- /dev/null +++ b/.changelog/quick-crows-swim.md @@ -0,0 +1,5 @@ +--- +pympp: minor +--- + +Added an owned-loop `PaymentRuntime` with method factories, sync and async lifecycle contexts, shared events, and sync/async credential creation for custom integrations. From 4f0e1fcf6b77875061f577389f85e31fa33ae42e Mon Sep 17 00:00:00 2001 From: Parv Ahuja <17094219+parvahuja@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:10:07 -0700 Subject: [PATCH 04/11] chore: remove duplicate changelog --- .changelog/payment-runtime.md | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .changelog/payment-runtime.md diff --git a/.changelog/payment-runtime.md b/.changelog/payment-runtime.md deleted file mode 100644 index 684f319..0000000 --- a/.changelog/payment-runtime.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -pympp: minor ---- - -Added an owned-loop `PaymentRuntime` with method factories, sync and async -lifecycle contexts, shared events, and sync/async credential creation for -custom integrations. From ba2d72babfb48140263cf7028bee30bbe9951db1 Mon Sep 17 00:00:00 2001 From: Parv Ahuja <17094219+parvahuja@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:21:33 -0700 Subject: [PATCH 05/11] refactor: simplify payment runtime --- README.md | 1 + pyproject.toml | 1 + src/mpp/runtime.py | 528 +++++++++++++----------------------------- tests/test_runtime.py | 330 ++++---------------------- 4 files changed, 214 insertions(+), 646 deletions(-) diff --git a/README.md b/README.md index 5d6421a..1e8effe 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ async with PaymentRuntime(method_factories=[method_factory]) as runtime: Factories run once on the owned loop. Async context-manager results are closed on that same loop; direct `methods=` are borrowed and must be loop-independent. +Factories and methods must finish any work they spawn before returning. ## Examples diff --git a/pyproject.toml b/pyproject.toml index 7596fbc..569a7eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Python SDK for the Machine Payments Protocol (MPP)" readme = "README.md" requires-python = ">=3.11" dependencies = [ + "anyio>=4.12,<5", "httpx>=0.27", ] authors = [ diff --git a/src/mpp/runtime.py b/src/mpp/runtime.py index 996f389..55fa456 100644 --- a/src/mpp/runtime.py +++ b/src/mpp/runtime.py @@ -6,10 +6,19 @@ import inspect import threading from collections.abc import Awaitable, Callable, Coroutine, Mapping, Sequence -from contextlib import AbstractAsyncContextManager, AsyncExitStack, contextmanager -from contextvars import ContextVar, copy_context -from dataclasses import dataclass -from typing import Any, Protocol, Self, TypeVar, runtime_checkable +from concurrent.futures import Future +from contextlib import ( + AbstractAsyncContextManager, + AbstractContextManager, + AsyncExitStack, + contextmanager, +) +from contextvars import ContextVar +from typing import Any, Protocol, Self, TypeVar, cast + +from anyio import TASK_STATUS_IGNORED +from anyio.abc import TaskStatus +from anyio.from_thread import BlockingPortal, start_blocking_portal from mpp import Challenge, Credential from mpp.events import ( @@ -22,48 +31,24 @@ _T = TypeVar("_T") -@dataclass(slots=True) -class _Lifecycle: - active: bool = True - - -@dataclass(slots=True) -class _RuntimeLease: - owners: set[object] - - @property - def active(self) -> bool: - return bool(self.owners) - - -_RUNTIME_LEASES: ContextVar[dict[int, _RuntimeLease] | None] = ContextVar( - "mpp_runtime_leases", +_RUNTIME_CONTEXT: ContextVar[tuple[object, str] | None] = ContextVar( + "mpp_runtime_context", default=None, ) -_LIFECYCLE: ContextVar[_Lifecycle | None] = ContextVar("mpp_runtime_lifecycle", default=None) @contextmanager -def _lifecycle(): - lifecycle = _Lifecycle() - token = _LIFECYCLE.set(lifecycle) +def _runtime_scope(key: object, kind: str): + token = _RUNTIME_CONTEXT.set((key, kind)) try: yield finally: - lifecycle.active = False - _LIFECYCLE.reset(token) + _RUNTIME_CONTEXT.reset(token) -def _lifecycle_active() -> bool: - lifecycle = _LIFECYCLE.get() - return lifecycle is not None and lifecycle.active - - -def _operation_owner() -> object: - try: - return asyncio.current_task() or threading.current_thread() - except RuntimeError: - return threading.current_thread() +def _scope_active(key: object, kind: str) -> bool: + current = _RUNTIME_CONTEXT.get() + return current is not None and current[0] is key and current[1] == kind async def _wait_for_task(task: asyncio.Task[_T]) -> _T: @@ -76,7 +61,6 @@ async def _wait_for_task(task: asyncio.Task[_T]) -> _T: return task.result() -@runtime_checkable class Method(Protocol): """Client-side payment method.""" @@ -95,114 +79,62 @@ async def create_credential(self, challenge: Challenge, /) -> Credential: class _AsyncBridge: - """Own one lazy event loop for payment-method work.""" + """Run payment work on one AnyIO-owned asyncio loop.""" def __init__(self) -> None: self._closed = False self._lock = threading.Lock() - self._loop: asyncio.AbstractEventLoop | None = None - self._ready = threading.Event() - self._stopped = threading.Event() - self._start_error: BaseException | None = None - self._stop_error: BaseException | None = None + self._context: AbstractContextManager[BlockingPortal] | None = None + self._portal: BlockingPortal | None = None self._thread: threading.Thread | None = None + self._tasks: set[asyncio.Task[Any]] = set() - def _record_stop_error(self, error: BaseException) -> None: - if self._stop_error is None: - self._stop_error = error - - @staticmethod - def _drain_loop(loop: asyncio.AbstractEventLoop) -> BaseException | None: - error: BaseException | None = None - try: - pending = asyncio.all_tasks(loop) - except BaseException as cause: - pending = set() - error = cause - for task in pending: - try: - task.cancel() - except BaseException as cause: - if error is None: - error = cause - - async def drain_pending() -> None: - await asyncio.gather(*pending, return_exceptions=True) - - cleanup = ( - *((drain_pending,) if pending else ()), - loop.shutdown_asyncgens, - loop.shutdown_default_executor, - ) - for action in cleanup: - try: - loop.run_until_complete(action()) - except BaseException as cause: - if error is None: - error = cause - return error - - def _submit(self, coroutine: Coroutine[Any, Any, _T]) -> Any: - with self._lock: - if self._closed: - raise RuntimeError("PaymentRuntime is closed") - if self._thread is None: - thread = threading.Thread( - target=self._run, - name="pympp-payment-runtime", - daemon=True, - ) - self._thread = thread - try: - thread.start() - except BaseException as error: - self._start_error = error - self._ready.set() - self._stopped.set() - raise RuntimeError("PaymentRuntime background loop failed to start") from error - self._ready.wait() + def start(self) -> None: with self._lock: if self._closed: raise RuntimeError("PaymentRuntime is closed") - if self._start_error is not None: - raise RuntimeError("PaymentRuntime background loop failed to start") from ( - self._start_error - ) - if self._loop is None: - raise RuntimeError("PaymentRuntime background loop failed to start") - if threading.current_thread() is self._thread: - raise RuntimeError("Cannot block the PaymentRuntime background loop") - return copy_context().run( - asyncio.run_coroutine_threadsafe, - coroutine, - self._loop, + if self._portal is not None: + return + + context = start_blocking_portal( + backend="asyncio", + name="pympp-payment-runtime", ) + portal: BlockingPortal | None = None + try: + portal = context.__enter__() + self._portal = portal + self._context = context + self._thread = portal.call(threading.current_thread) + except BaseException as error: + self._closed = True + if portal is not None: + context.__exit__(None, None, None) + raise RuntimeError("PaymentRuntime background loop failed to start") from error - def _run(self) -> None: - initialized = False - stop_error: BaseException | None = None + async def _run_coroutine( + self, + coroutine: Coroutine[Any, Any, _T], + *, + task_status: TaskStatus[None] = TASK_STATUS_IGNORED, + ) -> _T: + task = asyncio.current_task() + assert task is not None + self._tasks.add(task) + task_status.started() try: - with asyncio.Runner() as runner: - self._loop = runner.get_loop() - initialized = True - self._ready.set() - try: - self._loop.run_forever() - except BaseException as error: - stop_error = error - cleanup_error = self._drain_loop(self._loop) - if stop_error is None: - stop_error = cleanup_error - except BaseException as error: - if not initialized: - self._start_error = error - elif stop_error is None: - stop_error = error + return await coroutine finally: - if stop_error is not None: - self._record_stop_error(stop_error) - self._ready.set() - self._stopped.set() + self._tasks.discard(task) + + def _submit(self, coroutine: Coroutine[Any, Any, _T]) -> Future[_T]: + with self._lock: + if self._closed or self._portal is None: + raise RuntimeError("PaymentRuntime is closed") + if self.is_current_thread(): + raise RuntimeError("Cannot block the PaymentRuntime background loop") + future, _ = self._portal.start_task(self._run_coroutine, coroutine) + return cast(Future[_T], future) def run(self, coroutine: Coroutine[Any, Any, _T]) -> _T: """Run async work from synchronous code.""" @@ -219,7 +151,7 @@ def run(self, coroutine: Coroutine[Any, Any, _T]) -> _T: async def run_async(self, coroutine: Coroutine[Any, Any, _T]) -> _T: """Run async work without blocking the caller's event loop.""" - if asyncio.get_running_loop() is self._loop: + if self.is_current_thread(): return await coroutine try: future = self._submit(coroutine) @@ -232,76 +164,49 @@ async def run_async(self, coroutine: Coroutine[Any, Any, _T]) -> _T: future.cancel() raise - async def _cancel_pending(self) -> None: + async def _shutdown( + self, + finalizer: Callable[[], Awaitable[None]] | None, + ) -> None: current = asyncio.current_task() - pending = [task for task in asyncio.all_tasks() if task is not current] + pending = [task for task in self._tasks if task is not current] for task in pending: task.cancel() if pending: await asyncio.gather(*pending, return_exceptions=True) + if finalizer is not None: + await finalizer() def is_current_thread(self) -> bool: return threading.current_thread() is self._thread - def cancel_pending(self) -> None: - """Cancel and drain runtime-loop work without stopping the loop.""" - thread, loop = self._thread, self._loop - if thread is None or loop is None or not thread.is_alive(): - return + def close(self, finalizer: Callable[[], Awaitable[None]] | None = None) -> None: + """Stop the owned loop, if it was started.""" if self.is_current_thread(): - raise RuntimeError("Cannot synchronously cancel the PaymentRuntime loop") - if loop.is_running(): - asyncio.run_coroutine_threadsafe(self._cancel_pending(), loop).result() + raise RuntimeError("Cannot close the PaymentRuntime background loop from itself") - def close(self) -> None: - """Stop the runtime loop, if it was started.""" with self._lock: if self._closed: - already_closed = True - wait = threading.current_thread() is not self._thread - thread = self._thread - else: - already_closed = False - self._closed = True - wait = False - thread = self._thread - if already_closed: - if wait: - self._stopped.wait() - if self._stop_error is not None: - raise self._stop_error - return - if thread is None: - self._stopped.set() - return - if self._loop is None: - self._ready.wait() - loop = self._loop - if loop is None: - if thread.ident is not None: - thread.join() - return - if threading.current_thread() is thread: - loop.stop() + return + self._closed = True + context, self._context = self._context, None + portal, self._portal = self._portal, None + + if context is None or portal is None: return + error: BaseException | None = None - if thread.is_alive() and loop.is_running(): - try: - future = asyncio.run_coroutine_threadsafe(self._cancel_pending(), loop) - future.result() - except BaseException as cause: - error = cause try: - loop.call_soon_threadsafe(loop.stop) + portal.call(self._shutdown, finalizer) + except BaseException as cause: + error = cause + try: + context.__exit__(None, None, None) except BaseException as cause: if error is None: error = cause - if thread.ident is not None: - thread.join() if error is not None: raise error - if self._stop_error is not None: - raise self._stop_error class PaymentRuntime: @@ -310,6 +215,7 @@ class PaymentRuntime: Direct ``methods`` are borrowed and must be loop-independent. Use ``method_factories`` for loop-bound methods: factories are called on the owned loop, and async context-manager results are exited there on close. + Factories and methods must finish any work they spawn before returning. """ def __init__( @@ -328,18 +234,23 @@ def __init__( self._method_stack: AsyncExitStack | None = None self.events = events or EventDispatcher() self._bridge = _AsyncBridge() - self._lifecycle = threading.Condition() + self._state_changed = threading.Condition() self._active_operations = 0 + self._scope_key = object() self._state = "new" self._start_error: BaseException | None = None - self._finalizing = False self._deferred_close = False + def _in_method_lifecycle(self) -> bool: + return _scope_active(self._scope_key, "lifecycle") + + def _in_operation(self) -> bool: + return _scope_active(self._scope_key, "operation") + async def _initialize_methods(self) -> None: - stack = AsyncExitStack() - methods: list[Method] = [] - with _lifecycle(): - try: + with _runtime_scope(self._scope_key, "lifecycle"): + async with AsyncExitStack() as stack: + methods: list[Method] = [] for factory in self._method_factories: value: Any = factory() if inspect.isawaitable(value): @@ -349,29 +260,23 @@ async def _initialize_methods(self) -> None: if not _is_method(value): raise TypeError("Method factory must return a payment Method") methods.append(value) - except BaseException: - try: - await stack.aclose() - except BaseException: - pass - raise - if self._method_factories: - self.methods = tuple(methods) - self._method_stack = stack + if self._method_factories: + self.methods = tuple(methods) + self._method_stack = stack.pop_all() async def _teardown_methods(self) -> None: stack, self._method_stack = self._method_stack, None if stack is not None: - with _lifecycle(): + with _runtime_scope(self._scope_key, "lifecycle"): await stack.aclose() def start(self) -> Self: """Start the owned loop and initialize method factories once.""" - with self._lifecycle: + with self._state_changed: while self._state == "starting": - if _lifecycle_active(): + if self._in_method_lifecycle(): raise RuntimeError("Cannot use PaymentRuntime while method factories start") - self._lifecycle.wait() + self._state_changed.wait() if self._state == "open": return self if self._state in ("closing", "closed"): @@ -381,26 +286,27 @@ def start(self) -> Self: self._state = "starting" try: + self._bridge.start() self._bridge.run(self._initialize_methods()) except BaseException as error: try: self._bridge.close() except BaseException: pass - with self._lifecycle: + with self._state_changed: self._start_error = error self._state = "closed" - self._lifecycle.notify_all() + self._state_changed.notify_all() raise - with self._lifecycle: + with self._state_changed: self._state = "open" - self._lifecycle.notify_all() + self._state_changed.notify_all() return self async def astart(self) -> Self: """Asynchronously start the runtime without blocking the caller loop.""" - with self._lifecycle: + with self._state_changed: if self._state == "open": return self start = asyncio.create_task(asyncio.to_thread(self.start)) @@ -428,127 +334,38 @@ async def __aexit__(self, *_args: Any) -> None: await self.aclose() @contextmanager - def _runtime_operation(self, *, started: bool = False): - key = id(self) - leases = _RUNTIME_LEASES.get() or {} - lease = leases.get(key) - owner = _operation_owner() - with self._lifecycle: - inherited = lease is not None and lease.active - joined = inherited and lease is not None and owner not in lease.owners - if lease is not None and joined: - lease.owners.add(owner) - if inherited: - assert lease is not None - try: - yield - finally: - if joined: - self._release_runtime_lease(lease, owner) + def _runtime_operation(self): + if self._in_operation(): + yield return - if not started: - self.start() - with self._lifecycle: + with self._state_changed: if self._state != "open": raise RuntimeError("PaymentRuntime is closed") self._active_operations += 1 - lease = _RuntimeLease({owner}) - token = _RUNTIME_LEASES.set({**leases, key: lease}) + try: - yield + with _runtime_scope(self._scope_key, "operation"): + yield finally: - _RUNTIME_LEASES.reset(token) - self._release_runtime_lease(lease, owner) - - def _release_runtime_lease(self, lease: _RuntimeLease, owner: object) -> None: - with self._lifecycle: - lease.owners.discard(owner) - if lease.active: - return - self._active_operations -= 1 - should_close = self._deferred_close and self._active_operations == 0 - if self._active_operations == 0: - self._lifecycle.notify_all() - if should_close: - if self._bridge.is_current_thread(): - return - self._finish_close() - - def _has_active_runtime_lease(self) -> bool: - lease = (_RUNTIME_LEASES.get() or {}).get(id(self)) - return lease is not None and lease.active + with self._state_changed: + self._active_operations -= 1 + should_close = self._deferred_close and self._active_operations == 0 + if should_close: + self._finish_close() def _finish_close(self) -> None: - with self._lifecycle: + with self._state_changed: if self._state == "closed": return - if self._finalizing: - while self._state != "closed": - self._lifecycle.wait() - return - self._finalizing = True - - error: BaseException | None = None - - def cleanup(action: Callable[[], Any]) -> None: - nonlocal error - try: - action() - except BaseException as cause: - if error is None: - error = cause - try: - cleanup(self._bridge.cancel_pending) - if self._method_stack is not None: - cleanup(lambda: self._bridge.run(self._teardown_methods())) - cleanup(self._bridge.close) + finalizer = self._teardown_methods if self._method_stack is not None else None + self._bridge.close(finalizer) finally: - with self._lifecycle: + with self._state_changed: self._state = "closed" - self._finalizing = False self._deferred_close = False - self._lifecycle.notify_all() - if error is not None: - raise error - - async def _finish_close_async(self) -> None: - with self._lifecycle: - if ( - self._state != "closing" - or not self._deferred_close - or self._active_operations - or self._finalizing - ): - return - self._finalizing = True - - error: BaseException | None = None - try: - try: - await self._bridge._cancel_pending() - except BaseException as cause: - error = cause - if self._method_stack is not None: - try: - await self._teardown_methods() - except BaseException as cause: - if error is None: - error = cause - try: - self._bridge.close() - except BaseException as cause: - if error is None: - error = cause - finally: - with self._lifecycle: - self._state = "closed" - self._finalizing = False - self._deferred_close = False - self._lifecycle.notify_all() - if error is not None: - raise error + self._state_changed.notify_all() def match_challenge( self, @@ -587,7 +404,7 @@ async def create_credential( event_payload: dict[str, Any] | None = None, ) -> Credential: """Create a credential on the runtime-owned event loop.""" - return await self.run_async( + return await self._run_async( self._create_credential( challenge, method, @@ -603,7 +420,7 @@ def create_credential_sync( event_payload: dict[str, Any] | None = None, ) -> Credential: """Synchronously create a credential on the runtime-owned event loop.""" - return self.run_sync( + return self._run_sync( self._create_credential( challenge, method, @@ -611,10 +428,13 @@ def create_credential_sync( ) ) - def run_sync(self, coroutine: Coroutine[Any, Any, _T]) -> _T: - """Run a coroutine on the owned loop and block for its result.""" + def _run_sync(self, coroutine: Coroutine[Any, Any, _T]) -> _T: entered = False try: + if not self._in_operation(): + if self._bridge.is_current_thread(): + raise RuntimeError("Cannot use PaymentRuntime from unmanaged runtime-loop work") + self.start() with self._runtime_operation(): entered = True return self._bridge.run(coroutine) @@ -623,22 +443,20 @@ def run_sync(self, coroutine: Coroutine[Any, Any, _T]) -> _T: coroutine.close() raise - async def run_async(self, coroutine: Coroutine[Any, Any, _T]) -> _T: - """Run a coroutine on the owned loop without blocking.""" + async def _run_async(self, coroutine: Coroutine[Any, Any, _T]) -> _T: entered = False try: - if not self._has_active_runtime_lease(): + if not self._in_operation(): + if self._bridge.is_current_thread(): + raise RuntimeError("Cannot use PaymentRuntime from unmanaged runtime-loop work") await self.astart() - with self._runtime_operation(started=True): + with self._runtime_operation(): entered = True return await self._bridge.run_async(coroutine) except BaseException: if not entered: coroutine.close() raise - finally: - if entered and self._bridge.is_current_thread(): - await self._finish_close_async() async def _create_credential( self, @@ -653,7 +471,7 @@ async def _create_credential( "challenges": [challenge], "method": method, } - event_credential = await self._emit_event( + event_credential = await self.events.emit( CHALLENGE_RECEIVED, payload, first_result=True, @@ -663,7 +481,7 @@ async def _create_credential( if isinstance(event_credential, Credential) else await method.create_credential(challenge) ) - await self._emit_event( + await self.events.emit( CREDENTIAL_CREATED, {**payload, "credential": credential}, ) @@ -671,51 +489,35 @@ async def _create_credential( async def emit_event(self, name: str, payload: EventPayload) -> Any: """Emit an event on the runtime-owned loop.""" - return await self.run_async(self._emit_event(name, payload)) - - async def _emit_event( - self, - name: str, - payload: EventPayload, - *, - first_result: bool = False, - ) -> Any: - return await self.events.emit(name, payload, first_result=first_result) + return await self._run_async(self.events.emit(name, payload)) def emit_event_sync(self, name: str, payload: EventPayload) -> Any: """Synchronously emit an event on the runtime-owned loop.""" - return self.run_sync(self._emit_event(name, payload)) + return self._run_sync(self.events.emit(name, payload)) def close(self) -> None: """Close method resources and the owned event loop.""" - active_here = self._has_active_runtime_lease() + active_here = self._in_operation() on_runtime_thread = self._bridge.is_current_thread() - wait_for_bridge = False - with self._lifecycle: + with self._state_changed: while self._state == "starting": - if _lifecycle_active(): + if self._in_method_lifecycle(): raise RuntimeError("Cannot close PaymentRuntime while method factories start") - self._lifecycle.wait() + self._state_changed.wait() if self._state == "closed": - wait_for_bridge = not on_runtime_thread - elif self._state == "closing": - if active_here or on_runtime_thread or _lifecycle_active(): + return + if self._state == "closing": + if active_here or on_runtime_thread or self._in_method_lifecycle(): return while self._state != "closed": - self._lifecycle.wait() - wait_for_bridge = True - else: - if on_runtime_thread and not active_here: - raise RuntimeError( - "Cannot close PaymentRuntime from unmanaged runtime-loop work" - ) - self._state = "closing" - if active_here: - self._deferred_close = True - return - if wait_for_bridge: - self._bridge.close() - return + self._state_changed.wait() + return + if on_runtime_thread and not active_here: + raise RuntimeError("Cannot close PaymentRuntime from unmanaged runtime-loop work") + self._state = "closing" + if active_here: + self._deferred_close = True + return self._finish_close() async def aclose(self) -> None: @@ -732,12 +534,12 @@ async def aclose(self) -> None: def _is_method(value: Any) -> bool: - if not isinstance(getattr(value, "name", None), str): - return False - if not callable(getattr(value, "create_credential", None)): - return False intents = getattr(value, "intents", None) - return intents is None or isinstance(intents, Mapping) + return ( + isinstance(getattr(value, "name", None), str) + and callable(getattr(value, "create_credential", None)) + and (intents is None or isinstance(intents, Mapping)) + ) def _intent_names(method: Method) -> set[str] | None: diff --git a/tests/test_runtime.py b/tests/test_runtime.py index cc41ddb..c8c67b5 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -78,12 +78,10 @@ async def factory() -> AbstractAsyncContextManager[Method]: challenge("sync"), method, ) - thread = runtime._bridge._thread assert events == ["enter", "exit"] assert len(set(loops)) == 1 assert loops[0] is not caller_loop - assert thread is not None and not thread.is_alive() with pytest.raises(RuntimeError, match="closed"): runtime.start() @@ -154,9 +152,6 @@ async def use_runtime() -> None: with pytest.raises(asyncio.CancelledError): await task assert events == ["enter", "exit"] - assert runtime._state == "closed" - assert runtime._bridge._thread is not None - assert not runtime._bridge._thread.is_alive() @pytest.mark.asyncio async def test_cancelled_async_context_exit_finishes_close(self) -> None: @@ -186,32 +181,6 @@ async def use_runtime() -> None: with pytest.raises(asyncio.CancelledError): await task assert events == ["enter", "exit"] - assert runtime._state == "closed" - assert runtime._bridge._thread is not None - assert not runtime._bridge._thread.is_alive() - - @pytest.mark.asyncio - async def test_cancelled_aclose_finishes_close(self) -> None: - exit_started = threading.Event() - - @asynccontextmanager - async def factory() -> AsyncIterator[Method]: - try: - yield MockMethod() - finally: - exit_started.set() - await asyncio.sleep(0.05) - - runtime = await PaymentRuntime(method_factories=[factory]).astart() - close = asyncio.create_task(runtime.aclose()) - assert await asyncio.to_thread(exit_started.wait, 1) - close.cancel() - - with pytest.raises(asyncio.CancelledError): - await close - assert runtime._state == "closed" - assert runtime._bridge._thread is not None - assert not runtime._bridge._thread.is_alive() def test_sync_context_manager_and_close_before_start(self) -> None: calls = 0 @@ -228,8 +197,8 @@ def factory() -> MockMethod: with PaymentRuntime(method_factories=[factory]) as runtime: assert runtime.methods assert calls == 1 - assert runtime._bridge._thread is not None - assert not runtime._bridge._thread.is_alive() + with pytest.raises(RuntimeError, match="closed"): + runtime.start() def test_factory_failure_unwinds_entered_methods_and_stops_loop(self) -> None: events: list[str] = [] @@ -250,8 +219,6 @@ async def fail() -> MockMethod: runtime.start() assert events == ["enter", "exit"] - assert runtime._bridge._thread is not None - assert not runtime._bridge._thread.is_alive() with pytest.raises(RuntimeError, match="failed to start"): runtime.start() @@ -271,14 +238,48 @@ async def factory() -> MockMethod: with runtime: pass + def test_factory_lifecycle_is_scoped_per_runtime(self) -> None: + b_entered = threading.Event() + release_b = threading.Event() + a_called_b = threading.Event() + + def b_factory() -> MockMethod: + b_entered.set() + release_b.wait() + return MockMethod() + + b = PaymentRuntime(method_factories=[b_factory]) + a: PaymentRuntime | None = None + pool = ThreadPoolExecutor(max_workers=2) + try: + b_started = pool.submit(b.start) + assert b_entered.wait(1) + + def a_factory() -> MockMethod: + a_called_b.set() + b.start() + return MockMethod() + + a = PaymentRuntime(method_factories=[a_factory]) + a_started = pool.submit(a.start) + assert a_called_b.wait(1) + assert not a_started.done() + + release_b.set() + assert b_started.result(1) is b + assert a_started.result(1) is a + finally: + release_b.set() + pool.shutdown(wait=True, cancel_futures=True) + if a is not None: + a.close() + b.close() + def test_invalid_factory_result_unwinds_and_stops_loop(self) -> None: runtime = PaymentRuntime(method_factories=[lambda: object()]) # type: ignore[list-item] with pytest.raises(TypeError, match="payment Method"): runtime.start() - assert runtime._state == "closed" - assert runtime._bridge._thread is not None - assert not runtime._bridge._thread.is_alive() def test_methods_and_factories_are_mutually_exclusive(self) -> None: with pytest.raises(ValueError, match="either methods or method_factories"): @@ -303,22 +304,13 @@ async def factory() -> AsyncIterator[Method]: runtime = await PaymentRuntime(method_factories=[factory]).astart() - if async_close: - - async def async_close_from_event(_payload: Any) -> None: - events.append("close") + async def close_from_event(_payload: Any) -> None: + events.append("close") + if async_close: await runtime.aclose() - events.append("closed-callback") - - close_from_event = async_close_from_event - else: - - def sync_close_from_event(_payload: Any) -> None: - events.append("close") + else: runtime.close() - events.append("closed-callback") - - close_from_event = sync_close_from_event + events.append("closed-callback") runtime.events.on("challenge.received", close_from_event) credential = await asyncio.wait_for( @@ -328,9 +320,8 @@ def sync_close_from_event(_payload: Any) -> None: assert credential.payload == {"ok": True} assert events == ["enter", "close", "closed-callback", "exit"] - assert runtime._state == "closed" - assert runtime._bridge._thread is not None - assert not runtime._bridge._thread.is_alive() + with pytest.raises(RuntimeError, match="closed"): + runtime.start() def test_external_close_cancels_method_before_lifecycle_exit(self) -> None: events: list[str] = [] @@ -374,45 +365,6 @@ def create() -> None: assert type(errors[0]).__name__ == "CancelledError" assert events == ["enter", "credential-start", "credential-finally", "exit"] - @pytest.mark.parametrize("threaded_close", [False, True]) - def test_close_during_method_exit_does_not_deadlock(self, threaded_close: bool) -> None: - events: list[str] = [] - runtime_holder: dict[str, PaymentRuntime] = {} - - class ManagedMethod(MockMethod): - async def __aenter__(self) -> ManagedMethod: - events.append("enter") - return self - - async def __aexit__(self, *_args: Any) -> None: - events.append("exit-start") - runtime = runtime_holder["runtime"] - if threaded_close: - await asyncio.to_thread(runtime.close) - else: - runtime.close() - events.append("exit-end") - - runtime = PaymentRuntime(method_factories=[ManagedMethod]) - runtime_holder["runtime"] = runtime - runtime.start() - errors: list[BaseException] = [] - - def close() -> None: - try: - runtime.close() - except BaseException as error: - errors.append(error) - - worker = threading.Thread(target=close, daemon=True) - worker.start() - worker.join(timeout=1) - - assert not worker.is_alive() - assert not errors - assert events == ["enter", "exit-start", "exit-end"] - assert runtime._state == "closed" - def test_concurrent_close_waits_for_method_exit(self) -> None: exit_started = threading.Event() release_exit = threading.Event() @@ -455,26 +407,6 @@ def close(*, done: threading.Event | None = None) -> None: assert not first.is_alive() and not second.is_alive() assert exits == 1 - @pytest.mark.asyncio - async def test_async_finalizer_yields_to_in_progress_close( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - runtime = PaymentRuntime([]) - runtime._state = "closing" - runtime._deferred_close = True - runtime._finalizing = True - called = False - - async def cancel_pending() -> None: - nonlocal called - called = True - - monkeypatch.setattr(runtime._bridge, "_cancel_pending", cancel_pending) - await runtime._finish_close_async() - - assert not called - def test_method_exit_failure_still_stops_runtime(self) -> None: @asynccontextmanager async def factory() -> AsyncIterator[Method]: @@ -487,89 +419,10 @@ async def factory() -> AsyncIterator[Method]: with pytest.raises(ValueError, match="method exit failed"): runtime.close() - assert runtime._state == "closed" - assert runtime._method_stack is None - assert runtime._bridge._thread is not None - assert not runtime._bridge._thread.is_alive() - - @pytest.mark.asyncio - async def test_deferred_close_waits_for_inherited_runtime_lease_child(self) -> None: - runtime = PaymentRuntime([]) - entered = asyncio.Event() - release = asyncio.Event() - - async def child() -> None: - with runtime._runtime_operation(): - entered.set() - await release.wait() - - with runtime._runtime_operation(): - task = asyncio.create_task(child()) - await asyncio.wait_for(entered.wait(), 1) - runtime.close() - - assert runtime._state == "closing" - assert runtime._active_operations == 1 - release.set() - await asyncio.wait_for(task, 1) - assert runtime._state == "closed" - - def test_detached_owned_loop_close_finishes_cleanly(self) -> None: - runtime_holder: dict[str, PaymentRuntime] = {} - release: asyncio.Event | None = None - done = threading.Event() - errors: list[BaseException] = [] - lifecycle: list[str] = [] - - class SpawningMethod(MockMethod): - async def create_credential(self, value: Challenge) -> Credential: - nonlocal release - release = asyncio.Event() - - async def detached() -> None: - assert release is not None - await release.wait() - - async def close_inside_operation() -> None: - runtime_holder["runtime"].close() - - try: - await runtime_holder["runtime"].run_async(close_inside_operation()) - except BaseException as error: - errors.append(error) - finally: - done.set() - - asyncio.create_task(detached()) - return await super().create_credential(value) - - @asynccontextmanager - async def factory() -> AsyncIterator[Method]: - lifecycle.append("enter") - try: - yield SpawningMethod() - finally: - lifecycle.append("exit") - - runtime = PaymentRuntime(method_factories=[factory]).start() - runtime_holder["runtime"] = runtime - runtime.create_credential_sync(challenge(), runtime.methods[0]) - - async def wake_detached() -> None: - assert release is not None - release.set() - - runtime.run_sync(wake_detached()) - assert done.wait(1) - assert runtime._bridge._thread is not None runtime.close() - assert errors == [] - assert lifecycle == ["enter", "exit"] - assert runtime._method_stack is None - assert not runtime._bridge._thread.is_alive() -class TestRuntimeBridge: +class TestRuntimeExecution: def test_concurrent_sync_calls_share_one_method_loop(self) -> None: method = MockMethod() runtime = PaymentRuntime([method]) @@ -610,95 +463,6 @@ async def test_sync_async_and_events_share_runtime_loop(self) -> None: assert len(event_loops) == 4 assert set(event_loops) == {method.loops[0]} - def test_bridge_rejects_same_thread_blocking(self) -> None: - runtime = PaymentRuntime([]) - - async def block_bridge() -> None: - with pytest.raises(RuntimeError, match="Cannot block"): - runtime._bridge.run(asyncio.sleep(0)) - - try: - runtime._bridge.run(block_bridge()) - finally: - runtime.close() - - def test_thread_start_failure_closes_runtime_without_waiting( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - runtime = PaymentRuntime([]) - - def fail_start(_thread: threading.Thread) -> None: - raise OSError("thread unavailable") - - monkeypatch.setattr(threading.Thread, "start", fail_start) - with pytest.raises(RuntimeError, match="background loop failed") as exc_info: - runtime.start() - - assert isinstance(exc_info.value.__cause__, OSError) - assert runtime._state == "closed" - assert runtime._bridge._ready.is_set() - assert runtime._bridge._stopped.is_set() - - def test_shutdown_runs_all_cleanup_and_reraises_first_error( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - runtime = PaymentRuntime([]).start() - original_close = runtime._bridge.close - closed = False - - def fail_cancel() -> None: - raise ValueError("first cleanup failure") - - def close_then_fail() -> None: - nonlocal closed - original_close() - closed = True - raise RuntimeError("later cleanup failure") - - monkeypatch.setattr(runtime._bridge, "cancel_pending", fail_cancel) - monkeypatch.setattr(runtime._bridge, "close", close_then_fail) - - with pytest.raises(ValueError, match="first cleanup failure"): - runtime.close() - - assert closed - assert runtime._state == "closed" - assert runtime._bridge._stopped.is_set() - assert runtime._bridge._thread is not None - assert not runtime._bridge._thread.is_alive() - - def test_loop_cleanup_failure_is_published_after_thread_stops( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - runtime = PaymentRuntime([]).start() - runtime.run_sync(asyncio.to_thread(lambda: None)) - loop = runtime._bridge._loop - assert loop is not None - shutdown_default_executor = loop.shutdown_default_executor - executor_shutdowns = 0 - - async def fail_shutdown(_loop: Any) -> None: - raise OSError("async generator cleanup failed") - - async def record_executor_shutdown(_loop: Any) -> None: - nonlocal executor_shutdowns - executor_shutdowns += 1 - await shutdown_default_executor() - - monkeypatch.setattr(type(loop), "shutdown_asyncgens", fail_shutdown) - monkeypatch.setattr(type(loop), "shutdown_default_executor", record_executor_shutdown) - with pytest.raises(OSError, match="async generator cleanup failed"): - runtime.close() - - assert executor_shutdowns == 1 - assert runtime._state == "closed" - assert runtime._bridge._stopped.is_set() - assert runtime._bridge._thread is not None - assert not runtime._bridge._thread.is_alive() - def test_close_is_idempotent(self) -> None: method = MockMethod() runtime = PaymentRuntime([method]) From 8763962349200ea17b2f51553c6aa116b398fab8 Mon Sep 17 00:00:00 2001 From: Parv Ahuja <17094219+parvahuja@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:28:18 -0700 Subject: [PATCH 06/11] fix: preserve nested runtime context --- src/mpp/runtime.py | 12 +++++++----- tests/test_runtime.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/mpp/runtime.py b/src/mpp/runtime.py index 55fa456..89705fe 100644 --- a/src/mpp/runtime.py +++ b/src/mpp/runtime.py @@ -31,15 +31,15 @@ _T = TypeVar("_T") -_RUNTIME_CONTEXT: ContextVar[tuple[object, str] | None] = ContextVar( +_RUNTIME_CONTEXT: ContextVar[tuple[tuple[object, str], ...]] = ContextVar( "mpp_runtime_context", - default=None, + default=(), ) @contextmanager def _runtime_scope(key: object, kind: str): - token = _RUNTIME_CONTEXT.set((key, kind)) + token = _RUNTIME_CONTEXT.set((*_RUNTIME_CONTEXT.get(), (key, kind))) try: yield finally: @@ -47,8 +47,10 @@ def _runtime_scope(key: object, kind: str): def _scope_active(key: object, kind: str) -> bool: - current = _RUNTIME_CONTEXT.get() - return current is not None and current[0] is key and current[1] == kind + return any( + current_key is key and current_kind == kind + for current_key, current_kind in _RUNTIME_CONTEXT.get() + ) async def _wait_for_task(task: asyncio.Task[_T]) -> _T: diff --git a/tests/test_runtime.py b/tests/test_runtime.py index c8c67b5..0f3e6c7 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -275,6 +275,24 @@ def a_factory() -> MockMethod: a.close() b.close() + def test_nested_factory_cycle_fails_without_deadlock(self) -> None: + runtimes: dict[str, PaymentRuntime] = {} + + def a_factory() -> MockMethod: + runtimes["b"].start() + return MockMethod() + + def b_factory() -> MockMethod: + with pytest.raises(RuntimeError, match="while method factories start"): + runtimes["a"].start() + return MockMethod() + + runtimes["a"] = PaymentRuntime(method_factories=[a_factory]) + runtimes["b"] = PaymentRuntime(method_factories=[b_factory]) + with runtimes["a"]: + pass + runtimes["b"].close() + def test_invalid_factory_result_unwinds_and_stops_loop(self) -> None: runtime = PaymentRuntime(method_factories=[lambda: object()]) # type: ignore[list-item] From 8a0fe70e90ca0cf8c6c44466e4ed9629e6ceac2f Mon Sep 17 00:00:00 2001 From: Parv Ahuja <17094219+parvahuja@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:31:28 -0700 Subject: [PATCH 07/11] feat: share runtime with async HTTP payments --- .changelog/runtime-async-http.md | 5 + README.md | 19 + src/mpp/__init__.py | 1 + src/mpp/client/transport.py | 800 +++++++++++++++++++++------- src/mpp/errors.py | 23 + src/mpp/events.py | 4 +- src/mpp/runtime.py | 572 +++++++++++++++++++- tests/test_client.py | 886 ++++++++++++++++++++++++++++--- tests/test_errors.py | 10 + tests/test_runtime.py | 200 ++++++- 10 files changed, 2219 insertions(+), 301 deletions(-) create mode 100644 .changelog/runtime-async-http.md diff --git a/.changelog/runtime-async-http.md b/.changelog/runtime-async-http.md new file mode 100644 index 0000000..b20081f --- /dev/null +++ b/.changelog/runtime-async-http.md @@ -0,0 +1,5 @@ +--- +pympp: minor +--- + +Let asynchronous HTTP clients share a `PaymentRuntime`, with origin policy and fail-closed protection for uncertain payment outcomes. diff --git a/README.md b/README.md index 1e8effe..cf253a4 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,25 @@ Factories run once on the owned loop. Async context-manager results are closed on that same loop; direct `methods=` are borrowed and must be loop-independent. Factories and methods must finish any work they spawn before returning. +The runtime can also power the existing asynchronous HTTP client while enforcing +an exact origin allowlist: + +```python +from mpp.client import Client + +async with PaymentRuntime( + method_factories=[method_factory], + allowed_origins=["https://api.example.com"], +) as runtime: + async with Client(runtime=runtime) as client: + response = await client.get("https://api.example.com/paid") +``` + +If a credential is sent but its outcome cannot be confirmed, matching attempts +raise `PaymentOutcomeUnknownError` instead of paying again. Reconcile those +payments externally before calling +`runtime.reset_unknown_outcomes(reconciled=True)`. + ## Examples | Example | Description | diff --git a/src/mpp/__init__.py b/src/mpp/__init__.py index 77d0545..0d3a2f0 100644 --- a/src/mpp/__init__.py +++ b/src/mpp/__init__.py @@ -32,6 +32,7 @@ PaymentExpiredError, PaymentInsufficientError, PaymentMethodUnsupportedError, + PaymentOutcomeUnknownError, PaymentRequiredError, VerificationFailedError, ) diff --git a/src/mpp/client/transport.py b/src/mpp/client/transport.py index 5712f01..ecbddca 100644 --- a/src/mpp/client/transport.py +++ b/src/mpp/client/transport.py @@ -10,14 +10,16 @@ from __future__ import annotations import logging -from datetime import UTC, datetime -from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable +import re +from http.cookies import CookieError, SimpleCookie +from typing import TYPE_CHECKING, Any +from urllib.parse import quote import httpx from mpp import Challenge, Credential from mpp._parsing import ParseError -from mpp.errors import PaymentError +from mpp.errors import PaymentError, PaymentOutcomeUnknownError from mpp.events import ( CHALLENGE_RECEIVED, CREDENTIAL_CREATED, @@ -28,24 +30,21 @@ EventHandler, Unsubscribe, ) +from mpp.runtime import ( + Method, + PaymentRuntime, + _CallerLoopRuntime, + _challenge_is_expired, + _HttpPaymentAttempt, +) logger = logging.getLogger(__name__) +_COOKIE_ESCAPE = re.compile(r"%[0-9a-fA-F]{2}") if TYPE_CHECKING: from collections.abc import Sequence -@runtime_checkable -class Method(Protocol): - """Payment method interface for client-side credential creation.""" - - name: str - - async def create_credential(self, challenge: Challenge) -> Credential: - """Create a credential to satisfy the given challenge.""" - ... - - def _client_payment_failed_payload( *, challenge: Challenge | None, @@ -62,12 +61,322 @@ def _client_payment_failed_payload( "credential": credential, "error": error, "method": method, + "protocol": "http", "request": request, "response": response, } -class PaymentTransport(httpx.AsyncBaseTransport): +def _challenged_request( + response: httpx.Response, + fallback: httpx.Request, +) -> httpx.Request: + try: + return response.request + except RuntimeError: + response.request = fallback + return fallback + + +def _copy_request( + request: httpx.Request, + *, + headers: httpx.Headers | None = None, +) -> httpx.Request: + return httpx.Request( + method=request.method, + url=request.url, + headers=request.headers if headers is None else headers, + content=request.content, + extensions=request.extensions, + ) + + +def _apply_response_cookies( + response: httpx.Response, + source_request: httpx.Request, + target_request: httpx.Request, +) -> None: + """Apply cookies from an internal 402 response to its immediate retry.""" + set_cookie_headers = response.headers.get_list("set-cookie") + if not set_cookie_headers: + return + + _bind_response_request(response, source_request) + cookies = httpx.Cookies() + cookies.extract_cookies(response) + cookie_request = httpx.Request(target_request.method, target_request.url) + cookies.set_cookie_header(cookie_request) + replacement_parts = [ + part.strip() for part in cookie_request.headers.get("cookie", "").split(";") if part.strip() + ] + replacement_names = {part.split("=", 1)[0].strip() for part in replacement_parts} + + for header in set_cookie_headers: + replacement = SimpleCookie() + try: + replacement.load(header) + except CookieError: + continue + for name, cookie in replacement.items(): + if ( + name not in replacement_names + and _cookie_applies_to_request(cookie, target_request.url) + and _cookie_replaced_in_jar(response, name, cookie, target_request.url) + ): + replacement_names.add(name) + + existing_parts = [ + part.strip() for part in target_request.headers.get("cookie", "").split(";") if part.strip() + ] + retained_parts = [ + part for part in existing_parts if part.split("=", 1)[0].strip() not in replacement_names + ] + merged = "; ".join((*replacement_parts, *retained_parts)) + if merged: + target_request.headers["cookie"] = merged + else: + target_request.headers.pop("cookie", None) + + +def _cookie_applies_to_request(cookie: Any, url: httpx.URL) -> bool: + host = url.raw_host.decode("ascii").casefold() + domain = cookie["domain"].lstrip(".").casefold() + if domain and host != domain and not host.endswith(f".{domain}"): + return False + if cookie["secure"] and url.scheme.casefold() != "https": + return False + + request_path = _cookie_request_path(url) + cookie_path = _cookie_path(cookie, url) + return request_path == cookie_path or ( + request_path.startswith(cookie_path) + and (cookie_path.endswith("/") or request_path[len(cookie_path) :].startswith("/")) + ) + + +def _cookie_replaced_in_jar( + response: httpx.Response, + name: str, + cookie: Any, + url: httpx.URL, +) -> bool: + explicit_domain = cookie["domain"].lstrip(".").casefold() + domain = explicit_domain or url.raw_host.decode("ascii").casefold() + path = _cookie_path(cookie, url) + sentinel = "mpp-existing-cookie" + probe = httpx.Cookies() + probe.set(name, sentinel, domain=f".{domain}" if explicit_domain else domain, path=path) + probe.extract_cookies(response) + return not any( + item.name == name + and item.domain.lstrip(".").casefold() == domain + and item.path == path + and item.value == sentinel + for item in probe.jar + ) + + +def _cookie_path(cookie: Any, url: httpx.URL) -> str: + cookie_path = cookie["path"] + if cookie_path.startswith("/"): + return cookie_path + request_path = _cookie_request_path(url) + last_slash = request_path.rfind("/") + return "/" if last_slash <= 0 else request_path[:last_slash] + + +def _cookie_request_path(url: httpx.URL) -> str: + path = url.raw_path.partition(b"?")[0].decode("ascii") + path = quote(path, safe="%/;:@&=+$,!~*'()") + return _COOKIE_ESCAPE.sub(lambda match: match[0].upper(), path) or "/" + + +def _propagate_response_cookies( + source: httpx.Response, + target: httpx.Response, +) -> None: + """Expose cookies from an internal 402 response on the returned response.""" + if source is target: + return + source_values = source.headers.get_list("set-cookie") + if not source_values: + return + target_values = target.headers.get_list("set-cookie") + target.headers.pop("set-cookie", None) + target.headers.update([("set-cookie", value) for value in (*source_values, *target_values)]) + + +def _bind_response_request(response: httpx.Response, request: httpx.Request) -> None: + try: + _ = response.request + except RuntimeError: + response.request = request + + +async def _aclose_response(response: httpx.Response) -> None: + try: + await response.aclose() + except BaseException: + pass + + +def _authentication_challenges(header: str) -> list[str]: + """Split a WWW-Authenticate field without splitting auth-param lists.""" + challenges: list[str] = [] + start = 0 + quoted = False + escaped = False + token_chars = frozenset("!#$%&'*+-.^_`|~") + + for index, character in enumerate(header): + if quoted: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + quoted = False + continue + if character == '"': + quoted = True + continue + if character != ",": + continue + + next_index = index + 1 + while next_index < len(header) and header[next_index] in " \t": + next_index += 1 + token_end = next_index + while token_end < len(header) and ( + header[token_end].isalnum() or header[token_end] in token_chars + ): + token_end += 1 + if token_end == next_index: + continue + + after_token = token_end + while after_token < len(header) and header[after_token] in " \t": + after_token += 1 + if after_token < len(header) and header[after_token] == "=": + continue + if token_end < len(header) and header[token_end] not in " \t": + continue + + challenge = header[start:index].strip() + if challenge: + challenges.append(challenge) + start = next_index + + challenge = header[start:].strip() + if challenge: + challenges.append(challenge) + return challenges + + +def _payment_challenges(response: httpx.Response) -> tuple[list[Challenge], ParseError | None]: + challenges: list[Challenge] = [] + parse_error: ParseError | None = None + for header in response.headers.get_list("www-authenticate"): + for authentication_challenge in _authentication_challenges(header): + if not authentication_challenge.lower().startswith("payment "): + continue + try: + challenges.append(Challenge.from_www_authenticate(authentication_challenge)) + except ParseError as error: + parse_error = error + return challenges, parse_error + + +def _match_http_challenge( + runtime: PaymentRuntime, + challenges: list[Challenge], +) -> tuple[Challenge | None, Method | None]: + current: list[Challenge] = [] + expired: list[Challenge] = [] + for challenge in challenges: + (expired if _challenge_is_expired(challenge) else current).append(challenge) + for candidates in (current, expired): + if not candidates: + continue + try: + return runtime.match_challenge( + candidates, + prefer_method_order=False, + allow_name_only=True, + ) + except ValueError: + pass + return None, None + + +class _EventHandlers: + _events: EventDispatcher + + def on(self, name: str, handler: EventHandler) -> Unsubscribe: + """Register a client payment event handler.""" + return self._events.on(name, handler) + + def on_challenge_received(self, handler: EventHandler) -> Unsubscribe: + return self.on(CHALLENGE_RECEIVED, handler) + + def on_credential_created(self, handler: EventHandler) -> Unsubscribe: + return self.on(CREDENTIAL_CREATED, handler) + + def on_payment_response(self, handler: EventHandler) -> Unsubscribe: + return self.on(PAYMENT_RESPONSE, handler) + + def on_payment_failed(self, handler: EventHandler) -> Unsubscribe: + return self.on(PAYMENT_FAILED, handler) + + +class _OutcomeStream: + def __init__( + self, + stream: Any, + runtime: PaymentRuntime, + attempt: _HttpPaymentAttempt, + ) -> None: + self._stream = stream + self._runtime: PaymentRuntime | None = runtime + self._attempt: _HttpPaymentAttempt | None = attempt + + def _take_attempt(self) -> tuple[PaymentRuntime, _HttpPaymentAttempt] | None: + runtime, attempt = self._runtime, self._attempt + self._runtime = None + self._attempt = None + if runtime is None or attempt is None: + return None + return runtime, attempt + + def _mark_complete(self) -> None: + if state := self._take_attempt(): + state[0]._mark_http_response_body_complete(state[1]) + + def _mark_unknown(self, error: BaseException) -> None: + if state := self._take_attempt(): + state[0]._mark_http_payment_unknown(state[1], error) + + +class _AsyncOutcomeStream(_OutcomeStream, httpx.AsyncByteStream): + async def __aiter__(self): + try: + async for chunk in self._stream: + yield chunk + except BaseException as error: + self._mark_unknown(error) + raise + else: + self._mark_complete() + + async def aclose(self) -> None: + try: + await self._stream.aclose() + finally: + self._mark_unknown(RuntimeError("Paid response body was not fully consumed")) + + +class PaymentTransport(_EventHandlers, httpx.AsyncBaseTransport): """httpx transport that handles 402 Payment Required responses. Wraps an inner transport and automatically: @@ -88,219 +397,303 @@ class PaymentTransport(httpx.AsyncBaseTransport): def __init__( self, - methods: Sequence[Method], + methods: Sequence[Method] | None = None, inner: httpx.AsyncBaseTransport | None = None, events: EventDispatcher | None = None, + *, + runtime: PaymentRuntime | None = None, ) -> None: - self._methods = {m.name: m for m in methods} + self._owns_runtime = runtime is None + if runtime is not None: + if methods is not None or events is not None: + raise ValueError("Pass either methods/events or runtime, not both") + self._runtime = runtime + else: + if methods is None: + raise ValueError("Pass methods or runtime") + self._runtime = _CallerLoopRuntime(methods or [], events=events) self._inner = inner or httpx.AsyncHTTPTransport() - self._events = events or EventDispatcher() - - def on(self, name: str, handler: EventHandler) -> Unsubscribe: - """Register a client payment event handler.""" - return self._events.on(name, handler) - - def on_challenge_received(self, handler: EventHandler) -> Unsubscribe: - """Register a handler for selected payment challenges.""" - return self.on(CHALLENGE_RECEIVED, handler) - - def on_credential_created(self, handler: EventHandler) -> Unsubscribe: - """Register a handler for created credentials.""" - return self.on(CREDENTIAL_CREATED, handler) - - def on_payment_response(self, handler: EventHandler) -> Unsubscribe: - """Register a handler for successful paid retry responses.""" - return self.on(PAYMENT_RESPONSE, handler) - - def on_payment_failed(self, handler: EventHandler) -> Unsubscribe: - """Register a handler for failed automatic payment handling.""" - return self.on(PAYMENT_FAILED, handler) + self._events = self._runtime.events async def handle_async_request(self, request: httpx.Request) -> httpx.Response: """Handle request, automatically retrying on 402 with credentials.""" - # Async-generator bodies (content=async_gen()) produce an AsyncByteStream - # that is not also a SyncByteStream. They cannot be safely buffered for - # replay: the generator may be infinite, already partially consumed, or - # tied to a one-shot I/O source. Reject early so callers get a clear - # message instead of a silent empty body on the paid retry. - if isinstance(request.stream, httpx.AsyncByteStream) and not isinstance( - request.stream, httpx.SyncByteStream - ): - raise PaymentError( - "Streaming request bodies (async generators) are not supported " - "through the payment retry flow. Use a buffered body " - "(bytes, str, files=, or data=) instead." - ) - - # Buffer the request body before the first dispatch so it can be replayed - # on a paid 402 retry. Handles bytes bodies and multipart/files= bodies. - # After aread() the stream is replaced with a replayable ByteStream. - await request.aread() - - response = await self._inner.handle_async_request(request) + with self._runtime._httpx_operation_scope(request): + response = await self._inner.handle_async_request(request) + return await self._handle_async_response(request, response) + async def _handle_async_response( + self, + request: httpx.Request, + response: httpx.Response, + ) -> httpx.Response: + """Handle an already-dispatched response, retrying a payable 402.""" if response.status_code != 402: return response - await response.aread() + try: + # A high-level send may have followed redirects before returning the + # 402. Apply policy and retry against the request that was challenged. + challenged_request = _challenged_request(response, request) + if not self._runtime.allows_http_payment(challenged_request.url): + return response + + challenges, parse_error = _payment_challenges(response) + if challenges: + await self._runtime.astart() + + challenge, matched_method = _match_http_challenge(self._runtime, challenges) + + if challenge is None or matched_method is None: + if parse_error is not None or challenges: + # Surface parse/method-selection failures to observers while + # preserving the original 402 response for the caller. + await self._runtime.emit_event( + PAYMENT_FAILED, + _client_payment_failed_payload( + challenge=None, + challenges=challenges, + credential=None, + error=parse_error + or ValueError("No compatible payment method for challenges"), + method=None, + request=challenged_request, + response=response, + ), + ) + return response - # Handle multiple WWW-Authenticate headers (per RFC 9110) - www_auth_headers = response.headers.get_list("www-authenticate") + expired = _challenge_is_expired(challenge) + if expired: + logger.warning("Challenge expired at %s, not paying", challenge.expires) + await self._runtime.emit_event( + PAYMENT_FAILED, + _client_payment_failed_payload( + challenge=challenge, + challenges=challenges, + credential=None, + error=ValueError(f"Challenge expired at {challenge.expires}"), + method=matched_method, + request=challenged_request, + response=response, + ), + ) + return response - challenges: list[Challenge] = [] - parse_error: ParseError | None = None - for header in www_auth_headers: - if not header.lower().startswith("payment "): - continue try: - parsed = Challenge.from_www_authenticate(header) - except ParseError as error: - parse_error = error - continue - challenges.append(parsed) - - challenge = None - matched_method = None - for parsed in challenges: - if parsed.method in self._methods: - challenge = parsed - matched_method = self._methods[parsed.method] - break - - if not challenge or not matched_method: - if parse_error is not None or challenges: - # Surface parse/method-selection failures to observers while - # preserving the original 402 response for the caller. - await self._events.emit( + await challenged_request.aread() + except httpx.StreamConsumed as cause: + error = PaymentError( + "Streaming request bodies cannot be replayed after a payment challenge. " + "Use a buffered body for paid requests." + ) + await self._runtime.emit_event( PAYMENT_FAILED, _client_payment_failed_payload( - challenge=None, + challenge=challenge, challenges=challenges, credential=None, - error=parse_error - or ValueError("No compatible payment method for challenges"), - method=None, - request=request, + error=error, + method=matched_method, + request=challenged_request, response=response, ), ) - return response + raise error from cause - # Check expiry before paying (client-side guardrail) - if challenge.expires: - try: - expires_dt = datetime.fromisoformat(challenge.expires.replace("Z", "+00:00")) - if expires_dt < datetime.now(UTC): - logger.warning("Challenge expired at %s, not paying", challenge.expires) - await self._events.emit( - PAYMENT_FAILED, - _client_payment_failed_payload( - challenge=challenge, - challenges=challenges, - credential=None, - error=ValueError(f"Challenge expired at {challenge.expires}"), - method=matched_method, - request=request, - response=response, - ), - ) - return response - except ValueError: - pass # If we can't parse, let server validate + await response.aread() + except BaseException: + await _aclose_response(response) + raise try: - # challenge.received is the one client event that can override the - # default credential creation path by returning a Credential. - event_credential = await self._events.emit( - CHALLENGE_RECEIVED, - { - "challenge": challenge, - "challenges": challenges, - "method": matched_method, - "request": request, - "response": response, - }, - first_result=True, - ) - credential = ( - event_credential - if isinstance(event_credential, Credential) - else await matched_method.create_credential(challenge) - ) - await self._events.emit( - CREDENTIAL_CREATED, - { - "challenge": challenge, - "credential": credential, - "method": matched_method, - "request": request, - "response": response, - }, - ) - auth_header = credential.to_authorization() - except Exception as error: - await self._events.emit( + attempt = self._runtime._begin_http_payment(challenge, challenged_request) + except PaymentOutcomeUnknownError as error: + await self._runtime.emit_event( PAYMENT_FAILED, _client_payment_failed_payload( challenge=challenge, challenges=challenges, - credential=None, + credential=error.credential, error=error, method=matched_method, - request=request, + request=challenged_request, response=response, ), ) raise - headers = httpx.Headers(request.headers) - headers["Authorization"] = auth_header - - retry_request = httpx.Request( - method=request.method, - url=request.url, - headers=headers, - content=request.content, - extensions=request.extensions, - ) - try: - payment_response = await self._inner.handle_async_request(retry_request) - except Exception as error: - await self._events.emit( + credential = await self._runtime.create_credential( + challenge, + matched_method, + event_payload={ + "challenges": challenges, + "request": challenged_request, + "response": response, + "protocol": "http", + }, + ) + auth_header = credential.to_authorization() + except BaseException as error: + self._runtime._discard_http_payment(attempt) + if not isinstance(error, Exception): + raise + await self._runtime.emit_event( PAYMENT_FAILED, _client_payment_failed_payload( challenge=challenge, challenges=challenges, - credential=credential, + credential=None, error=error, method=matched_method, - request=request, + request=challenged_request, response=response, ), ) raise + self._runtime._set_http_payment_credential(attempt, credential) - if payment_response.is_success: - await self._events.emit( - PAYMENT_RESPONSE, - { - "challenge": challenge, - "credential": credential, - "method": matched_method, - "request": request, - "response": payment_response, - }, - ) - - return payment_response + try: + headers = httpx.Headers(challenged_request.headers) + headers["Authorization"] = auth_header + retry_request = _copy_request(challenged_request, headers=headers) + _apply_response_cookies(response, challenged_request, retry_request) + + with self._runtime._paid_operation(): + try: + self._runtime._mark_http_payment_sent(attempt, retry_request) + except PaymentOutcomeUnknownError as error: + await self._runtime.emit_event( + PAYMENT_FAILED, + _client_payment_failed_payload( + challenge=challenge, + challenges=challenges, + credential=credential, + error=error, + method=matched_method, + request=challenged_request, + response=response, + ), + ) + raise + try: + payment_response = await self._inner.handle_async_request(retry_request) + except BaseException as cause: + self._runtime._mark_http_payment_unknown(attempt, cause) + if not isinstance(cause, Exception): + raise + error = PaymentOutcomeUnknownError( + challenge, + cause, + credential=credential, + request=challenged_request, + ) + await self._runtime.emit_event( + PAYMENT_FAILED, + _client_payment_failed_payload( + challenge=challenge, + challenges=challenges, + credential=credential, + error=error, + method=matched_method, + request=challenged_request, + response=response, + ), + ) + raise error from cause + + _bind_response_request(payment_response, retry_request) + _propagate_response_cookies(response, payment_response) + + try: + if payment_response.status_code == 402: + cause = RuntimeError( + "Server returned another payment challenge after receiving a credential" + ) + self._runtime._mark_http_payment_unknown(attempt, cause) + error = PaymentOutcomeUnknownError( + challenge, + cause, + credential=credential, + request=challenged_request, + ) + await self._runtime.emit_event( + PAYMENT_FAILED, + _client_payment_failed_payload( + challenge=challenge, + challenges=challenges, + credential=credential, + error=error, + method=matched_method, + request=challenged_request, + response=payment_response, + ), + ) + raise error from cause + + if payment_response.status_code >= 400: + cause = RuntimeError( + f"Credentialed request returned HTTP {payment_response.status_code}" + ) + self._runtime._mark_http_payment_unknown(attempt, cause) + await self._runtime.emit_event( + PAYMENT_FAILED, + _client_payment_failed_payload( + challenge=challenge, + challenges=challenges, + credential=credential, + error=PaymentOutcomeUnknownError( + challenge, + cause, + credential=credential, + request=challenged_request, + ), + method=matched_method, + request=challenged_request, + response=payment_response, + ), + ) + elif payment_response.is_stream_consumed: + self._runtime._mark_http_response_body_complete(attempt) + else: + payment_response.stream = _AsyncOutcomeStream( + payment_response.stream, + self._runtime, + attempt, + ) + + if payment_response.is_success: + await self._runtime.emit_event( + PAYMENT_RESPONSE, + { + "challenge": challenge, + "challenges": challenges, + "credential": credential, + "method": matched_method, + "request": challenged_request, + "response": payment_response, + "protocol": "http", + }, + ) + except BaseException: + await _aclose_response(payment_response) + raise + return payment_response + except BaseException: + if not attempt.sent: + self._runtime._discard_http_payment(attempt) + raise async def aclose(self) -> None: """Close the inner transport.""" - await self._inner.aclose() + try: + await self._inner.aclose() + finally: + if self._owns_runtime: + await self._runtime.aclose() -class Client: +class Client(_EventHandlers): """HTTP client with automatic payment handling. Example: @@ -308,29 +701,15 @@ class Client: response = await client.get("https://api.example.com/resource") """ - def __init__(self, methods: Sequence[Method]) -> None: - self._transport = PaymentTransport(methods) + def __init__( + self, + methods: Sequence[Method] | None = None, + *, + runtime: PaymentRuntime | None = None, + ) -> None: + self._transport = PaymentTransport(methods=methods, runtime=runtime) self._client = httpx.AsyncClient(transport=self._transport) - - def on(self, name: str, handler: EventHandler) -> Unsubscribe: - """Register a client payment event handler.""" - return self._transport.on(name, handler) - - def on_challenge_received(self, handler: EventHandler) -> Unsubscribe: - """Register a handler for selected payment challenges.""" - return self.on(CHALLENGE_RECEIVED, handler) - - def on_credential_created(self, handler: EventHandler) -> Unsubscribe: - """Register a handler for created credentials.""" - return self.on(CREDENTIAL_CREATED, handler) - - def on_payment_response(self, handler: EventHandler) -> Unsubscribe: - """Register a handler for successful paid retry responses.""" - return self.on(PAYMENT_RESPONSE, handler) - - def on_payment_failed(self, handler: EventHandler) -> Unsubscribe: - """Register a handler for failed automatic payment handling.""" - return self.on(PAYMENT_FAILED, handler) + self._events = self._transport._events async def __aenter__(self) -> Client: await self._client.__aenter__() @@ -369,7 +748,8 @@ async def request( method: str, url: str, *, - methods: Sequence[Method], + methods: Sequence[Method] | None = None, + runtime: PaymentRuntime | None = None, **kwargs: Any, ) -> httpx.Response: """Send an HTTP request with automatic payment handling. @@ -384,15 +764,27 @@ async def request( methods=[tempo(...)], ) """ - async with Client(methods) as client: + async with Client(methods, runtime=runtime) as client: return await client.request(method, url, **kwargs) -async def get(url: str, *, methods: Sequence[Method], **kwargs: Any) -> httpx.Response: +async def get( + url: str, + *, + methods: Sequence[Method] | None = None, + runtime: PaymentRuntime | None = None, + **kwargs: Any, +) -> httpx.Response: """Send a GET request with automatic payment handling.""" - return await request("GET", url, methods=methods, **kwargs) + return await request("GET", url, methods=methods, runtime=runtime, **kwargs) -async def post(url: str, *, methods: Sequence[Method], **kwargs: Any) -> httpx.Response: +async def post( + url: str, + *, + methods: Sequence[Method] | None = None, + runtime: PaymentRuntime | None = None, + **kwargs: Any, +) -> httpx.Response: """Send a POST request with automatic payment handling.""" - return await request("POST", url, methods=methods, **kwargs) + return await request("POST", url, methods=methods, runtime=runtime, **kwargs) diff --git a/src/mpp/errors.py b/src/mpp/errors.py index 7ba85c9..859374d 100644 --- a/src/mpp/errors.py +++ b/src/mpp/errors.py @@ -71,6 +71,29 @@ def to_problem_details(self, challenge_id: str | None = None) -> dict[str, Any]: return details +class PaymentOutcomeUnknownError(PaymentError, RuntimeError): + """A credential was sent, but the payment result could not be confirmed.""" + + def __init__( + self, + challenge: Any, + cause: BaseException, + *, + credential: Any | None = None, + request: Any | None = None, + ) -> None: + self.challenge = challenge + self.cause = cause + self.credential = credential + self.request = request + challenge_id = getattr(challenge, "id", "unknown") + super().__init__( + "Payment outcome is unknown after sending a credential " + f"for challenge {challenge_id}. " + "Do not blindly retry." + ) + + class PaymentRequiredError(PaymentError): """No credential was provided but payment is required.""" diff --git a/src/mpp/events.py b/src/mpp/events.py index 5e23926..64b8f81 100644 --- a/src/mpp/events.py +++ b/src/mpp/events.py @@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from enum import StrEnum -from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict, overload +from typing import TYPE_CHECKING, Any, Final, Literal, NotRequired, TypedDict, overload if TYPE_CHECKING: import httpx @@ -58,6 +58,7 @@ class ClientChallengeReceivedPayload(TypedDict): challenge: Challenge challenges: list[Challenge] method: Any + protocol: NotRequired[Literal["http"]] request: httpx.Request response: httpx.Response @@ -76,6 +77,7 @@ class ClientPaymentFailedPayload(TypedDict): credential: Credential | None error: Exception method: Any | None + protocol: NotRequired[Literal["http"]] request: httpx.Request response: httpx.Response diff --git a/src/mpp/runtime.py b/src/mpp/runtime.py index 89705fe..caa8e33 100644 --- a/src/mpp/runtime.py +++ b/src/mpp/runtime.py @@ -1,8 +1,9 @@ -"""Shared lifecycle for client-side payment methods.""" +"""Shared payment runtime for asynchronous HTTP clients.""" from __future__ import annotations import asyncio +import hashlib import inspect import threading from collections.abc import Awaitable, Callable, Coroutine, Mapping, Sequence @@ -14,8 +15,11 @@ contextmanager, ) from contextvars import ContextVar -from typing import Any, Protocol, Self, TypeVar, cast +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any, Protocol, Self, TypeVar, cast +import httpx from anyio import TASK_STATUS_IGNORED from anyio.abc import TaskStatus from anyio.from_thread import BlockingPortal, start_blocking_portal @@ -28,15 +32,63 @@ EventPayload, ) +if TYPE_CHECKING: + from mpp.client import PaymentTransport + _T = TypeVar("_T") +class _BridgeBaseException(Exception): + def __init__(self, error: BaseException) -> None: + self.error = error + + _RUNTIME_CONTEXT: ContextVar[tuple[tuple[object, str], ...]] = ContextVar( "mpp_runtime_context", default=(), ) +@dataclass(frozen=True, slots=True) +class _HttpPaymentTombstone: + challenge: Challenge + credential: Credential | None + cause: BaseException + request: httpx.Request + + +@dataclass(eq=False, slots=True) +class _HttpPaymentAttempt: + runtime: PaymentRuntime + challenge_key: str + operation_key: str + challenge: Challenge + request: httpx.Request + credential: Credential | None = None + cause: BaseException | None = None + sent: bool = False + body_complete: bool = False + send_complete: bool = False + operation: _HttpxOperation | None = None + idempotent: bool = False + retry_request: httpx.Request | None = None + + +@dataclass(slots=True) +class _HttpxOperation: + attempts: list[_HttpPaymentAttempt] = field(default_factory=list) + payment_sent: bool = False + active: bool = True + + +_HTTPX_OPERATIONS: ContextVar[dict[int, _HttpxOperation] | None] = ContextVar( + "mpp_httpx_operations", + default=None, +) +_HTTP_PAYMENT_ATTEMPT_EXTENSION = "mpp.payment_attempt" +_DEFAULT_MAX_UNKNOWN_OUTCOMES = 1024 + + @contextmanager def _runtime_scope(key: object, kind: str): token = _RUNTIME_CONTEXT.set((*_RUNTIME_CONTEXT.get(), (key, kind))) @@ -126,6 +178,12 @@ async def _run_coroutine( task_status.started() try: return await coroutine + except asyncio.CancelledError: + raise + except BaseException as error: + if isinstance(error, Exception): + raise + raise _BridgeBaseException(error) from None finally: self._tasks.discard(task) @@ -147,6 +205,8 @@ def run(self, coroutine: Coroutine[Any, Any, _T]) -> _T: raise try: return future.result() + except _BridgeBaseException as error: + raise error.error from None except BaseException: future.cancel() raise @@ -162,6 +222,8 @@ async def run_async(self, coroutine: Coroutine[Any, Any, _T]) -> _T: raise try: return await asyncio.wrap_future(future) + except _BridgeBaseException as error: + raise error.error from None except BaseException: future.cancel() raise @@ -226,22 +288,40 @@ def __init__( *, method_factories: Sequence[MethodFactory] = (), events: EventDispatcher | None = None, + allowed_origins: Sequence[str] | None = None, + max_unknown_outcomes: int = _DEFAULT_MAX_UNKNOWN_OUTCOMES, ) -> None: if methods is not None and method_factories: raise ValueError("Pass either methods or method_factories, not both") + if ( + isinstance(max_unknown_outcomes, bool) + or not isinstance(max_unknown_outcomes, int) + or max_unknown_outcomes < 1 + ): + raise ValueError("max_unknown_outcomes must be a positive integer") self.methods = tuple(methods or ()) if any(not _is_method(method) for method in self.methods): raise TypeError("methods must contain payment Methods") self._method_factories = tuple(method_factories) self._method_stack: AsyncExitStack | None = None self.events = events or EventDispatcher() + self._allowed = _AllowedOrigins(allowed_origins) self._bridge = _AsyncBridge() self._state_changed = threading.Condition() self._active_operations = 0 + self._active_paid_operations = 0 self._scope_key = object() self._state = "new" self._start_error: BaseException | None = None self._deferred_close = False + self._http_attempt_lock = threading.Lock() + self._http_challenges: dict[str, _HttpPaymentAttempt] = {} + self._http_unknown_challenges: dict[str, _HttpPaymentTombstone] = {} + self._http_unknown_operations: dict[str, _HttpPaymentTombstone] = {} + self._http_unknown_circuit: _HttpPaymentTombstone | None = None + self._http_idempotent_operations: dict[str, _HttpPaymentAttempt] = {} + self._http_active_idempotent_operations: dict[str, int] = {} + self._max_unknown_outcomes = max_unknown_outcomes def _in_method_lifecycle(self) -> bool: return _scope_active(self._scope_key, "lifecycle") @@ -249,6 +329,9 @@ def _in_method_lifecycle(self) -> bool: def _in_operation(self) -> bool: return _scope_active(self._scope_key, "operation") + def _in_paid_operation(self) -> bool: + return _scope_active(self._scope_key, "paid") + async def _initialize_methods(self) -> None: with _runtime_scope(self._scope_key, "lifecycle"): async with AsyncExitStack() as stack: @@ -342,7 +425,7 @@ def _runtime_operation(self): return with self._state_changed: - if self._state != "open": + if self._state != "open" and not self._in_paid_operation(): raise RuntimeError("PaymentRuntime is closed") self._active_operations += 1 @@ -352,7 +435,38 @@ def _runtime_operation(self): finally: with self._state_changed: self._active_operations -= 1 - should_close = self._deferred_close and self._active_operations == 0 + should_close = ( + self._deferred_close + and self._active_operations == 0 + and self._active_paid_operations == 0 + ) + if should_close: + self._finish_close() + + @contextmanager + def _paid_operation(self): + if self._in_paid_operation(): + yield + return + + self.start() + with self._state_changed: + if self._state != "open": + raise RuntimeError("PaymentRuntime is closed") + self._active_paid_operations += 1 + + try: + with _runtime_scope(self._scope_key, "paid"): + yield + finally: + with self._state_changed: + self._active_paid_operations -= 1 + should_close = ( + self._deferred_close + and self._active_operations == 0 + and self._active_paid_operations == 0 + ) + self._state_changed.notify_all() if should_close: self._finish_close() @@ -364,11 +478,322 @@ def _finish_close(self) -> None: finalizer = self._teardown_methods if self._method_stack is not None else None self._bridge.close(finalizer) finally: + self._clear_payment_state() with self._state_changed: self._state = "closed" self._deferred_close = False self._state_changed.notify_all() + def _clear_payment_state(self) -> None: + with self._http_attempt_lock: + for attempt in set(self._http_challenges.values()): + if attempt.sent: + self._mark_http_payment_unknown_locked( + attempt, + RuntimeError( + "PaymentRuntime closed before the paid response outcome was confirmed" + ), + ) + else: + self._remove_http_attempt_locked(attempt) + self._http_challenges.clear() + self._http_unknown_challenges.clear() + self._http_unknown_operations.clear() + self._http_unknown_circuit = None + self._http_idempotent_operations.clear() + self._http_active_idempotent_operations.clear() + + def reset_unknown_outcomes(self, *, reconciled: bool) -> None: + """Reopen payments after every retained unknown outcome was reconciled. + + This clears runtime-level tombstones and any fail-closed circuits. + Existing request objects keep their own tombstone markers and must not + be reused. + """ + if not reconciled: + raise ValueError("Unknown payment outcomes must be externally reconciled before reset") + with self._http_attempt_lock: + self._http_unknown_challenges.clear() + self._http_unknown_operations.clear() + self._http_unknown_circuit = None + + def payment_transport(self, inner: httpx.AsyncBaseTransport | None = None) -> PaymentTransport: + """Create an httpx transport using this runtime's payment methods.""" + from mpp.client import PaymentTransport + + return PaymentTransport( + inner=inner, + runtime=self, + ) + + def allows_http_payment(self, url: httpx.URL) -> bool: + """Return whether credentials may be created for an HTTP origin.""" + return self._allowed.http_url(url) + + @contextmanager + def _httpx_operation_scope(self, request: httpx.Request): + operations = _HTTPX_OPERATIONS.get() or {} + + operation_key = _http_idempotency_key(request) + operation = _HttpxOperation() + if operation_key is not None: + with self._http_attempt_lock: + self._http_active_idempotent_operations[operation_key] = ( + self._http_active_idempotent_operations.get(operation_key, 0) + 1 + ) + token = _HTTPX_OPERATIONS.set({**operations, id(self): operation}) + try: + yield operation + except BaseException as cause: + self._complete_httpx_operation(operation, cause) + raise + else: + self._complete_httpx_operation(operation) + finally: + operation.active = False + if operation_key is not None: + with self._http_attempt_lock: + remaining = self._http_active_idempotent_operations.get(operation_key, 0) - 1 + if remaining > 0: + self._http_active_idempotent_operations[operation_key] = remaining + else: + self._http_active_idempotent_operations.pop(operation_key, None) + if attempt := self._http_idempotent_operations.get(operation_key): + self._remove_completed_http_attempt_locked(attempt) + _HTTPX_OPERATIONS.reset(token) + + def _complete_httpx_operation( + self, + operation: _HttpxOperation, + cause: BaseException | None = None, + ) -> None: + for attempt in tuple(operation.attempts): + if cause is not None: + if attempt.sent: + self._mark_http_payment_unknown(attempt, cause) + else: + self._discard_http_payment(attempt) + else: + self._mark_http_send_complete(attempt) + + def _begin_http_payment( + self, + challenge: Challenge, + request: httpx.Request, + ) -> _HttpPaymentAttempt: + from mpp.errors import PaymentOutcomeUnknownError + + challenge_key, operation_key, idempotent = _http_attempt_keys(challenge, request) + operation = (_HTTPX_OPERATIONS.get() or {}).get(id(self)) + marker = request.extensions.get(_HTTP_PAYMENT_ATTEMPT_EXTENSION) + if isinstance(marker, _HttpPaymentTombstone): + raise PaymentOutcomeUnknownError( + marker.challenge, + marker.cause, + credential=marker.credential, + request=marker.request, + ) + if isinstance(marker, _HttpPaymentAttempt) and marker.runtime is not self: + cause = marker.cause or RuntimeError( + "A payment credential was already sent for this logical HTTPX request" + ) + tombstone = marker.runtime._mark_http_payment_unknown(marker, cause) + raise PaymentOutcomeUnknownError( + tombstone.challenge, + tombstone.cause, + credential=tombstone.credential, + request=tombstone.request, + ) + with self._http_attempt_lock: + if circuit := self._http_unknown_circuit: + raise PaymentOutcomeUnknownError( + circuit.challenge, + circuit.cause, + credential=circuit.credential, + request=circuit.request, + ) + existing: _HttpPaymentAttempt | _HttpPaymentTombstone | None = None + if isinstance(marker, _HttpPaymentAttempt): + cause = marker.cause or RuntimeError( + "A payment credential was already sent for this logical HTTPX request" + ) + existing = self._mark_http_payment_unknown_locked(marker, cause) + elif operation is not None and operation.payment_sent: + sent = next( + (attempt for attempt in operation.attempts if attempt.sent), + None, + ) + if sent is not None: + cause = sent.cause or RuntimeError( + "A payment credential was already sent for this logical HTTPX request" + ) + existing = self._mark_http_payment_unknown_locked(sent, cause) + if existing is None: + existing = self._http_challenges.get(challenge_key) + if existing is None: + existing = self._http_unknown_challenges.get(challenge_key) + if existing is None and idempotent: + existing = self._http_idempotent_operations.get(operation_key) + if existing is None: + existing = self._http_unknown_operations.get(operation_key) + if existing is not None: + cause = existing.cause or RuntimeError( + "A matching payment attempt is already in progress" + ) + raise PaymentOutcomeUnknownError( + existing.challenge, + cause, + credential=existing.credential, + request=existing.request, + ) + attempt = _HttpPaymentAttempt( + runtime=self, + challenge_key=challenge_key, + operation_key=operation_key, + challenge=challenge, + request=request, + operation=operation, + idempotent=idempotent, + ) + self._http_challenges[challenge_key] = attempt + if idempotent: + self._http_idempotent_operations[operation_key] = attempt + if operation is not None: + operation.attempts.append(attempt) + return attempt + + def _set_http_payment_credential( + self, + attempt: _HttpPaymentAttempt, + credential: Credential, + ) -> None: + with self._http_attempt_lock: + attempt.credential = credential + + def _mark_http_payment_sent( + self, + attempt: _HttpPaymentAttempt, + retry_request: httpx.Request, + ) -> None: + from mpp.errors import PaymentOutcomeUnknownError + + with self._http_attempt_lock: + if circuit := self._http_unknown_circuit: + self._remove_http_attempt_locked(attempt) + raise PaymentOutcomeUnknownError( + circuit.challenge, + circuit.cause, + credential=circuit.credential, + request=circuit.request, + ) + existing = self._http_unknown_operations.get(attempt.operation_key) + if existing is not None and existing is not attempt: + self._remove_http_attempt_locked(attempt) + raise PaymentOutcomeUnknownError( + existing.challenge, + existing.cause or RuntimeError("A matching payment outcome is already unknown"), + credential=existing.credential, + request=existing.request, + ) + attempt.sent = True + attempt.retry_request = retry_request + if attempt.operation is not None: + attempt.operation.payment_sent = True + attempt.request.extensions[_HTTP_PAYMENT_ATTEMPT_EXTENSION] = attempt + retry_request.extensions[_HTTP_PAYMENT_ATTEMPT_EXTENSION] = attempt + + def _mark_http_payment_unknown( + self, + attempt: _HttpPaymentAttempt, + cause: BaseException, + ) -> _HttpPaymentTombstone: + with self._http_attempt_lock: + return self._mark_http_payment_unknown_locked(attempt, cause) + + def _mark_http_payment_unknown_locked( + self, + attempt: _HttpPaymentAttempt, + cause: BaseException, + ) -> _HttpPaymentTombstone: + compact_cause = _compact_cause(cause) + attempt.cause = compact_cause + tombstone = _HttpPaymentTombstone( + challenge=attempt.challenge, + credential=attempt.credential, + cause=compact_cause, + request=_compact_request(attempt.request), + ) + self._remove_http_attempt_locked(attempt) + self._set_http_request_markers(attempt, tombstone) + if self._http_unknown_circuit is not None: + return tombstone + + if attempt.operation_key in self._http_unknown_operations: + if attempt.challenge_key not in self._http_unknown_challenges: + if len(self._http_unknown_challenges) >= self._max_unknown_outcomes: + self._http_unknown_circuit = tombstone + else: + self._http_unknown_challenges[attempt.challenge_key] = tombstone + return tombstone + + if ( + len(self._http_unknown_challenges) >= self._max_unknown_outcomes + or len(self._http_unknown_operations) >= self._max_unknown_outcomes + ): + self._http_unknown_circuit = tombstone + return tombstone + self._http_unknown_challenges[attempt.challenge_key] = tombstone + self._http_unknown_operations[attempt.operation_key] = tombstone + return tombstone + + def _mark_http_response_body_complete(self, attempt: _HttpPaymentAttempt) -> None: + with self._http_attempt_lock: + attempt.body_complete = True + self._remove_completed_http_attempt_locked(attempt) + + def _mark_http_send_complete(self, attempt: _HttpPaymentAttempt) -> None: + with self._http_attempt_lock: + attempt.send_complete = True + self._remove_completed_http_attempt_locked(attempt) + + def _discard_http_payment(self, attempt: _HttpPaymentAttempt) -> None: + with self._http_attempt_lock: + if not attempt.sent: + self._remove_http_attempt_locked(attempt) + + def _remove_completed_http_attempt_locked(self, attempt: _HttpPaymentAttempt) -> None: + if attempt.cause is not None or not attempt.body_complete or not attempt.send_complete: + return + active = self._http_active_idempotent_operations.get(attempt.operation_key, 0) + if attempt.operation is not None and attempt.operation.active: + active -= 1 + if not attempt.idempotent or active <= 0: + self._remove_http_attempt_locked(attempt) + + def _remove_http_attempt_locked(self, attempt: _HttpPaymentAttempt) -> None: + for request in (attempt.request, attempt.retry_request): + if ( + request is not None + and request.extensions.get(_HTTP_PAYMENT_ATTEMPT_EXTENSION) is attempt + ): + request.extensions.pop(_HTTP_PAYMENT_ATTEMPT_EXTENSION, None) + if self._http_challenges.get(attempt.challenge_key) is attempt: + self._http_challenges.pop(attempt.challenge_key, None) + if ( + attempt.idempotent + and self._http_idempotent_operations.get(attempt.operation_key) is attempt + ): + self._http_idempotent_operations.pop(attempt.operation_key, None) + + @staticmethod + def _set_http_request_markers( + attempt: _HttpPaymentAttempt, + tombstone: _HttpPaymentTombstone, + ) -> None: + attempt.request.extensions[_HTTP_PAYMENT_ATTEMPT_EXTENSION] = tombstone + if attempt.retry_request is not None: + attempt.retry_request.extensions[_HTTP_PAYMENT_ATTEMPT_EXTENSION] = tombstone + def match_challenge( self, challenges: Sequence[Any], @@ -433,7 +858,7 @@ def create_credential_sync( def _run_sync(self, coroutine: Coroutine[Any, Any, _T]) -> _T: entered = False try: - if not self._in_operation(): + if not self._in_operation() and not self._in_paid_operation(): if self._bridge.is_current_thread(): raise RuntimeError("Cannot use PaymentRuntime from unmanaged runtime-loop work") self.start() @@ -448,7 +873,7 @@ def _run_sync(self, coroutine: Coroutine[Any, Any, _T]) -> _T: async def _run_async(self, coroutine: Coroutine[Any, Any, _T]) -> _T: entered = False try: - if not self._in_operation(): + if not self._in_operation() and not self._in_paid_operation(): if self._bridge.is_current_thread(): raise RuntimeError("Cannot use PaymentRuntime from unmanaged runtime-loop work") await self.astart() @@ -470,9 +895,9 @@ async def _create_credential( payload = { **(event_payload or {}), "challenge": challenge, - "challenges": [challenge], "method": method, } + payload.setdefault("challenges", [challenge]) event_credential = await self.events.emit( CHALLENGE_RECEIVED, payload, @@ -499,7 +924,7 @@ def emit_event_sync(self, name: str, payload: EventPayload) -> Any: def close(self) -> None: """Close method resources and the owned event loop.""" - active_here = self._in_operation() + active_here = self._in_operation() or self._in_paid_operation() on_runtime_thread = self._bridge.is_current_thread() with self._state_changed: while self._state == "starting": @@ -520,6 +945,8 @@ def close(self) -> None: if active_here: self._deferred_close = True return + while self._active_paid_operations: + self._state_changed.wait() self._finish_close() async def aclose(self) -> None: @@ -535,6 +962,135 @@ async def aclose(self) -> None: raise +class _CallerLoopRuntime(PaymentRuntime): + """Compatibility runtime for legacy async ``methods=`` entry points.""" + + def start(self) -> Self: + with self._state_changed: + if self._state == "new": + self._state = "open" + elif self._state != "open": + raise RuntimeError("PaymentRuntime is closed") + return self + + async def astart(self) -> Self: + return self.start() + + def _run_sync(self, coroutine: Coroutine[Any, Any, _T]) -> _T: + coroutine.close() + raise RuntimeError("Caller-loop payment runtime cannot be used synchronously") + + async def _run_async(self, coroutine: Coroutine[Any, Any, _T]) -> _T: + entered = False + try: + if not self._in_operation() and not self._in_paid_operation(): + self.start() + with self._runtime_operation(): + entered = True + return await coroutine + except BaseException: + if not entered: + coroutine.close() + raise + + +def _http_attempt_keys( + challenge: Challenge, + request: httpx.Request, +) -> tuple[str, str, bool]: + origin = repr(_httpx_origin(request.url)) + challenge_key = _http_attempt_digest("challenge", origin, challenge.id) + if operation_key := _http_idempotency_key(request): + idempotent = True + else: + idempotent = False + try: + body = request.content + except httpx.RequestNotRead: + body = b"" + operation_key = _http_attempt_digest( + "request", + request.method, + str(request.url).split("#", 1)[0], + hashlib.sha256(body).hexdigest(), + ) + return challenge_key, operation_key, idempotent + + +def _http_idempotency_key(request: httpx.Request) -> str | None: + if not (idempotency_key := request.headers.get("idempotency-key")): + return None + return _http_attempt_digest( + "idempotency", + request.method, + str(request.url).split("#", 1)[0], + idempotency_key, + ) + + +def _http_attempt_digest(*parts: str) -> str: + return hashlib.sha256("\0".join(parts).encode()).hexdigest() + + +def _compact_cause(cause: BaseException) -> BaseException: + try: + compact = type(cause)(str(cause)) + except BaseException: + compact = RuntimeError(f"{type(cause).__name__}: {cause}") + compact.__traceback__ = None + compact.__cause__ = None + compact.__context__ = None + return compact + + +def _compact_request(request: httpx.Request) -> httpx.Request: + return httpx.Request(request.method, request.url) + + +def _challenge_is_expired(challenge: Any) -> bool: + expires_value = getattr(challenge, "expires", None) + if expires_value is None: + return False + if not isinstance(expires_value, str) or not expires_value: + return True + try: + expires = datetime.fromisoformat(expires_value.replace("Z", "+00:00")) + if expires.tzinfo is None or expires.utcoffset() is None: + return True + return expires < datetime.now(UTC) + except (OverflowError, TypeError, ValueError): + return True + + +class _AllowedOrigins: + def __init__(self, allowed_origins: Sequence[str] | None) -> None: + self._allow_all = allowed_origins is None + self._origins = { + origin for value in allowed_origins or () if (origin := _origin(str(value))) is not None + } + + def http_url(self, url: httpx.URL) -> bool: + return self._allow_all or _httpx_origin(url) in self._origins + + +def _origin(value: str) -> tuple[str, str, int | None] | None: + try: + url = httpx.URL(value) + except (httpx.InvalidURL, TypeError, UnicodeError): + return None + if not url.scheme or not url.raw_host: + return None + return _httpx_origin(url) + + +def _httpx_origin(url: httpx.URL) -> tuple[str, str, int | None]: + scheme = url.scheme.casefold() + port = url.port + if port == {"http": 80, "https": 443}.get(scheme): + port = None + return scheme, url.raw_host.decode("ascii").casefold(), port + + def _is_method(value: Any) -> bool: intents = getattr(value, "intents", None) return ( diff --git a/tests/test_client.py b/tests/test_client.py index c13de90..79967ed 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,5 +1,8 @@ """Tests for client-side transport.""" +import asyncio +from http.cookies import SimpleCookie +from typing import Any, cast from unittest.mock import AsyncMock import httpx @@ -8,6 +11,8 @@ from mpp import Challenge, Credential from mpp.client import Client, PaymentTransport, get, post, request +from mpp.errors import PaymentError, PaymentOutcomeUnknownError +from mpp.runtime import PaymentRuntime from tests import make_credential @@ -43,6 +48,48 @@ async def aclose(self) -> None: pass +class ConsumingTransport(httpx.AsyncBaseTransport): + def __init__(self, responses: list[httpx.Response]) -> None: + self.responses = responses + self.bodies: list[bytes] = [] + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + stream = cast(httpx.AsyncByteStream, request.stream) + self.bodies.append(b"".join([chunk async for chunk in stream])) + return self.responses.pop(0) + + +class TrackingStream(httpx.AsyncByteStream): + def __init__(self, chunks: list[bytes]) -> None: + self.chunks = chunks + self.started = False + self.closed = False + + async def __aiter__(self): + self.started = True + for chunk in self.chunks: + yield chunk + + async def aclose(self) -> None: + self.closed = True + + +class BrokenTrackingStream(TrackingStream): + async def __aiter__(self): + self.started = True + raise httpx.ReadError("body lost") + yield b"" # pragma: no cover + + +def payment_challenge_header() -> str: + return Challenge( + id="test-id", + method="tempo", + intent="charge", + request={"amount": "1000"}, + ).to_www_authenticate("example.com") + + class TestPaymentTransport: @pytest.mark.asyncio async def test_passes_through_non_402(self) -> None: @@ -90,74 +137,322 @@ async def test_handles_402_with_matching_method(self) -> None: method.create_credential.assert_called_once() @pytest.mark.asyncio - async def test_paid_retry_replays_request_body(self) -> None: - """The paid retry must carry the original bytes request body. + async def test_paid_retry_applies_and_propagates_challenge_cookies(self) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if "authorization" not in request.headers: + return httpx.Response( + 402, + headers=[ + ("www-authenticate", payment_challenge_header()), + ("set-cookie", "session=new; Path=/"), + ("set-cookie", "payment_nonce=nonce-1; Path=/"), + ], + ) + return httpx.Response( + 200, + headers={"set-cookie": "final_cookie=ok; Path=/"}, + content=b"paid", + ) + + transport = PaymentTransport( + methods=[MockMethod()], + inner=httpx.MockTransport(handler), + ) + async with httpx.AsyncClient(transport=transport) as client: + client.cookies.set("session", "old", domain="example.com", path="/") + response = await client.get("https://example.com/paid") + + assert response.status_code == 200 + assert requests[0].headers["cookie"] == "session=old" + retry_cookies = SimpleCookie(requests[1].headers["cookie"]) + assert retry_cookies["session"].value == "new" + assert retry_cookies["payment_nonce"].value == "nonce-1" + assert dict(client.cookies) == { + "session": "new", + "payment_nonce": "nonce-1", + "final_cookie": "ok", + } + assert response.headers.get_list("set-cookie") == [ + "session=new; Path=/", + "payment_nonce=nonce-1; Path=/", + "final_cookie=ok; Path=/", + ] + + @pytest.mark.parametrize( + "set_cookie", + [ + "session=new; Path=/other", + "session=new; Domain=other.example; Path=/", + "session=new; Secure; Path=/", + "session=new; Max-Age=abc; Path=/", + "session=new; Domain=com; Path=/", + ], + ) + @pytest.mark.asyncio + async def test_inapplicable_challenge_cookie_preserves_existing_cookie( + self, + set_cookie: str, + ) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if len(requests) == 1: + return httpx.Response( + 402, + headers=[ + ("www-authenticate", payment_challenge_header()), + ("set-cookie", set_cookie), + ], + ) + return httpx.Response(200, content=b"paid") + + transport = PaymentTransport(methods=[MockMethod()], inner=httpx.MockTransport(handler)) + async with httpx.AsyncClient(transport=transport) as client: + client.cookies.set("session", "old", domain="example.com", path="/") + await client.get("http://example.com/paid") + + assert requests[1].headers["cookie"] == "session=old" + + @pytest.mark.parametrize( + ("url", "set_cookie", "expected"), + [ + ( + "https://example.com/paid", + "session=; Max-Age=0; Path=/", + "keep=yes", + ), + ( + "https://example.com/paid", + "session=; Max-Age=0; Domain=example.com; Path=/", + "keep=yes", + ), + ( + "https://example.com/paid", + "session=; Max-Age=0; Domain=.example.com; Path=/", + "keep=yes", + ), + ( + "https://example.com/a%2Fb/c", + "session=; Max-Age=0; Path=/a%2Fb", + "keep=yes", + ), + ( + "https://example.com/a%2Fb/c", + "session=; Max-Age=0; Path=/a/b", + "session=old; keep=yes", + ), + ], + ) + def test_challenge_cookie_deletion_respects_scope( + self, + url: str, + set_cookie: str, + expected: str, + ) -> None: + from mpp.client.transport import _apply_response_cookies + + source = httpx.Request("GET", url) + target = httpx.Request( + "GET", + source.url, + headers={"cookie": "session=old; keep=yes"}, + ) + response = httpx.Response( + 402, + headers={"set-cookie": set_cookie}, + request=source, + ) + + _apply_response_cookies(response, source, target) + + assert target.headers["cookie"] == expected + + @pytest.mark.parametrize("terminal", ["complete", "error", "close"]) + @pytest.mark.asyncio + async def test_paid_stream_releases_attempt_at_every_terminal( + self, + terminal: str, + ) -> None: + stream: TrackingStream = ( + BrokenTrackingStream([]) if terminal == "error" else TrackingStream([b"paid"]) + ) + transport = PaymentTransport( + methods=[MockMethod()], + inner=MockTransport( + [ + httpx.Response( + 402, + headers={"www-authenticate": payment_challenge_header()}, + ), + httpx.Response(200, stream=stream), + ] + ), + ) + response = await transport.handle_async_request(httpx.Request("GET", "https://example.com")) + outcome_stream = cast(Any, response.stream) + assert outcome_stream._runtime is not None + assert outcome_stream._attempt is not None + try: + if terminal == "complete": + assert await response.aread() == b"paid" + elif terminal == "error": + with pytest.raises(httpx.ReadError, match="body lost"): + await response.aread() + else: + await response.aclose() + assert outcome_stream._runtime is None + assert outcome_stream._attempt is None + finally: + await response.aclose() + await transport.aclose() + + @pytest.mark.asyncio + async def test_http_preserves_name_only_method_matching(self) -> None: + class ExplicitMethod(MockMethod): + _intents = {"charge": True} - Regression: the retry previously reused the already-consumed request - stream, so a POST body was dropped on the (paying) retry. - """ challenge = Challenge( id="test-id", method="tempo", - intent="charge", - request={"amount": "1000"}, + intent="subscription", + request={}, ) - www_auth = challenge.to_www_authenticate("example.com") - inner = MockTransport( [ - httpx.Response(402, headers={"www-authenticate": www_auth}), - httpx.Response(200, content=b'{"data": "ok"}'), + httpx.Response( + 402, + headers={"www-authenticate": challenge.to_www_authenticate("example.com")}, + ), + httpx.Response(200, content=b"paid"), ] ) - transport = PaymentTransport(methods=[MockMethod()], inner=inner) + method = ExplicitMethod() + transport = PaymentTransport(methods=[method], inner=inner) - body = b'{"prompt": "expensive question"}' - request = httpx.Request("POST", "https://example.com", content=body) - await transport.handle_async_request(request) + response = await transport.handle_async_request(httpx.Request("GET", "https://example.com")) - assert len(inner.requests) == 2 - retry_request = inner.requests[1] - assert retry_request.content == body - assert retry_request.headers["content-length"] == str(len(body)) + assert response.status_code == 200 + method.create_credential.assert_awaited_once() @pytest.mark.asyncio - async def test_paid_retry_replays_multipart_body(self) -> None: - """The paid retry must carry the original multipart (files=) body.""" + async def test_implicit_runtime_preserves_method_caller_loop(self) -> None: + caller_loop = asyncio.get_running_loop() + caller_future: asyncio.Future[None] = caller_loop.create_future() + method = MockMethod() + + async def create_credential(challenge: Challenge) -> Credential: + await caller_future + return make_credential({"hash": "0xabc"}, challenge_id=challenge.id) + + method.create_credential.side_effect = create_credential + caller_loop.call_later(0.01, caller_future.set_result, None) + transport = PaymentTransport( + methods=[method], + inner=MockTransport( + [ + httpx.Response( + 402, + headers={"www-authenticate": payment_challenge_header()}, + ), + httpx.Response(200), + ] + ), + ) + try: + response = await transport.handle_async_request( + httpx.Request("GET", "https://example.com") + ) + finally: + await transport.aclose() + + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_delegates_payment_matching_to_runtime(self) -> None: + """Should use an injected runtime for payment matching and credentials.""" challenge = Challenge( id="test-id", method="tempo", intent="charge", request={"amount": "1000"}, ) - www_auth = challenge.to_www_authenticate("example.com") - inner = MockTransport( [ - httpx.Response(402, headers={"www-authenticate": www_auth}), + httpx.Response( + 402, + headers={"www-authenticate": challenge.to_www_authenticate("example.com")}, + ), httpx.Response(200, content=b'{"data": "ok"}'), ] ) - transport = PaymentTransport(methods=[MockMethod()], inner=inner) - file_content = b"hello from file" - request = httpx.Request( - "POST", - "https://example.com", - files={"upload": ("report.txt", file_content, "text/plain")}, + method = MockMethod() + runtime = PaymentRuntime([method]) + transport = PaymentTransport(inner=inner, runtime=runtime) + + response = await transport.handle_async_request(httpx.Request("GET", "https://example.com")) + + assert response.status_code == 200 + assert inner.requests[1].headers["Authorization"].startswith("Payment ") + method.create_credential.assert_called_once() + + def test_rejects_runtime_with_methods_or_events(self) -> None: + runtime = PaymentRuntime([]) + + with pytest.raises(ValueError, match="either methods/events or runtime"): + PaymentTransport(methods=[], runtime=runtime) + with pytest.raises(ValueError, match="either methods/events or runtime"): + PaymentTransport(events=runtime.events, runtime=runtime) + + @pytest.mark.asyncio + async def test_runtime_transport_blocks_disallowed_origin(self) -> None: + inner = MockTransport( + [httpx.Response(402, headers={"www-authenticate": payment_challenge_header()})] ) - await transport.handle_async_request(request) + method = MockMethod() + runtime = PaymentRuntime([method], allowed_origins=["https://other.example"]) - assert len(inner.requests) == 2 - initial_body = inner.requests[0].content - retry_body = inner.requests[1].content - assert retry_body == initial_body - assert b"hello from file" in retry_body - assert b"report.txt" in retry_body + response = await runtime.payment_transport(inner).handle_async_request( + httpx.Request("GET", "https://example.com/paid") + ) + + assert response.status_code == 402 + method.create_credential.assert_not_called() @pytest.mark.asyncio - async def test_paid_retry_replays_put_body(self) -> None: - """The paid retry must carry the original body for PUT requests.""" + async def test_exact_https_origin_does_not_authorize_http(self) -> None: + inner = MockTransport( + [httpx.Response(402, headers={"www-authenticate": payment_challenge_header()})] + ) + method = MockMethod() + runtime = PaymentRuntime([method], allowed_origins=["https://example.com"]) + + response = await runtime.payment_transport(inner).handle_async_request( + httpx.Request("GET", "http://example.com/paid") + ) + + assert response.status_code == 402 + method.create_credential.assert_not_called() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("http_method", "url"), + [ + ("POST", "https://example.com"), + ("PUT", "https://example.com/item/1"), + ("PATCH", "https://example.com/item/1"), + ], + ) + async def test_paid_retry_replays_request_body( + self, + http_method: str, + url: str, + ) -> None: + """The paid retry must carry the original buffered request body.""" challenge = Challenge( id="test-id", method="tempo", @@ -169,24 +464,24 @@ async def test_paid_retry_replays_put_body(self) -> None: inner = MockTransport( [ httpx.Response(402, headers={"www-authenticate": www_auth}), - httpx.Response(200, content=b'{"updated": true}'), + httpx.Response(200, content=b'{"data": "ok"}'), ] ) transport = PaymentTransport(methods=[MockMethod()], inner=inner) - body = b'{"name": "updated"}' - request = httpx.Request("PUT", "https://example.com/item/1", content=body) + body = b'{"prompt": "expensive question"}' + request = httpx.Request(http_method, url, content=body) await transport.handle_async_request(request) assert len(inner.requests) == 2 retry_request = inner.requests[1] - assert retry_request.method == "PUT" + assert retry_request.method == http_method assert retry_request.content == body assert retry_request.headers["content-length"] == str(len(body)) @pytest.mark.asyncio - async def test_paid_retry_replays_patch_body(self) -> None: - """The paid retry must carry the original body for PATCH requests.""" + async def test_paid_retry_replays_multipart_body(self) -> None: + """The paid retry must carry the original multipart (files=) body.""" challenge = Challenge( id="test-id", method="tempo", @@ -195,59 +490,82 @@ async def test_paid_retry_replays_patch_body(self) -> None: ) www_auth = challenge.to_www_authenticate("example.com") - inner = MockTransport( + inner = ConsumingTransport( [ httpx.Response(402, headers={"www-authenticate": www_auth}), - httpx.Response(200, content=b'{"patched": true}'), + httpx.Response(200, content=b'{"data": "ok"}'), ] ) transport = PaymentTransport(methods=[MockMethod()], inner=inner) - body = b'{"status": "active"}' - request = httpx.Request("PATCH", "https://example.com/item/1", content=body) + file_content = b"hello from file" + request = httpx.Request( + "POST", + "https://example.com", + files={"upload": ("report.txt", file_content, "text/plain")}, + ) await transport.handle_async_request(request) - assert len(inner.requests) == 2 - retry_request = inner.requests[1] - assert retry_request.method == "PATCH" - assert retry_request.content == body - assert retry_request.headers["content-length"] == str(len(body)) + assert len(inner.bodies) == 2 + initial_body, retry_body = inner.bodies + assert retry_body == initial_body + assert b"hello from file" in retry_body + assert b"report.txt" in retry_body @pytest.mark.asyncio - async def test_paid_retry_raises_for_streaming_body(self) -> None: - """Should raise PaymentError upfront for async generator (streaming) bodies. + async def test_free_async_generator_body_passes_through(self) -> None: + received: list[bytes] = [] - Streaming bodies cannot be reliably buffered and replayed: the generator - may be infinite, already partially consumed, or tied to a one-shot source. - A descriptive error is better than a silent empty body on the paid retry. - """ - from mpp.errors import PaymentError + async def handler(request: httpx.Request) -> httpx.Response: + stream = cast(httpx.AsyncByteStream, request.stream) + received.append(b"".join([chunk async for chunk in stream])) + return httpx.Response(200, content=b"ok") - challenge = Challenge( - id="test-id", - method="tempo", - intent="charge", - request={"amount": "1000"}, - ) - www_auth = challenge.to_www_authenticate("example.com") + async def body(): + yield b"one-" + yield b"shot" - inner = MockTransport( - [ - httpx.Response(402, headers={"www-authenticate": www_auth}), - httpx.Response(200, content=b'{"data": "ok"}'), - ] + transport = PaymentTransport( + methods=[MockMethod()], + inner=httpx.MockTransport(handler), + ) + response = await transport.handle_async_request( + httpx.Request("POST", "https://example.com", content=body()) ) - transport = PaymentTransport(methods=[MockMethod()], inner=inner) - async def async_body_gen(): - yield b"chunk1" - yield b"chunk2" + assert response.status_code == 200 + assert received == [b"one-shot"] - request = httpx.Request("POST", "https://example.com", content=async_body_gen()) - with pytest.raises(PaymentError, match="Streaming request bodies"): - await transport.handle_async_request(request) + @pytest.mark.asyncio + async def test_paid_async_generator_fails_after_first_send(self) -> None: + requests: list[httpx.Request] = [] + response_stream = TrackingStream([b"payment explanation"]) - assert len(inner.requests) == 0 + class OneShotTransport(httpx.AsyncBaseTransport): + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + requests.append(request) + stream = cast(httpx.AsyncByteStream, request.stream) + _ = b"".join([chunk async for chunk in stream]) + return httpx.Response( + 402, + headers={"www-authenticate": payment_challenge_header()}, + stream=response_stream, + ) + + async def body(): + yield b"one-shot" + + transport = PaymentTransport( + methods=[MockMethod()], + inner=OneShotTransport(), + ) + with pytest.raises(PaymentError, match="cannot be replayed"): + await transport.handle_async_request( + httpx.Request("POST", "https://example.com", content=body()) + ) + + assert len(requests) == 1 + assert response_stream.closed is True @pytest.mark.asyncio async def test_emits_client_payment_events(self) -> None: @@ -395,9 +713,74 @@ async def test_returns_402_when_no_matching_method(self) -> None: assert payload["credential"] is None assert isinstance(payload["error"], ValueError) assert payload["method"] is None + assert payload["protocol"] == "http" assert payload["request"] is request assert payload["response"] is response + @pytest.mark.asyncio + async def test_skips_expired_offer_when_later_compatible_offer_is_valid(self) -> None: + expired = Challenge( + id="expired", + method="tempo", + intent="charge", + request={}, + expires="2000-01-01T00:00:00Z", + ) + current = Challenge( + id="current", + method="tempo", + intent="charge", + request={}, + ) + method = MockMethod() + inner = MockTransport( + [ + httpx.Response( + 402, + headers=[ + ("www-authenticate", expired.to_www_authenticate("example.com")), + ("www-authenticate", current.to_www_authenticate("example.com")), + ], + ), + httpx.Response(200, content=b"paid"), + ] + ) + received: list[str] = [] + transport = PaymentTransport(methods=[method], inner=inner) + transport.on_challenge_received( + lambda payload: received.extend(item.id for item in payload["challenges"]) + ) + response = await transport.handle_async_request(httpx.Request("GET", "https://example.com")) + + assert response.status_code == 200 + assert method.create_credential.await_args is not None + assert method.create_credential.await_args.args[0].id == "current" + assert received == ["expired", "current"] + + @pytest.mark.asyncio + async def test_unmatched_redirect_reports_challenged_request(self) -> None: + original_request = httpx.Request("GET", "https://example.com/start") + challenged_request = httpx.Request("GET", "https://redirected.example/paid") + response = httpx.Response( + 402, + headers={ + "www-authenticate": Challenge( + id="test-id", + method="stripe", + intent="charge", + request={}, + ).to_www_authenticate("redirected.example") + }, + request=challenged_request, + ) + transport = PaymentTransport(methods=[MockMethod()], inner=MockTransport([response])) + failed: list[dict[str, Any]] = [] + transport.on_payment_failed(failed.append) + + await transport.handle_async_request(original_request) + + assert failed[0]["request"] is challenged_request + @pytest.mark.asyncio async def test_returns_402_without_payment_header(self) -> None: """Should return 402 if no Payment WWW-Authenticate header.""" @@ -414,6 +797,88 @@ async def test_returns_402_without_payment_header(self) -> None: assert response.status_code == 402 + @pytest.mark.parametrize( + "www_authenticate", + [ + pytest.param("Bearer realm=test", id="non-payment"), + pytest.param("Payment invalid-base64!!", id="malformed"), + pytest.param( + Challenge( + id="test-id", + method="stripe", + intent="charge", + request={}, + ).to_www_authenticate("example.com"), + id="unsupported", + ), + pytest.param( + Challenge( + id="test-id", + method="tempo", + intent="charge", + request={}, + expires="2020-01-01T00:00:00Z", + ).to_www_authenticate("example.com"), + id="expired", + ), + ], + ) + @pytest.mark.asyncio + async def test_nonpayable_402_body_remains_lazy( + self, + www_authenticate: str, + ) -> None: + stream = TrackingStream([b"explanation"]) + response = httpx.Response( + 402, + headers={"www-authenticate": www_authenticate}, + stream=stream, + ) + transport = PaymentTransport( + methods=[MockMethod()], + inner=MockTransport([response]), + ) + try: + returned = await transport.handle_async_request( + httpx.Request("GET", "https://example.com") + ) + assert returned is response + assert stream.started is False + assert stream.closed is False + assert await returned.aread() == b"explanation" + assert stream.started is True + assert stream.closed is True + finally: + await transport.aclose() + + @pytest.mark.asyncio + async def test_disallowed_402_body_remains_lazy(self) -> None: + stream = TrackingStream([b"explanation"]) + response = httpx.Response( + 402, + headers={"www-authenticate": payment_challenge_header()}, + stream=stream, + ) + runtime = PaymentRuntime( + [MockMethod()], + allowed_origins=["https://allowed.example"], + ) + transport = PaymentTransport( + runtime=runtime, + inner=MockTransport([response]), + ) + try: + returned = await transport.handle_async_request( + httpx.Request("GET", "https://disallowed.example") + ) + assert returned is response + assert stream.started is False + assert stream.closed is False + finally: + await response.aclose() + await transport.aclose() + await runtime.aclose() + @pytest.mark.asyncio async def test_returns_402_on_parse_error(self) -> None: """Should return 402 if challenge cannot be parsed.""" @@ -430,6 +895,48 @@ async def test_returns_402_on_parse_error(self) -> None: assert response.status_code == 402 + @pytest.mark.asyncio + async def test_explicit_runtime_survives_payment_event_abort(self) -> None: + class EventAbort(BaseException): + pass + + async def abort(_payload: Any) -> None: + raise EventAbort + + stream = TrackingStream([b"payment explanation"]) + runtime = PaymentRuntime([MockMethod()]) + transport = PaymentTransport( + runtime=runtime, + inner=MockTransport( + [ + httpx.Response( + 402, + headers={ + "www-authenticate": Challenge( + id="test-id", + method="stripe", + intent="charge", + request={}, + ).to_www_authenticate("example.com") + }, + stream=stream, + ) + ] + ), + ) + transport.on_payment_failed(abort) + + try: + with pytest.raises(EventAbort): + await transport.handle_async_request(httpx.Request("GET", "https://example.com")) + + await asyncio.sleep(0.01) + await runtime.emit_event("portal.reused", {}) + assert stream.closed is True + finally: + await transport.aclose() + await runtime.aclose() + @pytest.mark.asyncio async def test_aclose(self) -> None: """Should close inner transport.""" @@ -499,6 +1006,62 @@ async def test_handles_multiple_www_authenticate_headers(self) -> None: assert len(inner.requests) == 2 method.create_credential.assert_called_once() + @pytest.mark.asyncio + async def test_handles_comma_combined_authentication_challenges(self) -> None: + payment = Challenge( + id="test-id", + method="tempo", + intent="charge", + request={"description": "one,two"}, + description='quoted, comma and \\"escape', + ).to_www_authenticate("example.com") + inner = MockTransport( + [ + httpx.Response( + 402, + headers={ + "www-authenticate": ( + 'Bearer realm="legacy", error="expired", ' + f'{payment}, Basic realm="fallback"' + ) + }, + ), + httpx.Response(200, content=b"paid"), + ] + ) + method = MockMethod() + transport = PaymentTransport(methods=[method], inner=inner) + + response = await transport.handle_async_request(httpx.Request("GET", "https://example.com")) + + assert response.status_code == 200 + method.create_credential.assert_awaited_once() + + @pytest.mark.asyncio + async def test_falsy_method_still_handles_payment(self) -> None: + class FalsyMethod(MockMethod): + def __bool__(self) -> bool: + return False + + method = FalsyMethod() + transport = PaymentTransport( + methods=[method], + inner=MockTransport( + [ + httpx.Response( + 402, + headers={"www-authenticate": payment_challenge_header()}, + ), + httpx.Response(200, content=b"paid"), + ] + ), + ) + + response = await transport.handle_async_request(httpx.Request("GET", "https://example.com")) + + assert response.status_code == 200 + method.create_credential.assert_awaited_once() + @pytest.mark.asyncio async def test_does_not_retry_when_method_rejects_challenge(self) -> None: """Should not send an Authorization retry when the method rejects the challenge.""" @@ -567,15 +1130,19 @@ async def test_emits_payment_failed_when_retry_raises(self) -> None: intent="charge", request={"amount": "1000"}, ) + challenge_response = httpx.Response( + 402, + headers=[ + ("www-authenticate", challenge.to_www_authenticate("example.com")), + ("set-cookie", "payment_nonce=nonce-1; Path=/"), + ], + ) class FailingRetryTransport(MockTransport): async def handle_async_request(self, request: httpx.Request) -> httpx.Response: self.requests.append(request) if len(self.requests) == 1: - return httpx.Response( - 402, - headers={"www-authenticate": challenge.to_www_authenticate("example.com")}, - ) + return challenge_response raise RuntimeError("network failed") transport = PaymentTransport(methods=[MockMethod()], inner=FailingRetryTransport([])) @@ -585,10 +1152,155 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response: ) ) - with pytest.raises(RuntimeError, match="network failed"): + with pytest.raises(PaymentOutcomeUnknownError) as exc_info: + await transport.handle_async_request(httpx.Request("GET", "https://example.com")) + + assert isinstance(exc_info.value.__cause__, RuntimeError) + assert events == ["failed:test-id:PaymentOutcomeUnknownError"] + assert "authorization" not in challenge_response.request.headers + + @pytest.mark.asyncio + async def test_repeated_402_after_credential_has_unknown_outcome(self) -> None: + method = MockMethod() + inner = MockTransport( + [ + httpx.Response(402, headers={"www-authenticate": payment_challenge_header()}), + httpx.Response(402, headers={"www-authenticate": payment_challenge_header()}), + ] + ) + transport = PaymentTransport(methods=[method], inner=inner) + + with pytest.raises(PaymentOutcomeUnknownError, match="Do not blindly retry"): + await transport.handle_async_request(httpx.Request("GET", "https://example.com")) + + assert len(inner.requests) == 2 + method.create_credential.assert_awaited_once() + + @pytest.mark.parametrize("race", ["operation", "circuit"]) + @pytest.mark.asyncio + async def test_send_boundary_unknown_emits_payment_failed(self, race: str) -> None: + from mpp.runtime import _HTTPX_OPERATIONS + + runtime = PaymentRuntime([MockMethod()], max_unknown_outcomes=1) + request = httpx.Request("GET", "https://example.com") + + def trip_guard(_payload: Any) -> None: + token = _HTTPX_OPERATIONS.set(None) + try: + blockers = ( + [("blocker", request.url)] + if race == "operation" + else [ + ("blocker-1", httpx.URL("https://example.com/1")), + ("blocker-2", httpx.URL("https://example.com/2")), + ] + ) + for identifier, url in blockers: + blocker_request = httpx.Request("GET", url) + blocker = runtime._begin_http_payment( + Challenge( + id=identifier, + method="tempo", + intent="charge", + request={}, + ), + blocker_request, + ) + runtime._mark_http_payment_sent(blocker, blocker_request) + runtime._mark_http_payment_unknown(blocker, TimeoutError("lost")) + finally: + _HTTPX_OPERATIONS.reset(token) + + events: list[str] = [] + runtime.events.on("credential.created", trip_guard) + runtime.events.on("*", lambda event: events.append(event.name)) + transport = PaymentTransport( + runtime=runtime, + inner=MockTransport( + [ + httpx.Response( + 402, + headers={"www-authenticate": payment_challenge_header()}, + ) + ] + ), + ) + + with pytest.raises(PaymentOutcomeUnknownError): + await transport.handle_async_request(request) + + assert events == ["challenge.received", "credential.created", "payment.failed"] + runtime.close() + + @pytest.mark.parametrize("status_code", [200, 402, 503]) + @pytest.mark.asyncio + async def test_event_abort_closes_unreturned_paid_response( + self, + status_code: int, + ) -> None: + class EventAbort(BaseException): + pass + + def abort(_payload: Any) -> None: + raise EventAbort + + stream = TrackingStream([b"paid response"]) + transport = PaymentTransport( + methods=[MockMethod()], + inner=MockTransport( + [ + httpx.Response( + 402, + headers={"www-authenticate": payment_challenge_header()}, + ), + httpx.Response(status_code, stream=stream), + ] + ), + ) + if status_code == 200: + transport.on_payment_response(abort) + else: + transport.on_payment_failed(abort) + + with pytest.raises(EventAbort): await transport.handle_async_request(httpx.Request("GET", "https://example.com")) - assert events == ["failed:test-id:RuntimeError"] + assert stream.closed is True + + @pytest.mark.asyncio + async def test_runtime_close_waits_for_committed_retry_and_response_event(self) -> None: + retry_started = asyncio.Event() + retry_release = asyncio.Event() + calls = 0 + + async def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + if calls == 1: + return httpx.Response( + 402, + headers={"www-authenticate": payment_challenge_header()}, + ) + retry_started.set() + await retry_release.wait() + return httpx.Response(200, content=b"paid") + + runtime = PaymentRuntime([MockMethod()]) + transport = PaymentTransport(runtime=runtime, inner=httpx.MockTransport(handler)) + request_task = asyncio.create_task( + transport.handle_async_request(httpx.Request("GET", "https://example.com")) + ) + await asyncio.wait_for(retry_started.wait(), 1) + close_task = asyncio.create_task(asyncio.to_thread(runtime.close)) + await asyncio.sleep(0.01) + assert not close_task.done() + + retry_release.set() + response = await asyncio.wait_for(request_task, 1) + await asyncio.wait_for(close_task, 1) + await transport.aclose() + + assert response.status_code == 200 class TestClient: diff --git a/tests/test_errors.py b/tests/test_errors.py index ad1e75b..8d1c215 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -2,6 +2,7 @@ import pytest +from mpp import PaymentOutcomeUnknownError as PublicPaymentOutcomeUnknownError from mpp.errors import ( BadRequestError, InvalidChallengeError, @@ -12,11 +13,18 @@ PaymentExpiredError, PaymentInsufficientError, PaymentMethodUnsupportedError, + PaymentOutcomeUnknownError, PaymentRequiredError, VerificationFailedError, ) +def test_unknown_outcome_preserves_runtime_error_compatibility() -> None: + assert PublicPaymentOutcomeUnknownError is PaymentOutcomeUnknownError + assert issubclass(PaymentOutcomeUnknownError, PaymentError) + assert issubclass(PaymentOutcomeUnknownError, RuntimeError) + + class TestToProblemDetails: def test_base_error_includes_required_fields(self) -> None: """to_problem_details() should include type, title, status, detail.""" @@ -74,6 +82,7 @@ class TestAutoSlug: (VerificationFailedError, "/verification-failed"), (PaymentExpiredError, "/payment-expired"), (PaymentInsufficientError, "/payment-insufficient"), + (PaymentOutcomeUnknownError, "/payment-outcome-unknown"), (PaymentActionRequiredError, "/payment-action-required"), (PaymentRequiredError, "/payment-required"), ], @@ -94,6 +103,7 @@ class TestAutoTitle: (PaymentExpiredError, "Payment Expired"), (PaymentInsufficientError, "Payment Insufficient"), (PaymentMethodUnsupportedError, "Method Unsupported"), + (PaymentOutcomeUnknownError, "Payment Outcome Unknown"), (PaymentActionRequiredError, "Payment Action Required"), (PaymentRequiredError, "Payment Required"), (BadRequestError, "Bad Request"), diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 0f3e6c7..785c138 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -10,9 +10,10 @@ from types import MappingProxyType from typing import Any +import httpx import pytest -from mpp import Challenge, Credential +from mpp import Challenge, Credential, PaymentOutcomeUnknownError from mpp.runtime import Method, PaymentRuntime @@ -584,3 +585,200 @@ class StripeMethod(MockMethod): is tempo ) runtime.close() + + +class TestHttpRuntimePolicy: + @pytest.mark.parametrize( + ("allowed", "url"), + [ + ("HTTPS://EXAMPLE.COM:443/path", "https://example.com/resource"), + ("https://[2001:DB8::1]:443/path", "https://[2001:db8::1]/resource"), + ("https://bücher.example", "https://xn--bcher-kva.example/resource"), + ("https://xn--bcher-kva.example", "https://bücher.example/resource"), + ], + ) + def test_allowed_origins_are_normalized(self, allowed: str, url: str) -> None: + runtime = PaymentRuntime([], allowed_origins=[allowed]) + + assert runtime.allows_http_payment(httpx.URL(url)) + runtime.close() + + def test_non_origin_allowlist_entries_fail_closed(self) -> None: + runtime = PaymentRuntime([], allowed_origins=["example.com", "not a URL"]) + + assert not runtime.allows_http_payment(httpx.URL("https://example.com")) + runtime.close() + + +class TestHttpRuntimeSafety: + @pytest.mark.parametrize("value", [0, -1, True, 1.5]) + def test_unknown_outcome_limit_must_be_positive(self, value: Any) -> None: + with pytest.raises(ValueError, match="positive integer"): + PaymentRuntime([], max_unknown_outcomes=value) + + def test_unknown_outcomes_trip_bounded_fail_closed_circuit(self) -> None: + runtime = PaymentRuntime([], max_unknown_outcomes=2) + + for index in range(3): + offered = challenge(f"unknown-{index}") + request = httpx.Request( + "POST", + f"https://example.com/pay/{index}", + content=str(index), + ) + attempt = runtime._begin_http_payment(offered, request) + runtime._mark_http_payment_sent(attempt, request) + runtime._mark_http_payment_unknown(attempt, TimeoutError("response lost")) + + assert len(runtime._http_unknown_challenges) == 2 + assert len(runtime._http_unknown_operations) == 2 + assert runtime._http_unknown_circuit is not None + + fresh = challenge("fresh") + with pytest.raises(PaymentOutcomeUnknownError, match="outcome is unknown"): + runtime._begin_http_payment( + fresh, + httpx.Request("POST", "https://example.com/pay/fresh"), + ) + with pytest.raises(ValueError, match="externally reconciled"): + runtime.reset_unknown_outcomes(reconciled=False) + + runtime.reset_unknown_outcomes(reconciled=True) + + assert not runtime._http_unknown_challenges + assert not runtime._http_unknown_operations + assert runtime._http_unknown_circuit is None + attempt = runtime._begin_http_payment( + fresh, + httpx.Request("POST", "https://example.com/pay/fresh"), + ) + runtime._discard_http_payment(attempt) + runtime.close() + + def test_close_preserves_sent_attempt_markers(self) -> None: + offered = challenge("closing") + request = httpx.Request("POST", "https://example.com/pay") + retry = httpx.Request("POST", "https://example.com/pay") + runtime = PaymentRuntime([]) + attempt = runtime._begin_http_payment(offered, request) + runtime._mark_http_payment_sent(attempt, retry) + + runtime.close() + + replacement = PaymentRuntime([]) + try: + with pytest.raises(PaymentOutcomeUnknownError, match="outcome is unknown"): + replacement._begin_http_payment(offered, request) + with pytest.raises(PaymentOutcomeUnknownError, match="outcome is unknown"): + replacement._begin_http_payment(offered, retry) + finally: + replacement.close() + + def test_attempt_markers_clear_after_success_and_discard(self) -> None: + runtime = PaymentRuntime([]) + request = httpx.Request("POST", "https://example.com/pay") + retry = httpx.Request("POST", "https://example.com/pay") + attempt = runtime._begin_http_payment(challenge("marker"), request) + runtime._mark_http_payment_sent(attempt, retry) + + runtime._mark_http_response_body_complete(attempt) + runtime._mark_http_send_complete(attempt) + + assert "mpp.payment_attempt" not in request.extensions + assert "mpp.payment_attempt" not in retry.extensions + + discarded = runtime._begin_http_payment( + challenge("discard"), + httpx.Request("POST", "https://example.com/other"), + ) + discarded.request.extensions["mpp.payment_attempt"] = discarded + runtime._discard_http_payment(discarded) + assert "mpp.payment_attempt" not in discarded.request.extensions + runtime.close() + + def test_colliding_unknown_operations_keep_each_challenge(self) -> None: + runtime = PaymentRuntime([]) + first_attempt = runtime._begin_http_payment( + challenge("first"), + httpx.Request("POST", "https://example.com/pay", content=b"same"), + ) + second_attempt = runtime._begin_http_payment( + challenge("second"), + httpx.Request("POST", "https://example.com/pay", content=b"same"), + ) + runtime._mark_http_payment_sent(first_attempt, first_attempt.request) + runtime._mark_http_payment_sent(second_attempt, second_attempt.request) + first_credential = Credential( + challenge=first_attempt.challenge.to_echo(), + payload={"payment": "first"}, + ) + second_credential = Credential( + challenge=second_attempt.challenge.to_echo(), + payload={"payment": "second"}, + ) + runtime._set_http_payment_credential(first_attempt, first_credential) + runtime._set_http_payment_credential(second_attempt, second_credential) + first = runtime._mark_http_payment_unknown(first_attempt, TimeoutError("first lost")) + second = runtime._mark_http_payment_unknown(second_attempt, TimeoutError("second lost")) + + assert len(runtime._http_unknown_operations) == 1 + assert len(runtime._http_unknown_challenges) == 2 + assert (first.challenge.id, first.credential, str(first.cause)) == ( + "first", + first_credential, + "first lost", + ) + assert (second.challenge.id, second.credential, str(second.cause)) == ( + "second", + second_credential, + "second lost", + ) + assert first_attempt.request.extensions["mpp.payment_attempt"] is first + assert second_attempt.request.extensions["mpp.payment_attempt"] is second + assert not runtime._http_challenges + runtime.close() + + def test_unknown_circuit_blocks_attempt_committed_after_it_trips(self) -> None: + runtime = PaymentRuntime([], max_unknown_outcomes=1) + late, first, second = ( + runtime._begin_http_payment( + challenge(identifier), + httpx.Request("POST", f"https://example.com/{identifier}"), + ) + for identifier in ("late", "first", "second") + ) + for attempt in (first, second): + runtime._mark_http_payment_sent(attempt, attempt.request) + runtime._mark_http_payment_unknown( + attempt, TimeoutError(f"{attempt.challenge.id} lost") + ) + + with pytest.raises(PaymentOutcomeUnknownError): + runtime._mark_http_payment_sent(late, late.request) + + assert runtime._http_unknown_circuit is not None + assert not late.sent + assert late.challenge_key not in runtime._http_challenges + runtime.close() + + def test_unknown_tombstone_drops_body_and_traceback(self) -> None: + runtime = PaymentRuntime([]) + offered = challenge("unknown") + request = httpx.Request("POST", "https://example.com/pay", content=b"x" * 4096) + retry = httpx.Request("POST", "https://example.com/pay", content=request.content) + attempt = runtime._begin_http_payment(offered, request) + runtime._mark_http_payment_sent(attempt, retry) + try: + raise httpx.ReadTimeout("response lost", request=request) + except httpx.ReadTimeout as cause: + assert cause.__traceback__ is not None + tombstone = runtime._mark_http_payment_unknown(attempt, cause) + + assert isinstance(tombstone.cause, httpx.ReadTimeout) + assert tombstone.cause.__traceback__ is None + assert tombstone.request is not request + assert tombstone.request.content == b"" + assert request.extensions["mpp.payment_attempt"] is tombstone + assert retry.extensions["mpp.payment_attempt"] is tombstone + assert not hasattr(tombstone, "runtime") + runtime.close() From bc806af4e3e336983f1a4a78e97cd6a833c042ba Mon Sep 17 00:00:00 2001 From: Parv Ahuja <17094219+parvahuja@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:51:03 -0700 Subject: [PATCH 08/11] feat: add synchronous payment transport --- .changelog/sync-payment-transport.md | 5 + README.md | 13 + src/mpp/client/__init__.py | 1 + src/mpp/client/sync_transport.py | 350 ++++++++++++++ src/mpp/runtime.py | 11 +- tests/test_sync_client.py | 666 +++++++++++++++++++++++++++ 6 files changed, 1045 insertions(+), 1 deletion(-) create mode 100644 .changelog/sync-payment-transport.md create mode 100644 src/mpp/client/sync_transport.py create mode 100644 tests/test_sync_client.py diff --git a/.changelog/sync-payment-transport.md b/.changelog/sync-payment-transport.md new file mode 100644 index 0000000..b680629 --- /dev/null +++ b/.changelog/sync-payment-transport.md @@ -0,0 +1,5 @@ +--- +pympp: minor +--- + +Add `SyncPaymentTransport` for payment-aware synchronous HTTPX clients. diff --git a/README.md b/README.md index cf253a4..d9bf22b 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,19 @@ raise `PaymentOutcomeUnknownError` instead of paying again. Reconcile those payments externally before calling `runtime.reset_unknown_outcomes(reconciled=True)`. +Synchronous HTTPX clients can use the same runtime: + +```python +import httpx + +with PaymentRuntime( + method_factories=[method_factory], + allowed_origins=["https://api.example.com"], +) as runtime: + with httpx.Client(transport=runtime.sync_payment_transport()) as client: + response = client.get("https://api.example.com/paid") +``` + ## Examples | Example | Description | diff --git a/src/mpp/client/__init__.py b/src/mpp/client/__init__.py index 2b000e2..2732e29 100644 --- a/src/mpp/client/__init__.py +++ b/src/mpp/client/__init__.py @@ -17,6 +17,7 @@ """ from mpp import _expires as Expires +from mpp.client.sync_transport import SyncPaymentTransport from mpp.client.transport import Client, PaymentTransport, get, post, request from mpp.events import ( CHALLENGE_RECEIVED, diff --git a/src/mpp/client/sync_transport.py b/src/mpp/client/sync_transport.py new file mode 100644 index 0000000..ad07c1a --- /dev/null +++ b/src/mpp/client/sync_transport.py @@ -0,0 +1,350 @@ +"""Synchronous payment-aware httpx transport.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import httpx + +from mpp.errors import PaymentError, PaymentOutcomeUnknownError +from mpp.events import PAYMENT_FAILED, PAYMENT_RESPONSE, EventDispatcher +from mpp.runtime import Method, PaymentRuntime, _challenge_is_expired + +from .transport import ( + _apply_response_cookies, + _bind_response_request, + _challenged_request, + _client_payment_failed_payload, + _copy_request, + _EventHandlers, + _match_http_challenge, + _OutcomeStream, + _payment_challenges, + _propagate_response_cookies, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + +logger = logging.getLogger(__name__) + + +def _close_response(response: httpx.Response) -> None: + try: + response.close() + except BaseException: + pass + + +class _SyncOutcomeStream(_OutcomeStream, httpx.SyncByteStream): + def __iter__(self): + try: + yield from self._stream + except BaseException as error: + self._mark_unknown(error) + raise + else: + self._mark_complete() + + def close(self) -> None: + try: + self._stream.close() + finally: + self._mark_unknown(RuntimeError("Paid response body was not fully consumed")) + + +class SyncPaymentTransport(_EventHandlers, httpx.BaseTransport): + """httpx transport that synchronously handles 402 payment challenges.""" + + def __init__( + self, + methods: Sequence[Method] | None = None, + inner: httpx.BaseTransport | None = None, + events: EventDispatcher | None = None, + *, + runtime: PaymentRuntime | None = None, + ) -> None: + self._owns_runtime = runtime is None + if runtime is not None: + if methods is not None or events is not None: + raise ValueError("Pass either methods/events or runtime, not both") + self._runtime = runtime + else: + if methods is None: + raise ValueError("Pass methods or runtime") + self._runtime = PaymentRuntime(methods, events=events) + self._inner = inner or httpx.HTTPTransport() + self._events = self._runtime.events + + def handle_request(self, request: httpx.Request) -> httpx.Response: + """Send a request and retry one 402 with a payment credential.""" + with self._runtime._httpx_operation_scope(request): + response = self._inner.handle_request(request) + return self._handle_response(request, response) + + def _handle_response( + self, + request: httpx.Request, + response: httpx.Response, + ) -> httpx.Response: + """Handle an already-dispatched response, retrying a payable 402.""" + if response.status_code != 402: + return response + + try: + challenged_request = _challenged_request(response, request) + if not self._runtime.allows_http_payment(challenged_request.url): + return response + + challenges, parse_error = _payment_challenges(response) + if challenges: + self._runtime.start() + + challenge, method = _match_http_challenge(self._runtime, challenges) + + if challenge is None or method is None: + if parse_error is not None or challenges: + self._runtime.emit_event_sync( + PAYMENT_FAILED, + _client_payment_failed_payload( + challenge=None, + challenges=challenges, + credential=None, + error=parse_error + or ValueError("No compatible payment method for challenges"), + method=None, + request=challenged_request, + response=response, + ), + ) + return response + + if _challenge_is_expired(challenge): + logger.warning("Challenge expired at %s, not paying", challenge.expires) + self._runtime.emit_event_sync( + PAYMENT_FAILED, + _client_payment_failed_payload( + challenge=challenge, + challenges=challenges, + credential=None, + error=ValueError(f"Challenge expired at {challenge.expires}"), + method=method, + request=challenged_request, + response=response, + ), + ) + return response + + try: + challenged_request.read() + except httpx.StreamConsumed as cause: + error = PaymentError( + "Streaming request bodies cannot be replayed after a payment challenge. " + "Use a buffered body for paid requests." + ) + self._runtime.emit_event_sync( + PAYMENT_FAILED, + _client_payment_failed_payload( + challenge=challenge, + challenges=challenges, + credential=None, + error=error, + method=method, + request=challenged_request, + response=response, + ), + ) + raise error from cause + + response.read() + except BaseException: + _close_response(response) + raise + + try: + attempt = self._runtime._begin_http_payment(challenge, challenged_request) + except PaymentOutcomeUnknownError as error: + self._runtime.emit_event_sync( + PAYMENT_FAILED, + _client_payment_failed_payload( + challenge=challenge, + challenges=challenges, + credential=error.credential, + error=error, + method=method, + request=challenged_request, + response=response, + ), + ) + raise + + try: + credential = self._runtime.create_credential_sync( + challenge, + method, + event_payload={ + "challenges": challenges, + "request": challenged_request, + "response": response, + "protocol": "http", + }, + ) + auth_header = credential.to_authorization() + except BaseException as error: + self._runtime._discard_http_payment(attempt) + if not isinstance(error, Exception): + raise + self._runtime.emit_event_sync( + PAYMENT_FAILED, + _client_payment_failed_payload( + challenge=challenge, + challenges=challenges, + credential=None, + error=error, + method=method, + request=challenged_request, + response=response, + ), + ) + raise + self._runtime._set_http_payment_credential(attempt, credential) + + try: + headers = httpx.Headers(challenged_request.headers) + headers["Authorization"] = auth_header + retry_request = _copy_request(challenged_request, headers=headers) + _apply_response_cookies(response, challenged_request, retry_request) + + with self._runtime._paid_operation(): + try: + self._runtime._mark_http_payment_sent(attempt, retry_request) + except PaymentOutcomeUnknownError as error: + self._runtime.emit_event_sync( + PAYMENT_FAILED, + _client_payment_failed_payload( + challenge=challenge, + challenges=challenges, + credential=credential, + error=error, + method=method, + request=challenged_request, + response=response, + ), + ) + raise + try: + payment_response = self._inner.handle_request(retry_request) + except BaseException as cause: + self._runtime._mark_http_payment_unknown(attempt, cause) + if not isinstance(cause, Exception): + raise + error = PaymentOutcomeUnknownError( + challenge, + cause, + credential=credential, + request=challenged_request, + ) + self._runtime.emit_event_sync( + PAYMENT_FAILED, + _client_payment_failed_payload( + challenge=challenge, + challenges=challenges, + credential=credential, + error=error, + method=method, + request=challenged_request, + response=response, + ), + ) + raise error from cause + + _bind_response_request(payment_response, retry_request) + _propagate_response_cookies(response, payment_response) + + try: + if payment_response.status_code == 402: + cause = RuntimeError( + "Server returned another payment challenge after receiving a credential" + ) + self._runtime._mark_http_payment_unknown(attempt, cause) + error = PaymentOutcomeUnknownError( + challenge, + cause, + credential=credential, + request=challenged_request, + ) + self._runtime.emit_event_sync( + PAYMENT_FAILED, + _client_payment_failed_payload( + challenge=challenge, + challenges=challenges, + credential=credential, + error=error, + method=method, + request=challenged_request, + response=payment_response, + ), + ) + raise error from cause + + if payment_response.status_code >= 400: + cause = RuntimeError( + f"Credentialed request returned HTTP {payment_response.status_code}" + ) + self._runtime._mark_http_payment_unknown(attempt, cause) + self._runtime.emit_event_sync( + PAYMENT_FAILED, + _client_payment_failed_payload( + challenge=challenge, + challenges=challenges, + credential=credential, + error=PaymentOutcomeUnknownError( + challenge, + cause, + credential=credential, + request=challenged_request, + ), + method=method, + request=challenged_request, + response=payment_response, + ), + ) + elif payment_response.is_stream_consumed: + self._runtime._mark_http_response_body_complete(attempt) + else: + payment_response.stream = _SyncOutcomeStream( + payment_response.stream, + self._runtime, + attempt, + ) + + if payment_response.is_success: + self._runtime.emit_event_sync( + PAYMENT_RESPONSE, + { + "challenge": challenge, + "challenges": challenges, + "credential": credential, + "method": method, + "request": challenged_request, + "response": payment_response, + "protocol": "http", + }, + ) + except BaseException: + _close_response(payment_response) + raise + return payment_response + except BaseException: + if not attempt.sent: + self._runtime._discard_http_payment(attempt) + raise + + def close(self) -> None: + """Close the inner transport and any runtime created by this transport.""" + try: + self._inner.close() + finally: + if self._owns_runtime: + self._runtime.close() diff --git a/src/mpp/runtime.py b/src/mpp/runtime.py index caa8e33..f4ac9cf 100644 --- a/src/mpp/runtime.py +++ b/src/mpp/runtime.py @@ -33,7 +33,7 @@ ) if TYPE_CHECKING: - from mpp.client import PaymentTransport + from mpp.client import PaymentTransport, SyncPaymentTransport _T = TypeVar("_T") @@ -526,6 +526,15 @@ def payment_transport(self, inner: httpx.AsyncBaseTransport | None = None) -> Pa runtime=self, ) + def sync_payment_transport( + self, + inner: httpx.BaseTransport | None = None, + ) -> SyncPaymentTransport: + """Create a synchronous httpx transport using this runtime.""" + from mpp.client import SyncPaymentTransport + + return SyncPaymentTransport(inner=inner, runtime=self) + def allows_http_payment(self, url: httpx.URL) -> bool: """Return whether credentials may be created for an HTTP origin.""" return self._allowed.http_url(url) diff --git a/tests/test_sync_client.py b/tests/test_sync_client.py new file mode 100644 index 0000000..d61a025 --- /dev/null +++ b/tests/test_sync_client.py @@ -0,0 +1,666 @@ +"""Tests for synchronous payment-aware HTTP clients.""" + +from __future__ import annotations + +import asyncio +import threading +from http.cookies import SimpleCookie +from typing import Any, cast +from unittest.mock import AsyncMock + +import httpx +import pytest + +from mpp import Challenge, Credential +from mpp.client import SyncPaymentTransport +from mpp.errors import PaymentError, PaymentOutcomeUnknownError +from mpp.runtime import PaymentRuntime +from tests import make_credential + + +class MockMethod: + name = "tempo" + _intents = {"charge": True} + + def __init__(self) -> None: + self.loops: list[asyncio.AbstractEventLoop] = [] + self.create_credential = AsyncMock(side_effect=self._create_credential) + + async def _create_credential(self, challenge: Challenge): + self.loops.append(asyncio.get_running_loop()) + return make_credential({"hash": "0xabc"}, challenge_id=challenge.id) + + +class MockTransport(httpx.BaseTransport): + def __init__(self, responses: list[httpx.Response]) -> None: + self.responses = responses + self.requests: list[httpx.Request] = [] + self.closed = False + + def handle_request(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + return self.responses.pop(0) + + def close(self) -> None: + self.closed = True + + +class ConsumingTransport(httpx.BaseTransport): + def __init__(self, responses: list[httpx.Response]) -> None: + self.responses = responses + self.requests: list[httpx.Request] = [] + self.bodies: list[bytes] = [] + + def handle_request(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + stream = cast(httpx.SyncByteStream, request.stream) + self.bodies.append(b"".join(stream)) + return self.responses.pop(0) + + +class TrackingStream(httpx.SyncByteStream): + def __init__(self, chunks: list[bytes]) -> None: + self.chunks = chunks + self.started = False + self.closed = False + + def __iter__(self): + self.started = True + yield from self.chunks + + def close(self) -> None: + self.closed = True + + +class BrokenTrackingStream(TrackingStream): + def __iter__(self): + self.started = True + raise httpx.ReadError("body lost") + yield b"" # pragma: no cover + + +def challenge(**overrides: Any) -> Challenge: + values = {"id": "test-id", "method": "tempo", "intent": "charge", "request": {}} + values.update(overrides) + return Challenge(**values) + + +def payment_required() -> httpx.Response: + return httpx.Response( + 402, + headers={"www-authenticate": challenge().to_www_authenticate("example.com")}, + ) + + +class TestSyncPaymentTransport: + def test_passes_through_free_response(self) -> None: + inner = MockTransport([httpx.Response(200, content=b"ok")]) + transport = SyncPaymentTransport(methods=[], inner=inner) + try: + response = transport.handle_request(httpx.Request("GET", "https://example.com")) + finally: + transport.close() + + assert response.content == b"ok" + assert len(inner.requests) == 1 + + def test_paid_retry_applies_and_propagates_challenge_cookies(self) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if "authorization" not in request.headers: + return httpx.Response( + 402, + headers=[ + ( + "www-authenticate", + challenge().to_www_authenticate("example.com"), + ), + ("set-cookie", "session=new; Path=/"), + ("set-cookie", "payment_nonce=nonce-1; Path=/"), + ], + ) + return httpx.Response( + 200, + headers={"set-cookie": "final_cookie=ok; Path=/"}, + content=b"paid", + ) + + transport = SyncPaymentTransport( + methods=[MockMethod()], + inner=httpx.MockTransport(handler), + ) + with httpx.Client(transport=transport) as client: + client.cookies.set("session", "old", domain="example.com", path="/") + response = client.get("https://example.com/paid") + + assert response.status_code == 200 + assert requests[0].headers["cookie"] == "session=old" + retry_cookies = SimpleCookie(requests[1].headers["cookie"]) + assert retry_cookies["session"].value == "new" + assert retry_cookies["payment_nonce"].value == "nonce-1" + assert dict(client.cookies) == { + "session": "new", + "payment_nonce": "nonce-1", + "final_cookie": "ok", + } + assert response.headers.get_list("set-cookie") == [ + "session=new; Path=/", + "payment_nonce=nonce-1; Path=/", + "final_cookie=ok; Path=/", + ] + + def test_replays_bytes_and_multipart_bodies(self) -> None: + requests = [ + httpx.Request("POST", "https://example.com", content=b'{"hello":"world"}'), + httpx.Request( + "POST", + "https://example.com", + files={"file": ("hello.txt", b"hello", "text/plain")}, + ), + ] + + for request in requests: + inner = ConsumingTransport([payment_required(), httpx.Response(200, content=b"paid")]) + transport = SyncPaymentTransport(methods=[MockMethod()], inner=inner) + try: + response = transport.handle_request(request) + finally: + transport.close() + + assert response.status_code == 200 + assert inner.bodies[1] == inner.bodies[0] + assert inner.requests[1].headers["authorization"].startswith("Payment ") + + def test_free_generator_body_passes_through(self) -> None: + def body(): + yield b"one-shot" + + received: list[bytes] = [] + + def handler(request: httpx.Request) -> httpx.Response: + stream = cast(httpx.SyncByteStream, request.stream) + received.append(b"".join(stream)) + return httpx.Response(200, content=b"ok") + + inner = httpx.MockTransport(handler) + transport = SyncPaymentTransport(methods=[MockMethod()], inner=inner) + try: + response = transport.handle_request( + httpx.Request("POST", "https://example.com", content=body()) + ) + finally: + transport.close() + + assert response.status_code == 200 + assert received == [b"one-shot"] + + def test_paid_generator_body_fails_after_first_send(self) -> None: + def body(): + yield b"one-shot" + + requests: list[httpx.Request] = [] + response_stream = TrackingStream([b"payment explanation"]) + + class OneShotTransport(httpx.BaseTransport): + def handle_request(self, request: httpx.Request) -> httpx.Response: + requests.append(request) + stream = cast(httpx.SyncByteStream, request.stream) + _ = b"".join(stream) + return httpx.Response( + 402, + headers={"www-authenticate": challenge().to_www_authenticate("example.com")}, + stream=response_stream, + ) + + transport = SyncPaymentTransport( + methods=[MockMethod()], + inner=OneShotTransport(), + ) + try: + with pytest.raises(PaymentError, match="cannot be replayed"): + transport.handle_request( + httpx.Request("POST", "https://example.com", content=body()) + ) + finally: + transport.close() + + assert len(requests) == 1 + assert response_stream.closed is True + + def test_paid_stream_remains_lazy(self) -> None: + stream = TrackingStream([b"one", b"two"]) + inner = MockTransport([payment_required(), httpx.Response(200, stream=stream)]) + transport = SyncPaymentTransport(methods=[MockMethod()], inner=inner) + try: + response = transport.handle_request(httpx.Request("GET", "https://example.com")) + outcome_stream = cast(Any, response.stream) + assert outcome_stream._runtime is not None + assert outcome_stream._attempt is not None + assert stream.started is False + assert response.read() == b"onetwo" + assert stream.started is True + assert outcome_stream._runtime is None + assert outcome_stream._attempt is None + finally: + transport.close() + + @pytest.mark.parametrize("terminal", ["error", "close"]) + def test_paid_stream_releases_attempt_on_noncomplete_terminal( + self, + terminal: str, + ) -> None: + stream: TrackingStream = ( + BrokenTrackingStream([]) if terminal == "error" else TrackingStream([b"paid"]) + ) + transport = SyncPaymentTransport( + methods=[MockMethod()], + inner=MockTransport( + [ + payment_required(), + httpx.Response(200, stream=stream), + ] + ), + ) + response = transport.handle_request(httpx.Request("GET", "https://example.com")) + outcome_stream = cast(Any, response.stream) + assert outcome_stream._runtime is not None + assert outcome_stream._attempt is not None + try: + if terminal == "error": + with pytest.raises(httpx.ReadError, match="body lost"): + response.read() + else: + response.close() + assert outcome_stream._runtime is None + assert outcome_stream._attempt is None + finally: + response.close() + transport.close() + + @pytest.mark.parametrize( + ("www_authenticate", "expected_challenges"), + [ + pytest.param("Payment invalid-base64!!", 0, id="malformed"), + pytest.param( + challenge(method="stripe").to_www_authenticate("example.com"), + 1, + id="no-match", + ), + pytest.param( + challenge(expires="2020-01-01T00:00:00Z").to_www_authenticate("example.com"), + 1, + id="expired", + ), + ], + ) + def test_nonpayable_challenges_fail_closed( + self, + www_authenticate: str, + expected_challenges: int, + ) -> None: + method = MockMethod() + inner = MockTransport([httpx.Response(402, headers={"www-authenticate": www_authenticate})]) + transport = SyncPaymentTransport(methods=[method], inner=inner) + failed: list[dict[str, Any]] = [] + transport.on_payment_failed(failed.append) + try: + response = transport.handle_request(httpx.Request("GET", "https://example.com")) + finally: + transport.close() + + assert response.status_code == 402 + assert len(inner.requests) == 1 + method.create_credential.assert_not_called() + assert len(failed) == 1 + assert len(failed[0]["challenges"]) == expected_challenges + assert failed[0]["credential"] is None + assert failed[0]["protocol"] == "http" + assert failed[0]["response"] is response + + @pytest.mark.parametrize( + "www_authenticate", + [ + pytest.param("Bearer realm=test", id="non-payment"), + pytest.param("Payment invalid-base64!!", id="malformed"), + pytest.param( + challenge(method="stripe").to_www_authenticate("example.com"), + id="unsupported", + ), + pytest.param( + challenge(expires="2020-01-01T00:00:00Z").to_www_authenticate("example.com"), + id="expired", + ), + ], + ) + def test_nonpayable_402_body_remains_lazy(self, www_authenticate: str) -> None: + stream = TrackingStream([b"explanation"]) + response = httpx.Response( + 402, + headers={"www-authenticate": www_authenticate}, + stream=stream, + ) + transport = SyncPaymentTransport( + methods=[MockMethod()], + inner=MockTransport([response]), + ) + try: + returned = transport.handle_request(httpx.Request("GET", "https://example.com")) + assert returned is response + assert stream.started is False + assert stream.closed is False + assert returned.read() == b"explanation" + assert stream.started is True + assert stream.closed is True + finally: + transport.close() + + def test_disallowed_402_body_remains_lazy(self) -> None: + stream = TrackingStream([b"explanation"]) + response = httpx.Response( + 402, + headers={"www-authenticate": challenge().to_www_authenticate("example.com")}, + stream=stream, + ) + runtime = PaymentRuntime( + [MockMethod()], + allowed_origins=["https://allowed.example"], + ) + transport = SyncPaymentTransport( + runtime=runtime, + inner=MockTransport([response]), + ) + try: + returned = transport.handle_request(httpx.Request("GET", "https://disallowed.example")) + assert returned is response + assert stream.started is False + assert stream.closed is False + finally: + response.close() + transport.close() + runtime.close() + + def test_initial_payment_failed_event_abort_closes_response(self) -> None: + class EventAbort(BaseException): + pass + + def abort(_payload: Any) -> None: + raise EventAbort + + stream = TrackingStream([b"payment explanation"]) + response = httpx.Response( + 402, + headers={ + "www-authenticate": challenge(method="stripe").to_www_authenticate("example.com") + }, + stream=stream, + ) + transport = SyncPaymentTransport( + methods=[MockMethod()], + inner=MockTransport([response]), + ) + transport.on_payment_failed(abort) + try: + with pytest.raises(EventAbort): + transport.handle_request(httpx.Request("GET", "https://example.com")) + finally: + transport.close() + + assert stream.closed is True + + @pytest.mark.parametrize("failure_stage", ["credential", "serialization", "retry"]) + def test_payment_failures_emit_and_propagate( + self, + failure_stage: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + error = RuntimeError(f"{failure_stage} failed") + requests: list[httpx.Request] = [] + + def fail_serialization(_credential: Credential) -> str: + raise error + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if len(requests) == 1: + return payment_required() + raise error + + method = MockMethod() + if failure_stage == "credential": + method.create_credential.side_effect = error + elif failure_stage == "serialization": + monkeypatch.setattr(Credential, "to_authorization", fail_serialization) + transport = SyncPaymentTransport( + methods=[method], + inner=httpx.MockTransport(handler), + ) + failed: list[dict[str, Any]] = [] + transport.on_payment_failed(failed.append) + try: + expected_error = ( + PaymentOutcomeUnknownError if failure_stage == "retry" else RuntimeError + ) + with pytest.raises(expected_error) as raised: + transport.handle_request(httpx.Request("GET", "https://example.com")) + finally: + transport.close() + + if failure_stage == "retry": + assert raised.value.__cause__ is error + else: + assert raised.value is error + assert len(requests) == (2 if failure_stage == "retry" else 1) + assert len(failed) == 1 + assert failed[0]["challenge"].id == "test-id" + if failure_stage in {"credential", "serialization"}: + assert failed[0]["credential"] is None + else: + assert failed[0]["credential"] is not None + assert isinstance(failed[0]["error"], expected_error) + + def test_repeated_402_after_credential_has_unknown_outcome(self) -> None: + method = MockMethod() + inner = MockTransport([payment_required(), payment_required()]) + transport = SyncPaymentTransport(methods=[method], inner=inner) + try: + with pytest.raises(PaymentOutcomeUnknownError, match="Do not blindly retry"): + transport.handle_request(httpx.Request("GET", "https://example.com")) + finally: + transport.close() + + assert len(inner.requests) == 2 + method.create_credential.assert_awaited_once() + + @pytest.mark.parametrize("race", ["operation", "circuit"]) + def test_send_boundary_unknown_emits_payment_failed(self, race: str) -> None: + from mpp.runtime import _HTTPX_OPERATIONS + + runtime = PaymentRuntime([MockMethod()], max_unknown_outcomes=1) + request = httpx.Request("GET", "https://example.com") + + def trip_guard(_payload: Any) -> None: + token = _HTTPX_OPERATIONS.set(None) + try: + blockers = ( + [("blocker", request.url)] + if race == "operation" + else [ + ("blocker-1", httpx.URL("https://example.com/1")), + ("blocker-2", httpx.URL("https://example.com/2")), + ] + ) + for identifier, url in blockers: + blocker_request = httpx.Request("GET", url) + blocker = runtime._begin_http_payment( + challenge(id=identifier), + blocker_request, + ) + runtime._mark_http_payment_sent(blocker, blocker_request) + runtime._mark_http_payment_unknown(blocker, TimeoutError("lost")) + finally: + _HTTPX_OPERATIONS.reset(token) + + events: list[str] = [] + runtime.events.on("credential.created", trip_guard) + runtime.events.on("*", lambda event: events.append(event.name)) + inner = MockTransport([payment_required()]) + transport = SyncPaymentTransport(runtime=runtime, inner=inner) + try: + with pytest.raises(PaymentOutcomeUnknownError): + transport.handle_request(request) + finally: + transport.close() + runtime.close() + + assert len(inner.requests) == 1 + assert events == ["challenge.received", "credential.created", "payment.failed"] + + @pytest.mark.parametrize("status_code", [200, 402, 503]) + def test_event_abort_closes_unreturned_paid_response( + self, + status_code: int, + ) -> None: + class EventAbort(BaseException): + pass + + def abort(_payload: Any) -> None: + raise EventAbort + + stream = TrackingStream([b"paid response"]) + transport = SyncPaymentTransport( + methods=[MockMethod()], + inner=MockTransport( + [ + payment_required(), + httpx.Response(status_code, stream=stream), + ] + ), + ) + if status_code == 200: + transport.on_payment_response(abort) + else: + transport.on_payment_failed(abort) + try: + with pytest.raises(EventAbort): + transport.handle_request(httpx.Request("GET", "https://example.com")) + finally: + transport.close() + + assert stream.closed is True + + def test_lifecycle_handler_can_supply_sync_credential(self) -> None: + method = MockMethod() + credential = make_credential({"hash": "0xevent"}, challenge_id="test-id") + inner = MockTransport([payment_required(), httpx.Response(200, content=b"paid")]) + transport = SyncPaymentTransport(methods=[method], inner=inner) + events: list[str] = [] + transport.on_challenge_received(lambda _payload: credential) + transport.on_credential_created(lambda payload: events.append("credential")) + transport.on_payment_response(lambda payload: events.append("response")) + try: + response = transport.handle_request(httpx.Request("GET", "https://example.com")) + finally: + transport.close() + + assert response.status_code == 200 + assert events == ["credential", "response"] + assert inner.requests[1].headers["authorization"] == credential.to_authorization() + method.create_credential.assert_not_called() + + def test_close_preserves_borrowed_runtime(self) -> None: + method = MockMethod() + runtime = PaymentRuntime([method]) + inner = MockTransport([]) + transport = runtime.sync_payment_transport(inner=inner) + try: + runtime.create_credential_sync(challenge(), method) + transport.close() + runtime.create_credential_sync(challenge(), method) + finally: + transport.close() + runtime.close() + + assert inner.closed + + def test_requires_methods_or_runtime(self) -> None: + with pytest.raises(ValueError, match="Pass methods or runtime"): + SyncPaymentTransport() + + def test_rejects_runtime_with_methods_or_events(self) -> None: + runtime = PaymentRuntime([]) + try: + with pytest.raises(ValueError, match="either methods/events or runtime"): + SyncPaymentTransport(methods=[], runtime=runtime) + with pytest.raises(ValueError, match="either methods/events or runtime"): + SyncPaymentTransport(events=runtime.events, runtime=runtime) + finally: + runtime.close() + + def test_close_finalizes_owned_runtime_when_inner_close_fails(self) -> None: + error = RuntimeError("inner close failed") + + class FailingCloseTransport(MockTransport): + def close(self) -> None: + super().close() + raise error + + inner = FailingCloseTransport([]) + transport = SyncPaymentTransport(methods=[], inner=inner) + runtime = transport._runtime + + with pytest.raises(RuntimeError) as raised: + transport.close() + + assert raised.value is error + assert inner.closed + with pytest.raises(RuntimeError, match="closed"): + runtime.start() + + +class TestSyncCloseLease: + def test_close_waits_for_committed_http_retry(self) -> None: + retry_started = threading.Event() + retry_release = threading.Event() + calls = 0 + + def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + if calls == 1: + return payment_required() + retry_started.set() + assert retry_release.wait(1) + return httpx.Response(200, content=b"paid") + + runtime = PaymentRuntime([MockMethod()]) + transport = SyncPaymentTransport(runtime=runtime, inner=httpx.MockTransport(handler)) + responses: list[httpx.Response] = [] + errors: list[BaseException] = [] + closed = threading.Event() + + def request() -> None: + try: + responses.append( + transport.handle_request(httpx.Request("GET", "https://example.com")) + ) + except BaseException as error: + errors.append(error) + + request_thread = threading.Thread(target=request) + close_thread = threading.Thread(target=lambda: (runtime.close(), closed.set())) + request_thread.start() + assert retry_started.wait(1) + close_thread.start() + assert not closed.wait(0.05) + + retry_release.set() + for thread in (request_thread, close_thread): + thread.join(timeout=1) + assert not thread.is_alive() + transport.close() + + assert not errors + assert responses[0].status_code == 200 + assert closed.is_set() From 79739adf4a7fa5822c27def096f586eb51192346 Mon Sep 17 00:00:00 2001 From: Parv Ahuja <17094219+parvahuja@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:06:10 -0700 Subject: [PATCH 09/11] feat: add per-client HTTPX adapters --- .changelog/httpx-client-adapters.md | 6 + .github/workflows/ci.yml | 34 +- README.md | 18 + src/mpp/_httpx.py | 129 +++++++ src/mpp/client/sync_transport.py | 5 +- src/mpp/client/transport.py | 5 +- src/mpp/runtime.py | 153 +++++++- tests/httpx/test_adapters.py | 518 ++++++++++++++++++++++++++++ 8 files changed, 864 insertions(+), 4 deletions(-) create mode 100644 .changelog/httpx-client-adapters.md create mode 100644 src/mpp/_httpx.py create mode 100644 tests/httpx/test_adapters.py diff --git a/.changelog/httpx-client-adapters.md b/.changelog/httpx-client-adapters.md new file mode 100644 index 0000000..1348b0e --- /dev/null +++ b/.changelog/httpx-client-adapters.md @@ -0,0 +1,6 @@ +--- +pympp: minor +--- + +Add per-client HTTPX adapters with automatic 402 payment handling and a tested +HTTPX 0.27/0.28 compatibility matrix. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6b0917..7ca591e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: ci-gate: name: CI Gate if: always() - needs: [test, integration, package, install-smoke] + needs: [test, httpx-adapter, integration, package, install-smoke] runs-on: ubuntu-latest steps: - env: @@ -89,6 +89,38 @@ jobs: htmlcov/ if-no-files-found: ignore + httpx-adapter: + name: HTTPX adapter (${{ matrix.httpx-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + httpx-version: ["0.27.0", "0.27.1", "0.27.2", "0.28.0", "0.28.1"] + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + cache-dependency-glob: "pyproject.toml" + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies + run: | + uv venv --python 3.12 + uv pip install -e . "httpx==$HTTPX_VERSION" "pytest>=8" "pytest-asyncio>=0.24" + env: + HTTPX_VERSION: ${{ matrix.httpx-version }} + + - name: Run adapter tests + run: uv run --no-sync pytest -v --confcutdir=tests/httpx tests/httpx + integration: name: Integration Test runs-on: ubuntu-latest diff --git a/README.md b/README.md index d9bf22b..eee484c 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,24 @@ with PaymentRuntime( response = client.get("https://api.example.com/paid") ``` +Existing HTTPX clients can instead be adapted in place, preserving their +authentication, redirects, hooks, and streaming behavior: + +```python +with PaymentRuntime( + method_factories=[method_factory], + allowed_origins=["https://api.example.com"], +) as runtime: + with runtime.wrap_client(httpx.Client()) as client: + response = client.get("https://api.example.com/paid") +``` + +Use `runtime.wrap_async_client(client)` for an `httpx.AsyncClient`. These +per-client adapters support HTTPX `0.27.x` and `0.28.x` and fail before changing +the client when its adapter seam is incompatible. The explicit +`PaymentTransport` and `SyncPaymentTransport` remain available on newer HTTPX +versions. + ## Examples | Example | Description | diff --git a/src/mpp/_httpx.py b/src/mpp/_httpx.py new file mode 100644 index 0000000..88bd43a --- /dev/null +++ b/src/mpp/_httpx.py @@ -0,0 +1,129 @@ +"""Compatibility checks for HTTPX client adapters.""" + +from __future__ import annotations + +import inspect +from importlib.metadata import version +from typing import Any + +import httpx + +HTTPX_ADAPTER_VERSIONS = ">=0.27,<0.29" +_SUPPORTED_HTTPX_MINORS = {(0, 27), (0, 28)} + + +class HttpxCompatibilityError(RuntimeError): + """The installed HTTPX version cannot be safely adapted.""" + + +_POSITIONAL = inspect.Parameter.POSITIONAL_OR_KEYWORD +_KEYWORD_ONLY = inspect.Parameter.KEYWORD_ONLY +_PRIVATE_PARAMETERS = (("self", _POSITIONAL), ("request", _POSITIONAL)) +_BOUND_PRIVATE_PARAMETERS = (("request", _POSITIONAL),) +_PUBLIC_PARAMETERS = ( + ("self", _POSITIONAL), + ("request", _POSITIONAL), + ("stream", _KEYWORD_ONLY), + ("auth", _KEYWORD_ONLY), + ("follow_redirects", _KEYWORD_ONLY), +) +_BOUND_PUBLIC_PARAMETERS = _PUBLIC_PARAMETERS[1:] + + +def _method_shape(method: Any) -> tuple[tuple[str, Any], ...]: + try: + return tuple( + (parameter.name, parameter.kind) + for parameter in inspect.signature(method).parameters.values() + ) + except (TypeError, ValueError) as error: + raise HttpxCompatibilityError("HTTPX adapter seam has no inspectable signature") from error + + +def _validate_method( + name: str, + method: Any, + expected: tuple[tuple[str, Any], ...], + *, + asynchronous: bool, +) -> None: + if not callable(method): + raise HttpxCompatibilityError(f"HTTPX adapter seam {name} is not callable") + if _method_shape(method) != expected: + raise HttpxCompatibilityError(f"HTTPX adapter seam {name} has an unsupported signature") + if inspect.iscoroutinefunction(method) is not asynchronous: + kind = "async" if asynchronous else "sync" + raise HttpxCompatibilityError(f"HTTPX adapter seam {name} is not {kind}") + + +def _validate_httpx_version() -> None: + installed = version("httpx") + try: + major, minor, *_ = installed.split(".") + supported = (int(major), int(minor)) in _SUPPORTED_HTTPX_MINORS + except ValueError as error: + raise HttpxCompatibilityError( + f"Cannot determine HTTPX compatibility from version {installed!r}" + ) from error + if not supported: + raise HttpxCompatibilityError( + f"HTTPX {installed} is unsupported by pympp HTTPX adapters " + f"(supported: {HTTPX_ADAPTER_VERSIONS}). " + "Use PaymentTransport or SyncPaymentTransport explicitly, or upgrade pympp." + ) + + +def _validate_httpx_seams(owner: Any, *, asynchronous: bool) -> tuple[Any, Any]: + seams: list[Any] = [] + for name, parameters in ( + ("_send_single_request", _PRIVATE_PARAMETERS), + ("send", _PUBLIC_PARAMETERS), + ): + seam_name = f"{owner.__name__}.{name}" + try: + method = inspect.getattr_static(owner, name) + except AttributeError as error: + raise HttpxCompatibilityError(f"HTTPX adapter seam {seam_name} is missing") from error + _validate_method( + seam_name, + method, + parameters, + asynchronous=asynchronous, + ) + seams.append(method) + return seams[0], seams[1] + + +def _validate_httpx_compatibility() -> tuple[Any, Any, Any, Any]: + """Return all compatible class-level HTTPX seams, without mutation.""" + _validate_httpx_version() + sync_private, sync_public = _validate_httpx_seams(httpx.Client, asynchronous=False) + async_private, async_public = _validate_httpx_seams(httpx.AsyncClient, asynchronous=True) + return sync_private, async_private, sync_public, async_public + + +def _validate_httpx_client( + client: httpx.Client | httpx.AsyncClient, +) -> tuple[Any, Any]: + """Return compatible bound send seams, without mutating the client.""" + _validate_httpx_version() + asynchronous = isinstance(client, httpx.AsyncClient) + _validate_httpx_seams( + type(client), + asynchronous=asynchronous, + ) + private, public = client._send_single_request, client.send + owner = type(client).__name__ + _validate_method( + f"{owner} instance._send_single_request", + private, + _BOUND_PRIVATE_PARAMETERS, + asynchronous=asynchronous, + ) + _validate_method( + f"{owner} instance.send", + public, + _BOUND_PUBLIC_PARAMETERS, + asynchronous=asynchronous, + ) + return private, public diff --git a/src/mpp/client/sync_transport.py b/src/mpp/client/sync_transport.py index ad07c1a..d19181e 100644 --- a/src/mpp/client/sync_transport.py +++ b/src/mpp/client/sync_transport.py @@ -79,7 +79,10 @@ def __init__( def handle_request(self, request: httpx.Request) -> httpx.Response: """Send a request and retry one 402 with a payment credential.""" - with self._runtime._httpx_operation_scope(request): + with self._runtime._httpx_operation_scope( + request, + reuse=self._runtime._httpx_adapter_active(), + ): response = self._inner.handle_request(request) return self._handle_response(request, response) diff --git a/src/mpp/client/transport.py b/src/mpp/client/transport.py index ecbddca..f4e24a5 100644 --- a/src/mpp/client/transport.py +++ b/src/mpp/client/transport.py @@ -417,7 +417,10 @@ def __init__( async def handle_async_request(self, request: httpx.Request) -> httpx.Response: """Handle request, automatically retrying on 402 with credentials.""" - with self._runtime._httpx_operation_scope(request): + with self._runtime._httpx_operation_scope( + request, + reuse=self._runtime._httpx_adapter_active(), + ): response = await self._inner.handle_async_request(request) return await self._handle_async_response(request, response) diff --git a/src/mpp/runtime.py b/src/mpp/runtime.py index f4ac9cf..a78945c 100644 --- a/src/mpp/runtime.py +++ b/src/mpp/runtime.py @@ -25,6 +25,9 @@ from anyio.from_thread import BlockingPortal, start_blocking_portal from mpp import Challenge, Credential +from mpp._httpx import HTTPX_ADAPTER_VERSIONS as HTTPX_ADAPTER_VERSIONS +from mpp._httpx import HttpxCompatibilityError as HttpxCompatibilityError +from mpp._httpx import _validate_httpx_client from mpp.events import ( CHALLENGE_RECEIVED, CREDENTIAL_CREATED, @@ -85,6 +88,10 @@ class _HttpxOperation: "mpp_httpx_operations", default=None, ) +_HTTPX_ADAPTER_RUNTIME: ContextVar[int | None] = ContextVar( + "mpp_httpx_adapter_runtime", + default=None, +) _HTTP_PAYMENT_ATTEMPT_EXTENSION = "mpp.payment_attempt" _DEFAULT_MAX_UNKNOWN_OUTCOMES = 1024 @@ -132,6 +139,49 @@ async def create_credential(self, challenge: Challenge, /) -> Credential: ] +class _BoundSendTransport(httpx.AsyncBaseTransport): + def __init__(self, send: Any) -> None: + self._send = send + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + return await self._send(request) + + +class _BoundSyncSendTransport(httpx.BaseTransport): + def __init__(self, send: Any) -> None: + self._send = send + + def handle_request(self, request: httpx.Request) -> httpx.Response: + return self._send(request) + + +_MISSING_CLIENT_ATTRIBUTE = object() + + +def _set_client_attributes(client: Any, **updates: Any) -> None: + namespace = vars(client) + previous = {name: namespace.get(name, _MISSING_CLIENT_ATTRIBUTE) for name in updates} + applied: list[str] = [] + try: + for name, value in updates.items(): + setattr(client, name, value) + applied.append(name) + except BaseException as error: + rollback_error: BaseException | None = None + for name in reversed(applied): + try: + value = previous[name] + if value is _MISSING_CLIENT_ATTRIBUTE: + delattr(client, name) + else: + setattr(client, name, value) + except BaseException as cause: + rollback_error = rollback_error or cause + if rollback_error is not None: + raise RuntimeError("Failed to roll back HTTPX client adapter") from error + raise + + class _AsyncBridge: """Run payment work on one AnyIO-owned asyncio loop.""" @@ -535,13 +585,114 @@ def sync_payment_transport( return SyncPaymentTransport(inner=inner, runtime=self) + def wrap_client(self, client: httpx.Client) -> httpx.Client: + """Make one existing HTTPX client payment-aware.""" + if not isinstance(client, httpx.Client): + raise TypeError("wrap_client requires an httpx.Client") + if getattr(client, "_mpp_payment_wrapped", False): + client._mpp_payment_runtime = self # type: ignore[attr-defined] + return client + original_send_single, original_send = _validate_httpx_client(client) + + def send_single(request: httpx.Request) -> httpx.Response: + runtime = client._mpp_payment_runtime # type: ignore[attr-defined] + return runtime.send_httpx_sync(original_send_single, request) + + def send(request: httpx.Request, *args: Any, **kwargs: Any) -> httpx.Response: + runtime = client._mpp_payment_runtime # type: ignore[attr-defined] + with runtime._httpx_operation_scope(request): + return original_send(request, *args, **kwargs) + + _set_client_attributes( + client, + send=send, + _send_single_request=send_single, + _mpp_payment_runtime=self, + _mpp_payment_wrapped=True, + ) + return client + + def wrap_async_client(self, client: httpx.AsyncClient) -> httpx.AsyncClient: + """Make one existing HTTPX async client payment-aware.""" + if not isinstance(client, httpx.AsyncClient): + raise TypeError("wrap_async_client requires an httpx.AsyncClient") + if getattr(client, "_mpp_payment_wrapped", False): + client._mpp_payment_runtime = self # type: ignore[attr-defined] + return client + original_send_single, original_send = _validate_httpx_client(client) + + async def send_single(request: httpx.Request) -> httpx.Response: + runtime = client._mpp_payment_runtime # type: ignore[attr-defined] + return await runtime.send_httpx(original_send_single, request) + + async def send(request: httpx.Request, *args: Any, **kwargs: Any) -> httpx.Response: + runtime = client._mpp_payment_runtime # type: ignore[attr-defined] + with runtime._httpx_operation_scope(request): + return await original_send(request, *args, **kwargs) + + _set_client_attributes( + client, + send=send, + _send_single_request=send_single, + _mpp_payment_runtime=self, + _mpp_payment_wrapped=True, + ) + return client + + async def send_httpx( + self, + send: Callable[[httpx.Request], Awaitable[httpx.Response]], + request: httpx.Request, + ) -> httpx.Response: + """Send one HTTPX request with automatic 402 payment handling.""" + with self._httpx_operation_scope(request, reuse=True): + token = _HTTPX_ADAPTER_RUNTIME.set(id(self)) + try: + transport = _BoundSendTransport(send) + response = await transport.handle_async_request(request) + return await self.payment_transport(inner=transport)._handle_async_response( + request, + response, + ) + finally: + _HTTPX_ADAPTER_RUNTIME.reset(token) + + def send_httpx_sync( + self, + send: Callable[[httpx.Request], httpx.Response], + request: httpx.Request, + ) -> httpx.Response: + """Send one synchronous HTTPX request with automatic 402 handling.""" + with self._httpx_operation_scope(request, reuse=True): + token = _HTTPX_ADAPTER_RUNTIME.set(id(self)) + try: + transport = _BoundSyncSendTransport(send) + response = transport.handle_request(request) + return self.sync_payment_transport(inner=transport)._handle_response( + request, + response, + ) + finally: + _HTTPX_ADAPTER_RUNTIME.reset(token) + def allows_http_payment(self, url: httpx.URL) -> bool: """Return whether credentials may be created for an HTTP origin.""" return self._allowed.http_url(url) + def _httpx_adapter_active(self) -> bool: + return _HTTPX_ADAPTER_RUNTIME.get() == id(self) + @contextmanager - def _httpx_operation_scope(self, request: httpx.Request): + def _httpx_operation_scope( + self, + request: httpx.Request, + *, + reuse: bool = False, + ): operations = _HTTPX_OPERATIONS.get() or {} + if reuse and (operation := operations.get(id(self))) is not None and operation.active: + yield operation + return operation_key = _http_idempotency_key(request) operation = _HttpxOperation() diff --git a/tests/httpx/test_adapters.py b/tests/httpx/test_adapters.py new file mode 100644 index 0000000..b1e3a89 --- /dev/null +++ b/tests/httpx/test_adapters.py @@ -0,0 +1,518 @@ +"""Tests for per-client HTTPX payment adapters.""" + +from __future__ import annotations + +import inspect +from typing import Any, cast +from unittest.mock import AsyncMock + +import httpx +import pytest + +import mpp._httpx as httpx_compat +from mpp import Challenge, Credential +from mpp.errors import PaymentError, PaymentOutcomeUnknownError +from mpp.runtime import ( + HTTPX_ADAPTER_VERSIONS, + HttpxCompatibilityError, + PaymentRuntime, +) + + +class Method: + name = "tempo" + _intents = {"charge": True} + + def __init__(self) -> None: + self.create_credential = AsyncMock(side_effect=self._create_credential) + + async def _create_credential(self, challenge: Challenge): + return Credential(challenge=challenge.to_echo(), payload={"hash": "0xabc"}) + + +def payment_required(challenge_id: str = "challenge") -> httpx.Response: + challenge = Challenge( + id=challenge_id, + method="tempo", + intent="charge", + request={}, + ) + return httpx.Response( + 402, + headers={"www-authenticate": challenge.to_www_authenticate("example.com")}, + ) + + +@pytest.mark.asyncio +async def test_wrap_clients_are_idempotent_and_preserve_auth_and_hooks() -> None: + method = Method() + runtime = PaymentRuntime([method]) + sync_requests: list[httpx.Request] = [] + async_requests: list[httpx.Request] = [] + hooks: list[tuple[str, int]] = [] + + def handler(requests: list[httpx.Request], request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, content=b"paid") if len(requests) == 2 else payment_required() + + def sync_hook(response: httpx.Response) -> None: + hooks.append(("sync", response.status_code)) + + async def async_hook(response: httpx.Response) -> None: + hooks.append(("async", response.status_code)) + + sync_client = httpx.Client( + auth=("user", "password"), + event_hooks={"response": [sync_hook]}, + transport=httpx.MockTransport(lambda request: handler(sync_requests, request)), + ) + async_client = httpx.AsyncClient( + auth=("user", "password"), + event_hooks={"response": [async_hook]}, + transport=httpx.MockTransport(lambda request: handler(async_requests, request)), + ) + try: + assert runtime.wrap_client(sync_client) is sync_client + assert runtime.wrap_client(sync_client) is sync_client + assert runtime.wrap_async_client(async_client) is async_client + assert runtime.wrap_async_client(async_client) is async_client + sync_response = sync_client.get("https://example.com/sync") + async_response = await async_client.get("https://example.com/async") + finally: + sync_client.close() + await async_client.aclose() + runtime.close() + + assert sync_response.status_code == async_response.status_code == 200 + assert sync_requests[0].headers["authorization"].startswith("Basic ") + assert async_requests[0].headers["authorization"].startswith("Basic ") + assert sync_requests[1].headers["authorization"].startswith("Payment ") + assert async_requests[1].headers["authorization"].startswith("Payment ") + assert hooks == [("sync", 200), ("async", 200)] + assert method.create_credential.await_count == 2 + + +@pytest.mark.asyncio +async def test_runtime_send_httpx_helpers_handle_sync_and_async_challenges() -> None: + method = Method() + runtime = PaymentRuntime([method]) + sync_requests: list[httpx.Request] = [] + async_requests: list[httpx.Request] = [] + + def sync_send(request: httpx.Request) -> httpx.Response: + sync_requests.append(request) + return ( + httpx.Response(200, content=b"sync") + if "authorization" in request.headers + else payment_required("sync") + ) + + async def async_send(request: httpx.Request) -> httpx.Response: + async_requests.append(request) + return ( + httpx.Response(200, content=b"async") + if "authorization" in request.headers + else payment_required("async") + ) + + try: + sync_response = runtime.send_httpx_sync( + sync_send, + httpx.Request("GET", "https://example.com/sync"), + ) + async_response = await runtime.send_httpx( + async_send, + httpx.Request("GET", "https://example.com/async"), + ) + finally: + runtime.close() + + assert sync_response.content == b"sync" + assert async_response.content == b"async" + assert all( + "authorization" in requests[1].headers for requests in (sync_requests, async_requests) + ) + + +@pytest.mark.asyncio +async def test_wrappers_send_free_one_shot_uploads_once() -> None: + sync_bodies: list[bytes] = [] + async_bodies: list[bytes] = [] + runtime = PaymentRuntime([]) + + def sync_handler(request: httpx.Request) -> httpx.Response: + sync_bodies.append(b"".join(cast(httpx.SyncByteStream, request.stream))) + return httpx.Response(200) + + async def async_handler(request: httpx.Request) -> httpx.Response: + stream = cast(httpx.AsyncByteStream, request.stream) + async_bodies.append(b"".join([chunk async for chunk in stream])) + return httpx.Response(200) + + def sync_body(): + yield b"one-" + yield b"shot" + + async def async_body(): + yield b"one-" + yield b"shot" + + sync_client = runtime.wrap_client(httpx.Client(transport=httpx.MockTransport(sync_handler))) + async_client = runtime.wrap_async_client( + httpx.AsyncClient(transport=httpx.MockTransport(async_handler)) + ) + try: + sync_response = sync_client.post("https://example.com", content=sync_body()) + async_response = await async_client.post( + "https://example.com", + content=async_body(), + ) + finally: + sync_client.close() + await async_client.aclose() + runtime.close() + + assert sync_response.status_code == async_response.status_code == 200 + assert sync_bodies == async_bodies == [b"one-shot"] + + +@pytest.mark.asyncio +async def test_redirect_policy_uses_the_challenged_origin() -> None: + requests: list[httpx.Request] = [] + method = Method() + runtime = PaymentRuntime([method], allowed_origins=["https://allowed.example"]) + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.host == "allowed.example": + return httpx.Response(302, headers={"location": "https://other.example/paid"}) + return payment_required() + + client = runtime.wrap_async_client( + httpx.AsyncClient( + transport=httpx.MockTransport(handler), + follow_redirects=True, + ) + ) + try: + response = await client.get("https://allowed.example/start") + finally: + await client.aclose() + runtime.close() + + assert response.status_code == 402 + assert [request.url.host for request in requests] == ["allowed.example", "other.example"] + method.create_credential.assert_not_awaited() + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.asyncio +async def test_repeated_challenge_never_pays_twice(asynchronous: bool) -> None: + requests: list[httpx.Request] = [] + method = Method() + runtime = PaymentRuntime([method]) + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return payment_required(f"challenge-{len(requests)}") + + client: httpx.Client | httpx.AsyncClient + client = ( + runtime.wrap_async_client(httpx.AsyncClient(transport=httpx.MockTransport(handler))) + if asynchronous + else runtime.wrap_client(httpx.Client(transport=httpx.MockTransport(handler))) + ) + try: + with pytest.raises(PaymentOutcomeUnknownError): + if asynchronous: + await cast(httpx.AsyncClient, client).get("https://example.com/paid") + else: + cast(httpx.Client, client).get("https://example.com/paid") + finally: + if asynchronous: + await cast(httpx.AsyncClient, client).aclose() + else: + cast(httpx.Client, client).close() + runtime.close() + + assert len(requests) == 2 + method.create_credential.assert_awaited_once() + + +def test_paid_send_failure_blocks_a_second_payment() -> None: + requests: list[httpx.Request] = [] + method = Method() + runtime = PaymentRuntime([method]) + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if "authorization" in request.headers: + raise httpx.ReadTimeout("paid response lost", request=request) + return payment_required() + + client = runtime.wrap_client(httpx.Client(transport=httpx.MockTransport(handler))) + headers = {"Idempotency-Key": "operation"} + try: + with pytest.raises(PaymentOutcomeUnknownError, match="outcome is unknown"): + client.get("https://example.com/paid", headers=headers) + with pytest.raises(PaymentOutcomeUnknownError): + client.get("https://example.com/paid", headers=headers) + finally: + client.close() + runtime.close() + + assert len(requests) == 3 + method.create_credential.assert_awaited_once() + + +def test_response_hook_failure_keeps_nested_transport_attempt_unknown() -> None: + method = Method() + runtime = PaymentRuntime([method]) + + def handler(request: httpx.Request) -> httpx.Response: + if "authorization" in request.headers: + return httpx.Response(200, content=b"paid") + return payment_required() + + hook_calls = 0 + + def hook(response: httpx.Response) -> None: + nonlocal hook_calls + hook_calls += 1 + response.read() + raise RuntimeError("consumer failed") + + transport = runtime.sync_payment_transport(inner=httpx.MockTransport(handler)) + client = runtime.wrap_client( + httpx.Client(transport=transport, event_hooks={"response": [hook]}) + ) + try: + with pytest.raises(RuntimeError, match="consumer failed"): + client.get("https://example.com/paid") + with pytest.raises(PaymentOutcomeUnknownError): + client.get("https://example.com/paid") + finally: + client.close() + runtime.close() + + assert hook_calls == 1 + method.create_credential.assert_awaited_once() + + +class BrokenStream(httpx.SyncByteStream): + def __iter__(self): + raise httpx.ReadError("body lost") + yield b"" # pragma: no cover + + +def test_paid_stream_failure_blocks_a_second_payment() -> None: + method = Method() + runtime = PaymentRuntime([method]) + + def handler(request: httpx.Request) -> httpx.Response: + if "authorization" in request.headers: + return httpx.Response(200, stream=BrokenStream()) + return payment_required() + + client = runtime.wrap_client(httpx.Client(transport=httpx.MockTransport(handler))) + try: + request = client.build_request("GET", "https://example.com/paid") + response = client.send(request, stream=True) + with pytest.raises(httpx.ReadError, match="body lost"): + response.read() + response.close() + with pytest.raises(PaymentOutcomeUnknownError): + client.get("https://example.com/paid") + finally: + client.close() + runtime.close() + + method.create_credential.assert_awaited_once() + + +def test_paid_one_shot_upload_fails_before_sending_a_credential() -> None: + bodies: list[bytes] = [] + method = Method() + runtime = PaymentRuntime([method]) + + class ConsumingTransport(httpx.BaseTransport): + def handle_request(self, request: httpx.Request) -> httpx.Response: + bodies.append(b"".join(cast(httpx.SyncByteStream, request.stream))) + return payment_required() + + def body(): + yield b"one-shot" + + client = runtime.wrap_client(httpx.Client(transport=ConsumingTransport())) + try: + with pytest.raises(PaymentError, match="cannot be replayed"): + client.post("https://example.com/paid", content=body()) + finally: + client.close() + runtime.close() + + assert bodies == [b"one-shot"] + method.create_credential.assert_not_awaited() + + +def test_unsupported_version_fails_without_mutating_client( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client = httpx.Client() + runtime = PaymentRuntime([]) + send = inspect.getattr_static(client, "send") + send_single = inspect.getattr_static(client, "_send_single_request") + monkeypatch.setattr(httpx_compat, "version", lambda _: "0.29.0") + try: + with pytest.raises(HttpxCompatibilityError, match=r"supported: >=0.27,<0.29"): + runtime.wrap_client(client) + finally: + client.close() + runtime.close() + + assert HTTPX_ADAPTER_VERSIONS == ">=0.27,<0.29" + assert inspect.getattr_static(client, "send") is send + assert inspect.getattr_static(client, "_send_single_request") is send_single + assert not hasattr(client, "_mpp_payment_runtime") + assert not hasattr(client, "_mpp_payment_wrapped") + + +def test_changed_send_signature_fails_without_mutating_client( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client = httpx.Client() + runtime = PaymentRuntime([]) + wrapped = runtime.wrap_client(httpx.Client()) + send_single = inspect.getattr_static(client, "_send_single_request") + + def incompatible( + self: httpx.Client, + request: httpx.Request, + extra: object, + ) -> httpx.Response: + raise AssertionError + + monkeypatch.setattr(httpx.Client, "send", incompatible) + try: + assert runtime.wrap_client(wrapped) is wrapped + with pytest.raises(HttpxCompatibilityError, match="Client.send.*unsupported signature"): + runtime.wrap_client(client) + finally: + client.close() + wrapped.close() + runtime.close() + + assert inspect.getattr_static(client, "_send_single_request") is send_single + assert inspect.getattr_static(client, "send") is incompatible + assert not hasattr(client, "_mpp_payment_runtime") + assert not hasattr(client, "_mpp_payment_wrapped") + + +def test_sync_wrap_ignores_an_incompatible_async_seam( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def incompatible( + self: httpx.AsyncClient, + request: httpx.Request, + extra: object, + ) -> httpx.Response: + raise AssertionError + + monkeypatch.setattr(httpx.AsyncClient, "send", incompatible) + runtime = PaymentRuntime([]) + client = httpx.Client(transport=httpx.MockTransport(lambda _: httpx.Response(200))) + try: + assert runtime.wrap_client(client).get("https://example.com").status_code == 200 + finally: + client.close() + runtime.close() + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.asyncio +async def test_instance_seams_fail_before_mutation(asynchronous: bool) -> None: + client: httpx.Client | httpx.AsyncClient = ( + httpx.AsyncClient() if asynchronous else httpx.Client() + ) + runtime = PaymentRuntime([]) + send_single = inspect.getattr_static(client, "_send_single_request") + if asynchronous: + + async def incompatible_async( + request: httpx.Request, + required: object, + ) -> httpx.Response: + raise AssertionError + + incompatible = incompatible_async + else: + + def incompatible_sync( + request: httpx.Request, + required: object, + ) -> httpx.Response: + raise AssertionError + + incompatible = incompatible_sync + + client.send = incompatible # type: ignore[method-assign, assignment] + try: + with pytest.raises(HttpxCompatibilityError, match="instance.send.*unsupported signature"): + if asynchronous: + runtime.wrap_async_client(cast(httpx.AsyncClient, client)) + else: + runtime.wrap_client(cast(httpx.Client, client)) + finally: + if asynchronous: + await cast(httpx.AsyncClient, client).aclose() + else: + cast(httpx.Client, client).close() + runtime.close() + + assert inspect.getattr_static(client, "send") is incompatible + assert inspect.getattr_static(client, "_send_single_request") is send_single + assert not hasattr(client, "_mpp_payment_runtime") + assert not hasattr(client, "_mpp_payment_wrapped") + + +def test_adapter_mutation_rolls_back_on_assignment_failure() -> None: + class RejectingClient(httpx.Client): + def __setattr__(self, name: str, value: object) -> None: + if name == "_mpp_payment_wrapped": + raise RuntimeError("assignment rejected") + super().__setattr__(name, value) + + client = RejectingClient() + runtime = PaymentRuntime([]) + send = inspect.getattr_static(client, "send") + send_single = inspect.getattr_static(client, "_send_single_request") + try: + with pytest.raises(RuntimeError, match="assignment rejected"): + runtime.wrap_client(client) + finally: + client.close() + runtime.close() + + assert inspect.getattr_static(client, "send") is send + assert inspect.getattr_static(client, "_send_single_request") is send_single + assert not hasattr(client, "_mpp_payment_runtime") + assert not hasattr(client, "_mpp_payment_wrapped") + + +@pytest.mark.asyncio +async def test_wrap_entry_points_reject_the_wrong_client_kind() -> None: + sync_client = httpx.Client() + async_client = httpx.AsyncClient() + runtime = PaymentRuntime([]) + try: + with pytest.raises(TypeError, match="httpx.Client"): + runtime.wrap_client(cast(Any, async_client)) + with pytest.raises(TypeError, match="httpx.AsyncClient"): + runtime.wrap_async_client(cast(Any, sync_client)) + finally: + sync_client.close() + await async_client.aclose() + runtime.close() + + assert not hasattr(sync_client, "_mpp_payment_wrapped") + assert not hasattr(async_client, "_mpp_payment_wrapped") From 8767e4fbb19f94fda152e43597237395790e50c4 Mon Sep 17 00:00:00 2001 From: Parv Ahuja <17094219+parvahuja@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:06:27 -0700 Subject: [PATCH 10/11] feat: add scoped HTTPX instrumentation --- .changelog/httpx-instrumentation.md | 5 + README.md | 19 ++ src/mpp/instrumentation.py | 362 ++++++++++++++++++++ src/mpp/runtime.py | 90 +++-- tests/httpx/test_instrumentation.py | 494 ++++++++++++++++++++++++++++ 5 files changed, 945 insertions(+), 25 deletions(-) create mode 100644 .changelog/httpx-instrumentation.md create mode 100644 src/mpp/instrumentation.py create mode 100644 tests/httpx/test_instrumentation.py diff --git a/.changelog/httpx-instrumentation.md b/.changelog/httpx-instrumentation.md new file mode 100644 index 0000000..26b8d96 --- /dev/null +++ b/.changelog/httpx-instrumentation.md @@ -0,0 +1,5 @@ +--- +pympp: minor +--- + +Add scoped HTTPX instrumentation for payment-aware Python harnesses. diff --git a/README.md b/README.md index eee484c..fb48d95 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,25 @@ the client when its adapter seam is incompatible. The explicit `PaymentTransport` and `SyncPaymentTransport` remain available on newer HTTPX versions. +For harnesses that create their own standard HTTPX clients, instrumentation can +apply the same behavior within a scope: + +```python +from mpp.instrumentation import instrument + +with PaymentRuntime( + method_factories=[method_factory], + allowed_origins=["https://api.example.com"], +) as runtime: + with instrument(runtime): + existing_harness.run() +``` + +Context scope is the default. Use `scope="process"` for independent worker +threads in a single-wallet process; ambiguous process bindings fail closed. +Process propagation covers raw threads and `ThreadPoolExecutor`, including +`asyncio.to_thread`, but not custom executors or process pools. + ## Examples | Example | Description | diff --git a/src/mpp/instrumentation.py b/src/mpp/instrumentation.py new file mode 100644 index 0000000..78f2c88 --- /dev/null +++ b/src/mpp/instrumentation.py @@ -0,0 +1,362 @@ +"""Scoped HTTPX instrumentation for payment-aware Python harnesses.""" + +from __future__ import annotations + +import inspect +import threading +from concurrent.futures import ThreadPoolExecutor +from contextvars import ContextVar +from dataclasses import dataclass +from functools import wraps +from types import MethodType +from typing import Any, Literal + +import httpx + +from mpp._httpx import ( + HTTPX_ADAPTER_VERSIONS, + HttpxCompatibilityError, + _validate_httpx_compatibility, +) +from mpp.runtime import PaymentRuntime, payment_flow_active + +HTTPX_INSTRUMENTATION_VERSIONS = HTTPX_ADAPTER_VERSIONS +_PAYMENT_INTERNAL_THREAD = "_mpp_payment_internal_thread" + + +@dataclass(eq=False, slots=True) +class _Binding: + runtime: PaymentRuntime + scope: Literal["context", "process"] + active: bool = True + + +@dataclass(frozen=True, slots=True) +class _Patch: + owner: Any + name: str + original: Any + replacement: Any + + +_bindings: ContextVar[tuple[_Binding, ...] | None] = ContextVar( + "mpp_instrumentation_bindings", + default=None, +) +_httpx_active: ContextVar[bool] = ContextVar("mpp_httpx_instrumentation_active", default=False) +_payment_internal_work: ContextVar[bool | None] = ContextVar( + "mpp_payment_internal_work", + default=None, +) + + +class _PaymentWorkerState(threading.local): + internal: bool | None = None + + +_payment_worker_state = _PaymentWorkerState() + + +@dataclass(slots=True) +class InstrumentationHandle: + """Handle returned by :func:`instrument`.""" + + runtime: PaymentRuntime + _binding: _Binding + + def disable(self) -> None: + """Disable this binding and restore unused HTTPX patches.""" + binding = self._binding + with _state.lock: + if not binding.active: + return + binding.active = False + _state.bindings = [item for item in _state.bindings if item is not binding] + _restore_unused_patches() + + local = _bindings.get() + if local is not None: + _bindings.set(tuple(item for item in local if item is not binding)) + + def __enter__(self) -> InstrumentationHandle: + return self + + def __exit__(self, *_args: Any) -> None: + self.disable() + + +def instrument( + runtime: PaymentRuntime, + *, + scope: Literal["context", "process"] = "context", + allow_unrestricted: bool = False, +) -> InstrumentationHandle: + """Make existing and future standard HTTPX clients payment-aware. + + Bindings are context-local by default. Process scope supports harnesses + whose requests run on independent worker threads; ambiguous process + bindings fail closed. + """ + if scope not in ("context", "process"): + raise ValueError('scope must be "context" or "process"') + if runtime._allows_all_http_origins() and not allow_unrestricted: + raise ValueError( + "Global HTTPX instrumentation requires allowed_origins. " + "Pass allow_unrestricted=True to explicitly allow payments to any origin." + ) + binding = _Binding(runtime=runtime, scope=scope) + + with _state.lock: + had_httpx_patches = bool(_state.httpx_patches) + _install_httpx_patches() + try: + if scope == "process": + _install_worker_patches() + _state.bindings.append(binding) + except BaseException: + if not had_httpx_patches and not _state.bindings: + _restore_patches(_state.httpx_patches) + _state.httpx_patches = () + raise + + local = _bindings.get() + _bindings.set((*(() if local is None else local), binding)) + return InstrumentationHandle(runtime=runtime, _binding=binding) + + +class _InstrumentationState: + def __init__(self) -> None: + self.lock = threading.RLock() + self.bindings: list[_Binding] = [] + self.httpx_patches: tuple[_Patch, ...] = () + self.worker_patches: tuple[_Patch, ...] = () + + +_state = _InstrumentationState() + + +def _internal_payment_work() -> bool: + if _payment_worker_state.internal is not None: + return _payment_worker_state.internal + override = _payment_internal_work.get() + if override is not None: + return override + return bool(getattr(threading.current_thread(), _PAYMENT_INTERNAL_THREAD, False)) + + +def _select_runtime() -> PaymentRuntime | None: + if _internal_payment_work(): + return None + + local = _bindings.get() + if local is not None: + for binding in reversed(local): + if binding.active: + if binding.scope == "context": + return binding.runtime + break + + with _state.lock: + runtimes: list[PaymentRuntime] = [] + for binding in _state.bindings: + if not binding.active or binding.scope != "process": + continue + if all(runtime is not binding.runtime for runtime in runtimes): + runtimes.append(binding.runtime) + return runtimes[0] if len(runtimes) == 1 else None + + +def _runtime_for(client: httpx.Client | httpx.AsyncClient) -> PaymentRuntime | None: + if ( + getattr(client, "_mpp_payment_wrapped", False) + or payment_flow_active() + or _httpx_active.get() + or _internal_payment_work() + ): + return None + return getattr(client, "_mpp_payment_runtime", None) or _select_runtime() + + +def _patch_is_installed(patch: _Patch) -> bool: + try: + return inspect.getattr_static(patch.owner, patch.name) is patch.replacement + except AttributeError: + return False + + +def _install_httpx_patches() -> None: + if _state.httpx_patches: + if not all( + map( + _patch_is_installed, + (*_state.httpx_patches, *_state.worker_patches), + ) + ): + raise HttpxCompatibilityError( + "Active pympp HTTPX instrumentation was replaced by another patch" + ) + return + + ( + original_sync_send_single, + original_async_send_single, + original_sync_send, + original_async_send, + ) = _validate_httpx_compatibility() + + @wraps(original_sync_send_single) + def sync_send_single( + self: httpx.Client, + request: httpx.Request, + ) -> httpx.Response: + runtime = _runtime_for(self) + if runtime is None: + return original_sync_send_single(self, request) + token = _httpx_active.set(True) + try: + return runtime.send_httpx_sync( + MethodType(original_sync_send_single, self), + request, + ) + finally: + _httpx_active.reset(token) + + @wraps(original_async_send_single) + async def async_send_single( + self: httpx.AsyncClient, + request: httpx.Request, + ) -> httpx.Response: + runtime = _runtime_for(self) + if runtime is None: + return await original_async_send_single(self, request) + token = _httpx_active.set(True) + try: + return await runtime.send_httpx( + MethodType(original_async_send_single, self), + request, + ) + finally: + _httpx_active.reset(token) + + @wraps(original_sync_send) + def sync_send( + self: httpx.Client, + request: httpx.Request, + *args: Any, + **kwargs: Any, + ) -> httpx.Response: + runtime = _runtime_for(self) + if runtime is None: + return original_sync_send(self, request, *args, **kwargs) + with runtime._httpx_operation_scope(request): + return original_sync_send(self, request, *args, **kwargs) + + @wraps(original_async_send) + async def async_send( + self: httpx.AsyncClient, + request: httpx.Request, + *args: Any, + **kwargs: Any, + ) -> httpx.Response: + runtime = _runtime_for(self) + if runtime is None: + return await original_async_send(self, request, *args, **kwargs) + with runtime._httpx_operation_scope(request): + return await original_async_send(self, request, *args, **kwargs) + + patches = ( + _Patch( + httpx.Client, + "_send_single_request", + original_sync_send_single, + sync_send_single, + ), + _Patch( + httpx.AsyncClient, + "_send_single_request", + original_async_send_single, + async_send_single, + ), + _Patch(httpx.Client, "send", original_sync_send, sync_send), + _Patch(httpx.AsyncClient, "send", original_async_send, async_send), + ) + _install_patches(patches) + _state.httpx_patches = patches + + +def _install_worker_patches() -> None: + if _state.worker_patches: + if not all(map(_patch_is_installed, _state.worker_patches)): + raise HttpxCompatibilityError( + "Active pympp worker instrumentation was replaced by another patch" + ) + return + + original_thread_start = threading.Thread.start + original_executor_submit = ThreadPoolExecutor.submit + + @wraps(original_thread_start) + def thread_start(self: threading.Thread, *args: Any, **kwargs: Any) -> Any: + if payment_flow_active() or _internal_payment_work(): + setattr(self, _PAYMENT_INTERNAL_THREAD, True) + return original_thread_start(self, *args, **kwargs) + + @wraps(original_executor_submit) + def executor_submit( + self: ThreadPoolExecutor, + fn: Any, + /, + *args: Any, + **kwargs: Any, + ) -> Any: + internal = payment_flow_active() or _internal_payment_work() + + def run_with_payment_context() -> Any: + previous = _payment_worker_state.internal + _payment_worker_state.internal = internal + token = _payment_internal_work.set(internal) + try: + return fn(*args, **kwargs) + finally: + _payment_internal_work.reset(token) + _payment_worker_state.internal = previous + + return original_executor_submit(self, run_with_payment_context) + + patches = ( + _Patch(threading.Thread, "start", original_thread_start, thread_start), + _Patch( + ThreadPoolExecutor, + "submit", + original_executor_submit, + executor_submit, + ), + ) + _install_patches(patches) + _state.worker_patches = patches + + +def _install_patches(patches: tuple[_Patch, ...]) -> None: + installed: list[_Patch] = [] + try: + for patch in patches: + setattr(patch.owner, patch.name, patch.replacement) + installed.append(patch) + except BaseException: + _restore_patches(tuple(installed)) + raise + + +def _restore_patches(patches: tuple[_Patch, ...]) -> None: + for patch in reversed(patches): + if _patch_is_installed(patch): + setattr(patch.owner, patch.name, patch.original) + + +def _restore_unused_patches() -> None: + if not any(binding.active and binding.scope == "process" for binding in _state.bindings): + _restore_patches(_state.worker_patches) + _state.worker_patches = () + if not any(binding.active for binding in _state.bindings): + _restore_patches(_state.httpx_patches) + _state.httpx_patches = () diff --git a/src/mpp/runtime.py b/src/mpp/runtime.py index a78945c..5664c65 100644 --- a/src/mpp/runtime.py +++ b/src/mpp/runtime.py @@ -46,10 +46,19 @@ def __init__(self, error: BaseException) -> None: self.error = error +@dataclass(slots=True) +class _PaymentFlow: + active: bool = True + + _RUNTIME_CONTEXT: ContextVar[tuple[tuple[object, str], ...]] = ContextVar( "mpp_runtime_context", default=(), ) +_PAYMENT_FLOW: ContextVar[_PaymentFlow | None] = ContextVar( + "mpp_payment_flow", + default=None, +) @dataclass(frozen=True, slots=True) @@ -112,6 +121,23 @@ def _scope_active(key: object, kind: str) -> bool: ) +@contextmanager +def _payment_flow(): + flow = _PaymentFlow() + token = _PAYMENT_FLOW.set(flow) + try: + yield + finally: + flow.active = False + _PAYMENT_FLOW.reset(token) + + +def payment_flow_active() -> bool: + """Return whether the current context is handling a payment flow.""" + flow = _PAYMENT_FLOW.get() + return flow is not None and flow.active + + async def _wait_for_task(task: asyncio.Task[_T]) -> _T: """Wait for completion while preserving an already-raised cancellation.""" while not task.done(): @@ -383,7 +409,7 @@ def _in_paid_operation(self) -> bool: return _scope_active(self._scope_key, "paid") async def _initialize_methods(self) -> None: - with _runtime_scope(self._scope_key, "lifecycle"): + with _runtime_scope(self._scope_key, "lifecycle"), _payment_flow(): async with AsyncExitStack() as stack: methods: list[Method] = [] for factory in self._method_factories: @@ -402,7 +428,7 @@ async def _initialize_methods(self) -> None: async def _teardown_methods(self) -> None: stack, self._method_stack = self._method_stack, None if stack is not None: - with _runtime_scope(self._scope_key, "lifecycle"): + with _runtime_scope(self._scope_key, "lifecycle"), _payment_flow(): await stack.aclose() def start(self) -> Self: @@ -679,6 +705,9 @@ def allows_http_payment(self, url: httpx.URL) -> bool: """Return whether credentials may be created for an HTTP origin.""" return self._allowed.http_url(url) + def _allows_all_http_origins(self) -> bool: + return self._allowed._allow_all + def _httpx_adapter_active(self) -> bool: return _HTTPX_ADAPTER_RUNTIME.get() == id(self) @@ -1052,35 +1081,46 @@ async def _create_credential( *, event_payload: dict[str, Any] | None = None, ) -> Credential: - payload = { - **(event_payload or {}), - "challenge": challenge, - "method": method, - } - payload.setdefault("challenges", [challenge]) - event_credential = await self.events.emit( - CHALLENGE_RECEIVED, - payload, - first_result=True, - ) - credential = ( - event_credential - if isinstance(event_credential, Credential) - else await method.create_credential(challenge) - ) - await self.events.emit( - CREDENTIAL_CREATED, - {**payload, "credential": credential}, - ) - return credential + with _payment_flow(): + payload = { + **(event_payload or {}), + "challenge": challenge, + "method": method, + } + payload.setdefault("challenges", [challenge]) + event_credential = await self.events.emit( + CHALLENGE_RECEIVED, + payload, + first_result=True, + ) + credential = ( + event_credential + if isinstance(event_credential, Credential) + else await method.create_credential(challenge) + ) + await self.events.emit( + CREDENTIAL_CREATED, + {**payload, "credential": credential}, + ) + return credential async def emit_event(self, name: str, payload: EventPayload) -> Any: """Emit an event on the runtime-owned loop.""" - return await self._run_async(self.events.emit(name, payload)) + return await self._run_async(self._emit_event(name, payload)) + + async def _emit_event( + self, + name: str, + payload: EventPayload, + *, + first_result: bool = False, + ) -> Any: + with _payment_flow(): + return await self.events.emit(name, payload, first_result=first_result) def emit_event_sync(self, name: str, payload: EventPayload) -> Any: """Synchronously emit an event on the runtime-owned loop.""" - return self._run_sync(self.events.emit(name, payload)) + return self._run_sync(self._emit_event(name, payload)) def close(self) -> None: """Close method resources and the owned event loop.""" diff --git a/tests/httpx/test_instrumentation.py b/tests/httpx/test_instrumentation.py new file mode 100644 index 0000000..6cef173 --- /dev/null +++ b/tests/httpx/test_instrumentation.py @@ -0,0 +1,494 @@ +"""Tests for scoped HTTPX payment instrumentation.""" + +from __future__ import annotations + +import asyncio +import threading +from concurrent.futures import ThreadPoolExecutor +from contextvars import copy_context +from typing import Any + +import httpx +import pytest + +import mpp.instrumentation as instrumentation +from mpp import Challenge, Credential +from mpp.instrumentation import HttpxCompatibilityError, instrument +from mpp.runtime import PaymentRuntime + + +class Method: + name = "tempo" + _intents = {"charge": True} + + def __init__(self) -> None: + self.calls = 0 + self.on_create: Any | None = None + + async def create_credential(self, challenge: Challenge) -> Credential: + self.calls += 1 + if self.on_create is not None: + result = self.on_create() + if asyncio.iscoroutine(result): + await result + return Credential(challenge=challenge.to_echo(), payload={"call": self.calls}) + + +def payment_required(challenge_id: str = "test-id") -> httpx.Response: + challenge = Challenge( + id=challenge_id, + method="tempo", + intent="charge", + request={"amount": "1000"}, + ) + return httpx.Response( + 402, + headers={"www-authenticate": challenge.to_www_authenticate("example.com")}, + ) + + +def paid_handler(request: httpx.Request) -> httpx.Response: + if "authorization" in request.headers: + return httpx.Response(200, content=b"paid") + return payment_required(request.url.path) + + +@pytest.fixture(autouse=True) +def restore_instrumentation(): + originals = ( + httpx.Client._send_single_request, + httpx.AsyncClient._send_single_request, + httpx.Client.send, + httpx.AsyncClient.send, + threading.Thread.start, + ThreadPoolExecutor.submit, + ) + bindings_token = instrumentation._bindings.set(None) + active_token = instrumentation._httpx_active.set(False) + internal_token = instrumentation._payment_internal_work.set(None) + instrumentation._payment_worker_state.internal = None + yield + ( + httpx.Client._send_single_request, + httpx.AsyncClient._send_single_request, + httpx.Client.send, + httpx.AsyncClient.send, + threading.Thread.start, + ThreadPoolExecutor.submit, + ) = originals + instrumentation._bindings.reset(bindings_token) + instrumentation._httpx_active.reset(active_token) + instrumentation._payment_internal_work.reset(internal_token) + instrumentation._payment_worker_state.internal = None + instrumentation._state.bindings = [] + instrumentation._state.httpx_patches = () + instrumentation._state.worker_patches = () + + +def test_sync_instrumentation_covers_existing_and_future_clients() -> None: + method = Method() + runtime = PaymentRuntime([method]) + existing = httpx.Client(transport=httpx.MockTransport(paid_handler)) + original_thread_start = threading.Thread.start + original_executor_submit = ThreadPoolExecutor.submit + try: + with instrument(runtime, allow_unrestricted=True): + assert threading.Thread.start is original_thread_start + assert ThreadPoolExecutor.submit is original_executor_submit + assert existing.get("https://example.com/existing").status_code == 200 + with httpx.Client(transport=httpx.MockTransport(paid_handler)) as future: + assert future.get("https://example.com/future").status_code == 200 + finally: + existing.close() + runtime.close() + + assert method.calls == 2 + + +@pytest.mark.asyncio +async def test_async_instrumentation_preserves_response_hooks() -> None: + seen: list[int] = [] + + async def hook(response: httpx.Response) -> None: + seen.append(response.status_code) + response.raise_for_status() + + runtime = PaymentRuntime([Method()]) + client = httpx.AsyncClient( + transport=httpx.MockTransport(paid_handler), + event_hooks={"response": [hook]}, + ) + try: + with instrument(runtime, allow_unrestricted=True): + response = await client.get("https://example.com/paid") + finally: + await client.aclose() + await runtime.aclose() + + assert response.content == b"paid" + assert seen == [200] + + +def test_process_scope_reaches_workers_but_context_scope_does_not() -> None: + method = Method() + runtime = PaymentRuntime([method]) + + def run(result: list[int]) -> None: + with httpx.Client(transport=httpx.MockTransport(paid_handler)) as client: + result.append(client.get("https://example.com/paid").status_code) + + context_result: list[int] = [] + with instrument(runtime, allow_unrestricted=True): + thread = threading.Thread(target=run, args=(context_result,)) + thread.start() + thread.join() + + process_result: list[int] = [] + original_thread_start = threading.Thread.start + original_executor_submit = ThreadPoolExecutor.submit + with instrument(runtime, scope="process", allow_unrestricted=True): + assert threading.Thread.start is not original_thread_start + assert ThreadPoolExecutor.submit is not original_executor_submit + thread = threading.Thread(target=run, args=(process_result,)) + thread.start() + thread.join() + runtime.close() + + assert context_result == [402] + assert process_result == [200] + assert method.calls == 1 + + +def test_ambiguous_process_bindings_fail_closed() -> None: + methods = [Method(), Method()] + runtimes = [PaymentRuntime([method]) for method in methods] + handles = [ + instrument(runtime, scope="process", allow_unrestricted=True) for runtime in runtimes + ] + result: list[int] = [] + copied = copy_context() + + assert instrumentation._select_runtime() is None + assert copied.run(instrumentation._select_runtime) is None + + def run() -> None: + with httpx.Client(transport=httpx.MockTransport(paid_handler)) as client: + result.append(client.get("https://example.com/paid").status_code) + + thread = threading.Thread(target=run) + thread.start() + thread.join() + for handle in handles: + handle.disable() + for runtime in runtimes: + runtime.close() + + assert result == [402] + assert [method.calls for method in methods] == [0, 0] + + +def test_nested_bindings_choose_innermost_and_restore_patches() -> None: + originals = ( + httpx.Client._send_single_request, + httpx.AsyncClient._send_single_request, + httpx.Client.send, + httpx.AsyncClient.send, + threading.Thread.start, + ThreadPoolExecutor.submit, + ) + methods = [Method(), Method()] + runtimes = [PaymentRuntime([method]) for method in methods] + outer = instrument(runtimes[0], allow_unrestricted=True) + patched = httpx.Client._send_single_request + inner = instrument(runtimes[1], scope="process", allow_unrestricted=True) + try: + assert threading.Thread.start is not originals[4] + assert ThreadPoolExecutor.submit is not originals[5] + with httpx.Client(transport=httpx.MockTransport(paid_handler)) as client: + assert client.get("https://example.com/inner").status_code == 200 + inner.disable() + assert threading.Thread.start is originals[4] + assert ThreadPoolExecutor.submit is originals[5] + with httpx.Client(transport=httpx.MockTransport(paid_handler)) as client: + assert client.get("https://example.com/outer").status_code == 200 + assert httpx.Client._send_single_request is patched + finally: + outer.disable() + for runtime in runtimes: + runtime.close() + + assert [method.calls for method in methods] == [1, 1] + assert ( + httpx.Client._send_single_request, + httpx.AsyncClient._send_single_request, + httpx.Client.send, + httpx.AsyncClient.send, + threading.Thread.start, + ThreadPoolExecutor.submit, + ) == originals + + +def test_disable_does_not_overwrite_later_patch() -> None: + runtime = PaymentRuntime([]) + handle = instrument(runtime, allow_unrestricted=True) + + def replacement(self: httpx.Client, request: httpx.Request) -> httpx.Response: + return httpx.Response(204, request=request) + + httpx.Client._send_single_request = replacement + handle.disable() + runtime.close() + + assert httpx.Client._send_single_request is replacement + + +def test_method_http_is_not_recursively_instrumented() -> None: + paths: list[str] = [] + method = Method() + + def handler(request: httpx.Request) -> httpx.Response: + paths.append(request.url.path) + if request.url.path in {"/event", "/method"}: + return httpx.Response(204) + return paid_handler(request) + + def send_internal(path: str) -> None: + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + assert client.get(f"https://example.com/{path}").status_code == 204 + + method.on_create = lambda: send_internal("method") + runtime = PaymentRuntime([method]) + runtime.events.on("challenge.received", lambda _payload: send_internal("event")) + try: + with instrument(runtime, allow_unrestricted=True): + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + assert client.get("https://example.com/paid").status_code == 200 + finally: + runtime.close() + + assert paths == ["/paid", "/event", "/method", "/paid"] + + +def test_runtime_callbacks_are_not_recursively_instrumented() -> None: + statuses: list[int] = [] + + def send(path: str) -> None: + with httpx.Client(transport=httpx.MockTransport(paid_handler)) as client: + statuses.append(client.get(f"https://example.com/{path}").status_code) + + class ManagedMethod(Method): + async def __aenter__(self) -> ManagedMethod: + send("enter") + return self + + async def __aexit__(self, *_args: Any) -> None: + send("exit") + + runtime = PaymentRuntime(method_factories=[ManagedMethod]) + runtime.events.on("test", lambda _payload: send("event")) + with instrument(runtime, scope="process", allow_unrestricted=True): + try: + runtime.start() + runtime.emit_event_sync("test", {}) + finally: + runtime.close() + + assert statuses == [402, 402, 402] + + +def test_response_hook_nested_send_has_an_independent_payment_budget() -> None: + method = Method() + runtime = PaymentRuntime([method]) + inner = httpx.Client(transport=httpx.MockTransport(paid_handler)) + + def hook(_response: httpx.Response) -> None: + assert inner.get("https://example.com/inner").status_code == 200 + + outer = httpx.Client( + transport=httpx.MockTransport(paid_handler), + event_hooks={"response": [hook]}, + ) + try: + with instrument(runtime, allow_unrestricted=True): + response = outer.get("https://example.com/outer") + finally: + outer.close() + inner.close() + runtime.close() + + assert response.content == b"paid" + assert method.calls == 2 + + +def test_process_scope_suppresses_raw_method_threads() -> None: + method = Method() + internal_statuses: list[int] = [] + + def on_create() -> None: + def request() -> None: + with httpx.Client(transport=httpx.MockTransport(paid_handler)) as client: + internal_statuses.append(client.get("https://example.com/internal").status_code) + + thread = threading.Thread(target=request) + thread.start() + thread.join() + + method.on_create = on_create + runtime = PaymentRuntime([method]) + try: + with instrument(runtime, scope="process", allow_unrestricted=True): + with httpx.Client(transport=httpx.MockTransport(paid_handler)) as client: + assert client.get("https://example.com/outer").status_code == 200 + finally: + runtime.close() + + assert internal_statuses == [402] + assert method.calls == 1 + + +def test_executor_suppression_ends_with_payment_flow() -> None: + method = Method() + internal_statuses: list[int] = [] + + def send(path: str) -> int: + with httpx.Client(transport=httpx.MockTransport(paid_handler)) as client: + return client.get(f"https://example.com/{path}").status_code + + async def on_create() -> None: + if method.calls == 1: + internal_statuses.append( + await asyncio.get_running_loop().run_in_executor(executor, send, "internal") + ) + + method.on_create = on_create + runtime = PaymentRuntime([method]) + with ThreadPoolExecutor(max_workers=1) as executor: + executor.submit(lambda: None).result() + try: + with instrument(runtime, scope="process", allow_unrestricted=True): + assert send("outer") == 200 + assert executor.submit(send, "unrelated").result() == 200 + finally: + runtime.close() + + assert internal_statuses == [402] + assert method.calls == 2 + + +def test_to_thread_worker_is_not_permanently_internal() -> None: + method = Method() + executor: ThreadPoolExecutor | None = None + internal_statuses: list[int] = [] + + def send(path: str) -> int: + with httpx.Client(transport=httpx.MockTransport(paid_handler)) as client: + return client.get(f"https://example.com/{path}").status_code + + async def on_create() -> None: + nonlocal executor + if method.calls == 1: + executor = ThreadPoolExecutor(max_workers=1) + asyncio.get_running_loop().set_default_executor(executor) + internal_statuses.append(await asyncio.to_thread(send, "internal")) + + async def send_from_copied_context() -> int: + assert executor is not None + asyncio.get_running_loop().set_default_executor(executor) + return await asyncio.to_thread(send, "unrelated") + + method.on_create = on_create + runtime = PaymentRuntime([method]) + try: + with instrument(runtime, scope="process", allow_unrestricted=True): + assert send("outer") == 200 + assert asyncio.run(send_from_copied_context()) == 200 + finally: + runtime.close() + if executor is not None: + executor.shutdown() + + assert internal_statuses == [402] + assert method.calls == 2 + + +def test_executor_patch_accepts_opaque_callables() -> None: + class OpaqueCallable: + __slots__ = () + + def __call__(self) -> int: + return 42 + + runtime = PaymentRuntime([]) + try: + with instrument(runtime, scope="process", allow_unrestricted=True): + with ThreadPoolExecutor(max_workers=1) as executor: + assert executor.submit(OpaqueCallable()).result() == 42 + finally: + runtime.close() + + +def test_stale_context_falls_back_to_active_process_binding() -> None: + stale_runtime = PaymentRuntime([]) + stale_handle = instrument(stale_runtime, allow_unrestricted=True) + stale_context = copy_context() + stale_handle.disable() + + method = Method() + runtime = PaymentRuntime([method]) + client = httpx.Client(transport=httpx.MockTransport(paid_handler)) + try: + with instrument(runtime, scope="process", allow_unrestricted=True): + response = stale_context.run(client.get, "https://example.com/paid") + finally: + client.close() + runtime.close() + stale_runtime.close() + + assert response.status_code == 200 + assert method.calls == 1 + + +@pytest.mark.parametrize("scope", ["invalid", "", "PROCESS"]) +def test_invalid_scope_fails_without_patching(scope: str) -> None: + original = httpx.Client._send_single_request + runtime = PaymentRuntime([]) + with pytest.raises(ValueError, match="scope"): + instrument(runtime, scope=scope) # type: ignore[arg-type] + runtime.close() + assert httpx.Client._send_single_request is original + + +def test_unrestricted_runtime_requires_explicit_opt_in() -> None: + original = httpx.Client._send_single_request + runtime = PaymentRuntime([]) + with pytest.raises(ValueError, match="allow_unrestricted=True"): + instrument(runtime) + assert httpx.Client._send_single_request is original + + with instrument(runtime, allow_unrestricted=True): + assert httpx.Client._send_single_request is not original + runtime.close() + assert httpx.Client._send_single_request is original + + +def test_reinstrument_fails_if_active_patch_was_replaced() -> None: + runtime = PaymentRuntime([]) + handle = instrument(runtime, allow_unrestricted=True) + installed_send = httpx.Client.send + + def replacement( + self: httpx.Client, + request: httpx.Request, + *args: Any, + **kwargs: Any, + ) -> httpx.Response: + return installed_send(self, request, *args, **kwargs) + + httpx.Client.send = replacement # type: ignore[method-assign] + try: + with pytest.raises(HttpxCompatibilityError, match="was replaced"): + instrument(runtime, allow_unrestricted=True) + finally: + httpx.Client.send = installed_send # type: ignore[method-assign] + handle.disable() + runtime.close() From 66958a3b691c94677a21eb4a4438a7750292e609 Mon Sep 17 00:00:00 2001 From: Parv Ahuja <17094219+parvahuja@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:09:01 -0700 Subject: [PATCH 11/11] feat: share runtime with MCP clients --- .changelog/runtime-mcp.md | 6 + .github/workflows/ci.yml | 28 +- README.md | 14 + pyproject.toml | 2 +- src/mpp/events.py | 14 +- src/mpp/extensions/mcp/__init__.py | 2 +- src/mpp/extensions/mcp/__init__.pyi | 2 +- src/mpp/extensions/mcp/client.py | 177 +++++------ src/mpp/runtime.py | 422 +++++++++++++++++++++++++- tests/test_mcp_client.py | 55 +++- tests/test_mcp_runtime.py | 441 ++++++++++++++++++++++++++++ 11 files changed, 1036 insertions(+), 127 deletions(-) create mode 100644 .changelog/runtime-mcp.md create mode 100644 tests/test_mcp_runtime.py diff --git a/.changelog/runtime-mcp.md b/.changelog/runtime-mcp.md new file mode 100644 index 0000000..6c9ed28 --- /dev/null +++ b/.changelog/runtime-mcp.md @@ -0,0 +1,6 @@ +--- +pympp: minor +--- + +Let explicit MCP clients share a `PaymentRuntime`, including origin policy, +payment events, and fail-closed protection for uncertain payment outcomes. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ca591e..dd4a153 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: ci-gate: name: CI Gate if: always() - needs: [test, httpx-adapter, integration, package, install-smoke] + needs: [test, httpx-adapter, mcp-compat, integration, package, install-smoke] runs-on: ubuntu-latest steps: - env: @@ -121,6 +121,32 @@ jobs: - name: Run adapter tests run: uv run --no-sync pytest -v --confcutdir=tests/httpx tests/httpx + mcp-compat: + name: MCP 1.19.0 + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + + - name: Set up Python + run: uv python install 3.12 + + - name: Install minimum supported MCP SDK + run: | + uv venv .compat-venv --python 3.12 + uv pip install --python .compat-venv/bin/python \ + -e ".[mcp]" "mcp==1.19.0" pytest pytest-asyncio + + - name: Run MCP client tests + run: | + .compat-venv/bin/python -m pytest -q --noconftest \ + tests/test_mcp_client.py tests/test_mcp_runtime.py + integration: name: Integration Test runs-on: ubuntu-latest diff --git a/README.md b/README.md index fb48d95..1d991c8 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,20 @@ async with PaymentRuntime( response = await client.get("https://api.example.com/paid") ``` +The optional MCP client can explicitly share that runtime and its origin policy, +events, and unknown-outcome protection: + +```python +from mpp.extensions.mcp import McpClient + +async with PaymentRuntime( + method_factories=[method_factory], + allowed_origins=["api.example.com"], +) as runtime: + async with McpClient(session, runtime=runtime) as client: + result = await client.call_tool("premium_tool", {"query": "hello"}) +``` + If a credential is sent but its outcome cannot be confirmed, matching attempts raise `PaymentOutcomeUnknownError` instead of paying again. Reconcile those payments externally before calling diff --git a/pyproject.toml b/pyproject.toml index 569a7eb..164e2dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ stripe = [ server = ["pydantic>=2.0"] redis = ["redis>=5.0"] sqlite = ["aiosqlite>=0.20"] -mcp = ["mcp>=1.1,<2"] +mcp = ["mcp>=1.19,<2"] dev = [ "pytest>=8.0", "pytest-asyncio>=0.24", diff --git a/src/mpp/events.py b/src/mpp/events.py index 64b8f81..2054d2f 100644 --- a/src/mpp/events.py +++ b/src/mpp/events.py @@ -58,9 +58,9 @@ class ClientChallengeReceivedPayload(TypedDict): challenge: Challenge challenges: list[Challenge] method: Any - protocol: NotRequired[Literal["http"]] - request: httpx.Request - response: httpx.Response + protocol: NotRequired[Literal["http", "mcp"]] + request: NotRequired[httpx.Request] + response: Any class ClientCredentialCreatedPayload(ClientChallengeReceivedPayload): @@ -68,7 +68,7 @@ class ClientCredentialCreatedPayload(ClientChallengeReceivedPayload): class ClientPaymentResponsePayload(ClientCredentialCreatedPayload): - response: httpx.Response + pass class ClientPaymentFailedPayload(TypedDict): @@ -77,9 +77,9 @@ class ClientPaymentFailedPayload(TypedDict): credential: Credential | None error: Exception method: Any | None - protocol: NotRequired[Literal["http"]] - request: httpx.Request - response: httpx.Response + protocol: NotRequired[Literal["http", "mcp"]] + request: NotRequired[httpx.Request] + response: Any class ServerChallengeCreatedPayload(TypedDict): diff --git a/src/mpp/extensions/mcp/__init__.py b/src/mpp/extensions/mcp/__init__.py index 351cd2d..8b8d707 100644 --- a/src/mpp/extensions/mcp/__init__.py +++ b/src/mpp/extensions/mcp/__init__.py @@ -76,8 +76,8 @@ async def expensive_tool(query: str, *, credential, receipt) -> str: "mpp.extensions.mcp.client": ( "McpClient", "McpToolResult", - "PaymentOutcomeUnknownError", ), + "mpp.errors": ("PaymentOutcomeUnknownError",), "mpp.extensions.mcp.decorator": ("pay",), "mpp.extensions.mcp.errors": ( "MalformedCredentialError", diff --git a/src/mpp/extensions/mcp/__init__.pyi b/src/mpp/extensions/mcp/__init__.pyi index 99268c3..5249058 100644 --- a/src/mpp/extensions/mcp/__init__.pyi +++ b/src/mpp/extensions/mcp/__init__.pyi @@ -1,7 +1,7 @@ +from mpp.errors import PaymentOutcomeUnknownError as _PaymentOutcomeUnknownError from mpp.extensions.mcp.capabilities import payment_capabilities as _payment_capabilities from mpp.extensions.mcp.client import McpClient as _McpClient from mpp.extensions.mcp.client import McpToolResult as _McpToolResult -from mpp.extensions.mcp.client import PaymentOutcomeUnknownError as _PaymentOutcomeUnknownError from mpp.extensions.mcp.constants import CODE_MALFORMED_CREDENTIAL as _CODE_MALFORMED_CREDENTIAL from mpp.extensions.mcp.constants import CODE_PAYMENT_REQUIRED as _CODE_PAYMENT_REQUIRED from mpp.extensions.mcp.constants import ( diff --git a/src/mpp/extensions/mcp/client.py b/src/mpp/extensions/mcp/client.py index 7310f3c..fc64a14 100644 --- a/src/mpp/extensions/mcp/client.py +++ b/src/mpp/extensions/mcp/client.py @@ -18,64 +18,44 @@ async with ClientSession(streams[0], streams[1]) as session: await session.initialize() - client = McpClient(session, methods=[method]) - result = await client.call_tool("premium_tool", {"query": "hello"}) - print(result.receipt) + async with McpClient(session, methods=[method]) as client: + result = await client.call_tool("premium_tool", {"query": "hello"}) + print(result.receipt) """ from __future__ import annotations +import asyncio import logging from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable +from datetime import timedelta +from typing import Any +from mpp.errors import PaymentOutcomeUnknownError as PaymentOutcomeUnknownError from mpp.extensions.mcp.constants import CODE_PAYMENT_REQUIRED, META_RECEIPT -from mpp.extensions.mcp.types import MCPChallenge, MCPCredential, MCPReceipt - -if TYPE_CHECKING: - from mpp import Challenge, Credential +from mpp.extensions.mcp.types import MCPChallenge, MCPReceipt +from mpp.runtime import Method, PaymentRuntime, _CallerLoopRuntime logger = logging.getLogger(__name__) -class PaymentOutcomeUnknownError(RuntimeError): - """Raised when a paid retry fails after a credential was attached.""" - - def __init__(self, challenge: MCPChallenge, cause: Exception) -> None: - self.challenge = challenge - self.cause = cause - super().__init__( - "Tool call failed after sending a payment credential; " - f"payment outcome is unknown for challenge {challenge.id}. " - "Do not blindly retry." - ) - - -@runtime_checkable -class Method(Protocol): - """Payment method interface for MCP client credential creation.""" - - name: str - - async def create_credential(self, challenge: Challenge) -> Credential: - """Create a credential to satisfy the given challenge.""" - ... +def _error_detail(error: Exception) -> Any: + nested = getattr(error, "error", None) + return nested if nested is not None else (error.args[0] if error.args else None) def _error_code(error: Exception) -> int | None: code = getattr(error, "code", None) if code is not None: return code - nested = getattr(error, "error", None) - return getattr(nested, "code", None) + return getattr(_error_detail(error), "code", None) def _error_data(error: Exception) -> Any: data = getattr(error, "data", None) if data is not None: return data - nested = getattr(error, "error", None) - return getattr(nested, "data", None) + return getattr(_error_detail(error), "data", None) def _is_payment_required_error(error: Exception) -> bool: @@ -163,28 +143,69 @@ class McpClient: Args: session: An initialized ``mcp.ClientSession``. - methods: Payment methods available for credential creation. + methods: Payment methods available for credential creation. Mutually + exclusive with ``runtime``. + runtime: An existing shared payment runtime. Mutually exclusive with + ``methods`` and not closed by this client. Example: - client = McpClient(session, methods=[tempo(...)]) - result = await client.call_tool("premium_tool", {"query": "hello"}) - print(result.receipt) + async with McpClient(session, runtime=runtime) as client: + result = await client.call_tool("premium_tool", {"query": "hello"}) + print(result.receipt) """ - def __init__(self, session: Any, methods: list[Method]) -> None: + def __init__( + self, + session: Any, + methods: list[Method] | None = None, + *, + runtime: PaymentRuntime | None = None, + ) -> None: self._session = session - self._methods = methods + if runtime is not None and methods is not None: + raise ValueError("Pass either methods or runtime, not both") + if runtime is None and methods is None: + raise ValueError("Pass methods or runtime") + self._owns_runtime = runtime is None + if runtime is None: + assert methods is not None + self._runtime = _CallerLoopRuntime(methods) + else: + self._runtime = runtime def __getattr__(self, name: str) -> Any: return getattr(self._session, name) + async def __aenter__(self) -> McpClient: + return self + + async def __aexit__(self, *_args: Any) -> None: + await self.aclose() + + def close(self) -> None: + """Close an owned runtime from synchronous code.""" + if not self._owns_runtime: + return + try: + asyncio.get_running_loop() + except RuntimeError: + self._runtime.close() + return + raise RuntimeError("Use 'await client.aclose()' inside a running event loop") + + async def aclose(self) -> None: + """Asynchronously close the runtime created by this client, if any.""" + if self._owns_runtime: + await self._runtime.aclose() + async def call_tool( self, name: str, arguments: dict[str, Any] | None = None, - *, + *args: Any, timeout: float | None = None, meta: dict[str, Any] | None = None, + **kwargs: Any, ) -> McpToolResult: """Call an MCP tool with automatic payment handling. @@ -197,6 +218,8 @@ async def call_tool( arguments: Tool arguments. timeout: Per-call timeout override (passed as ``read_timeout_seconds``). meta: Additional ``_meta`` fields to include in the request. + *args: Positional arguments accepted by the underlying MCP session. + **kwargs: Keyword arguments accepted by the underlying MCP session. Returns: An ``McpToolResult`` with the tool result and an optional receipt. @@ -206,72 +229,22 @@ async def call_tool( PaymentOutcomeUnknownError: If the paid retry fails after sending a credential. ValueError: If no installed method matches the server's challenge. """ - from mcp.shared.exceptions import McpError - - call_kwargs: dict[str, Any] = {} + call_kwargs = dict(kwargs) if timeout is not None: - call_kwargs["read_timeout_seconds"] = timeout + if args or "read_timeout_seconds" in call_kwargs: + raise TypeError("Pass either timeout or read_timeout_seconds, not both") + call_kwargs["read_timeout_seconds"] = timedelta(seconds=timeout) if meta is not None: call_kwargs["meta"] = meta - try: - result = await self._session.call_tool(name, arguments, **call_kwargs) - receipt = self._extract_receipt(result) - return McpToolResult(result=result, receipt=receipt) - - except McpError as e: - if not _is_payment_required_error(e): - raise - - challenges = _extract_challenges(e) - if not challenges: - raise ValueError("Server returned malformed payment challenges") from e - - challenge, method = self._match_challenge(challenges) - - core_credential = await method.create_credential(challenge.to_core()) - mcp_credential = MCPCredential.from_core(core_credential, challenge) - - retry_meta = dict(meta) if meta else {} - retry_meta.update(mcp_credential.to_meta()) - - retry_kwargs: dict[str, Any] = {"meta": retry_meta} - if timeout is not None: - retry_kwargs["read_timeout_seconds"] = timeout - - try: - retry_result = await self._session.call_tool(name, arguments, **retry_kwargs) - except Exception as exc: - raise PaymentOutcomeUnknownError(challenge, exc) from exc - - receipt = self._extract_receipt(retry_result) - return McpToolResult(result=retry_result, receipt=receipt) - - def _match_challenge(self, challenges: list[MCPChallenge]) -> tuple[MCPChallenge, Method]: - """Match a challenge to an installed method. - - Iterates installed methods in order (client preference) and returns - the first match by ``name`` and ``intent``. - """ - for method in self._methods: - supported_intents = self._intent_names(method) - for challenge in challenges: - if challenge.method == method.name and challenge.intent in supported_intents: - return challenge, method - - available = [challenge.method for challenge in challenges] - installed = [m.name for m in self._methods] - raise ValueError( - f"No compatible payment method. Server offered: {available}, client has: {installed}" + result = await self._runtime.call_mcp_tool( + self._session.call_tool, + name, + arguments, + *args, + **call_kwargs, ) - - @staticmethod - def _intent_names(method: Method) -> set[str]: - """Get intent names supported by a method.""" - intents = getattr(method, "intents", None) or getattr(method, "_intents", None) - if isinstance(intents, dict): - return set(intents.keys()) - return {"charge"} + return McpToolResult(result=result, receipt=self._extract_receipt(result)) @staticmethod def _extract_receipt(result: Any) -> MCPReceipt | None: diff --git a/src/mpp/runtime.py b/src/mpp/runtime.py index 5664c65..5e2b873 100644 --- a/src/mpp/runtime.py +++ b/src/mpp/runtime.py @@ -1,11 +1,13 @@ -"""Shared payment runtime for asynchronous HTTP clients.""" +"""Shared payment runtime for HTTP and MCP clients.""" from __future__ import annotations import asyncio import hashlib import inspect +import json import threading +import weakref from collections.abc import Awaitable, Callable, Coroutine, Mapping, Sequence from concurrent.futures import Future from contextlib import ( @@ -31,6 +33,8 @@ from mpp.events import ( CHALLENGE_RECEIVED, CREDENTIAL_CREATED, + PAYMENT_FAILED, + PAYMENT_RESPONSE, EventDispatcher, EventPayload, ) @@ -69,6 +73,14 @@ class _HttpPaymentTombstone: request: httpx.Request +@dataclass(frozen=True, slots=True) +class _McpPaymentTombstone: + challenge: Any + credential: Credential | None + cause: BaseException + endpoint_ref: weakref.ReferenceType[Any] | None + + @dataclass(eq=False, slots=True) class _HttpPaymentAttempt: runtime: PaymentRuntime @@ -93,6 +105,17 @@ class _HttpxOperation: active: bool = True +@dataclass(eq=False, slots=True) +class _McpPaymentAttempt: + challenge_key: str + operation_key: str + challenge: Any + endpoint: Any + credential: Credential | None = None + cause: BaseException | None = None + sent: bool = False + + _HTTPX_OPERATIONS: ContextVar[dict[int, _HttpxOperation] | None] = ContextVar( "mpp_httpx_operations", default=None, @@ -397,6 +420,11 @@ def __init__( self._http_unknown_circuit: _HttpPaymentTombstone | None = None self._http_idempotent_operations: dict[str, _HttpPaymentAttempt] = {} self._http_active_idempotent_operations: dict[str, int] = {} + self._mcp_attempt_lock = threading.Lock() + self._mcp_challenges: dict[str, _McpPaymentAttempt] = {} + self._mcp_unknown_challenges: dict[str, _McpPaymentTombstone] = {} + self._mcp_unknown_operations: dict[str, _McpPaymentTombstone] = {} + self._mcp_unknown_circuit: _McpPaymentTombstone | None = None self._max_unknown_outcomes = max_unknown_outcomes def _in_method_lifecycle(self) -> bool: @@ -578,6 +606,11 @@ def _clear_payment_state(self) -> None: self._http_unknown_circuit = None self._http_idempotent_operations.clear() self._http_active_idempotent_operations.clear() + with self._mcp_attempt_lock: + self._mcp_challenges.clear() + self._mcp_unknown_challenges.clear() + self._mcp_unknown_operations.clear() + self._mcp_unknown_circuit = None def reset_unknown_outcomes(self, *, reconciled: bool) -> None: """Reopen payments after every retained unknown outcome was reconciled. @@ -592,6 +625,10 @@ def reset_unknown_outcomes(self, *, reconciled: bool) -> None: self._http_unknown_challenges.clear() self._http_unknown_operations.clear() self._http_unknown_circuit = None + with self._mcp_attempt_lock: + self._mcp_unknown_challenges.clear() + self._mcp_unknown_operations.clear() + self._mcp_unknown_circuit = None def payment_transport(self, inner: httpx.AsyncBaseTransport | None = None) -> PaymentTransport: """Create an httpx transport using this runtime's payment methods.""" @@ -983,6 +1020,306 @@ def _set_http_request_markers( if attempt.retry_request is not None: attempt.retry_request.extensions[_HTTP_PAYMENT_ATTEMPT_EXTENSION] = tombstone + async def call_mcp_tool( + self, + call_tool: Any, + name: str, + arguments: dict[str, Any] | None = None, + *args: Any, + **kwargs: Any, + ) -> Any: + """Call an MCP tool with automatic payment handling.""" + from mpp.errors import PaymentOutcomeUnknownError + from mpp.extensions.mcp.client import ( + _extract_challenges, + _is_payment_required_error, + ) + from mpp.extensions.mcp.types import MCPCredential + + try: + result = await call_tool(name, arguments, *args, **kwargs) + except Exception as error: + if not _is_payment_required_error(error): + raise + challenge_response = error + else: + return result + + challenges = _extract_challenges(challenge_response) + allowed = [ + challenge for challenge in challenges if self._allowed.mcp_realm(challenge.realm) + ] + core_challenges = [challenge.to_core() for challenge in allowed] + + async def fail( + error: Exception, + *, + challenge: Challenge | None = None, + credential: Credential | None = None, + method: Method | None = None, + response: Any = challenge_response, + ) -> None: + await self.emit_event( + PAYMENT_FAILED, + { + "challenge": challenge, + "challenges": core_challenges, + "credential": credential, + "error": error, + "method": method, + "response": response, + "protocol": "mcp", + }, + ) + + if not allowed: + error = ValueError( + "Server returned malformed payment challenges or disallowed payment origins" + ) + await fail(error) + raise error from challenge_response + + await self.astart() + attempt: _McpPaymentAttempt | None = None + challenge: Any = None + method: Method | None = None + core_challenge: Challenge | None = None + core_credential: Credential | None = None + try: + current = [item for item in allowed if not _challenge_is_expired(item)] + try: + challenge, method = self.match_challenge(current) + except ValueError: + challenge, method = self.match_challenge(allowed) + + core_challenge = challenge.to_core() + assert core_challenge is not None + if _challenge_is_expired(challenge): + raise ValueError(f"Challenge expired at {challenge.expires}") + attempt = self._begin_mcp_payment(challenge, call_tool, name, arguments) + core_credential = await self.create_credential( + core_challenge, + method, + event_payload={ + "challenges": core_challenges, + "response": challenge_response, + "protocol": "mcp", + }, + ) + mcp_credential = MCPCredential.from_core(core_credential, challenge) + self._set_mcp_payment_credential(attempt, core_credential) + + retry_kwargs = dict(kwargs) + retry_meta = dict(retry_kwargs.get("meta") or {}) + retry_meta.update(mcp_credential.to_meta()) + retry_kwargs["meta"] = retry_meta + except BaseException as error: + if attempt is not None: + self._discard_mcp_payment(attempt) + if not isinstance(error, Exception): + raise + await fail( + error, + challenge=core_challenge, + credential=core_credential or getattr(error, "credential", None), + method=method, + ) + raise + + assert attempt is not None + assert core_credential is not None + try: + with self._paid_operation(): + try: + self._mark_mcp_payment_sent(attempt) + except PaymentOutcomeUnknownError as error: + await fail( + error, + challenge=core_challenge, + credential=core_credential, + method=method, + ) + raise + + try: + payment_response = await call_tool(name, arguments, *args, **retry_kwargs) + except BaseException as cause: + self._mark_mcp_payment_unknown(attempt, cause) + outcome_error = PaymentOutcomeUnknownError( + challenge, + cause, + credential=core_credential, + ) + if not isinstance(cause, Exception): + try: + await fail( + outcome_error, + challenge=core_challenge, + credential=core_credential, + method=method, + ) + except BaseException: + pass + raise + await fail( + outcome_error, + challenge=core_challenge, + credential=core_credential, + method=method, + ) + raise outcome_error from cause + + try: + await self.emit_event( + PAYMENT_RESPONSE, + { + "challenge": core_challenge, + "challenges": core_challenges, + "credential": core_credential, + "method": method, + "response": payment_response, + "protocol": "mcp", + }, + ) + except BaseException as error: + self._mark_mcp_payment_unknown(attempt, error) + raise + self._complete_mcp_payment(attempt) + return payment_response + except BaseException: + if not attempt.sent: + self._discard_mcp_payment(attempt) + raise + + def _begin_mcp_payment( + self, + challenge: Any, + call_tool: Any, + name: str, + arguments: dict[str, Any] | None, + ) -> _McpPaymentAttempt: + from mpp.errors import PaymentOutcomeUnknownError + + endpoint = getattr(call_tool, "__self__", None) + if endpoint is None: + endpoint = call_tool + challenge_key, operation_key = _mcp_attempt_keys( + challenge, + endpoint, + name, + arguments, + ) + with self._mcp_attempt_lock: + if circuit := self._mcp_unknown_circuit: + raise PaymentOutcomeUnknownError( + circuit.challenge, + circuit.cause, + credential=circuit.credential, + ) + existing = self._mcp_challenges.get(challenge_key) + if existing is None: + existing = self._mcp_unknown_challenges.get(challenge_key) + if existing is None: + existing = self._mcp_unknown_operations.get(operation_key) + if ( + isinstance(existing, _McpPaymentTombstone) + and existing.endpoint_ref is not None + and existing.endpoint_ref() is not endpoint + ): + self._mcp_unknown_operations.pop(operation_key, None) + existing = None + if existing is not None: + cause = existing.cause or RuntimeError( + "A matching MCP payment attempt is already in progress" + ) + raise PaymentOutcomeUnknownError( + existing.challenge, + cause, + credential=existing.credential, + ) + attempt = _McpPaymentAttempt( + challenge_key=challenge_key, + operation_key=operation_key, + challenge=challenge, + endpoint=endpoint, + ) + self._mcp_challenges[challenge_key] = attempt + return attempt + + def _set_mcp_payment_credential( + self, + attempt: _McpPaymentAttempt, + credential: Credential, + ) -> None: + with self._mcp_attempt_lock: + attempt.credential = credential + + def _mark_mcp_payment_sent(self, attempt: _McpPaymentAttempt) -> None: + from mpp.errors import PaymentOutcomeUnknownError + + with self._mcp_attempt_lock: + existing = self._mcp_unknown_circuit or self._mcp_unknown_operations.get( + attempt.operation_key + ) + if existing is not None: + self._remove_mcp_attempt_locked(attempt) + raise PaymentOutcomeUnknownError( + existing.challenge, + existing.cause, + credential=existing.credential, + ) + attempt.sent = True + + def _mark_mcp_payment_unknown( + self, + attempt: _McpPaymentAttempt, + cause: BaseException, + ) -> _McpPaymentTombstone: + with self._mcp_attempt_lock: + compact_cause = _compact_cause(cause) + attempt.cause = compact_cause + tombstone = _McpPaymentTombstone( + challenge=attempt.challenge, + credential=attempt.credential, + cause=compact_cause, + endpoint_ref=_weakref(attempt.endpoint), + ) + self._remove_mcp_attempt_locked(attempt) + if self._mcp_unknown_circuit is not None: + return tombstone + + if attempt.operation_key in self._mcp_unknown_operations: + if attempt.challenge_key not in self._mcp_unknown_challenges: + if len(self._mcp_unknown_challenges) >= self._max_unknown_outcomes: + self._mcp_unknown_circuit = tombstone + else: + self._mcp_unknown_challenges[attempt.challenge_key] = tombstone + return tombstone + + if ( + len(self._mcp_unknown_challenges) >= self._max_unknown_outcomes + or len(self._mcp_unknown_operations) >= self._max_unknown_outcomes + ): + self._mcp_unknown_circuit = tombstone + return tombstone + self._mcp_unknown_challenges[attempt.challenge_key] = tombstone + self._mcp_unknown_operations[attempt.operation_key] = tombstone + return tombstone + + def _discard_mcp_payment(self, attempt: _McpPaymentAttempt) -> None: + with self._mcp_attempt_lock: + if not attempt.sent: + self._remove_mcp_attempt_locked(attempt) + + def _complete_mcp_payment(self, attempt: _McpPaymentAttempt) -> None: + with self._mcp_attempt_lock: + if attempt.cause is None: + self._remove_mcp_attempt_locked(attempt) + + def _remove_mcp_attempt_locked(self, attempt: _McpPaymentAttempt) -> None: + if self._mcp_challenges.get(attempt.challenge_key) is attempt: + self._mcp_challenges.pop(attempt.challenge_key, None) + def match_challenge( self, challenges: Sequence[Any], @@ -1228,6 +1565,36 @@ def _http_idempotency_key(request: httpx.Request) -> str | None: ) +def _mcp_attempt_keys( + challenge: Any, + endpoint: Any, + name: str, + arguments: dict[str, Any] | None, +) -> tuple[str, str]: + realm = ( + repr(origin) + if (origin := _origin(challenge.realm)) is not None + else (_bare_host(challenge.realm) or challenge.realm.casefold()) + ) + challenge_key = _http_attempt_digest("mcp-challenge", realm, challenge.id) + try: + arguments_key = json.dumps( + arguments or {}, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + except (TypeError, ValueError): + arguments_key = repr(arguments) + operation_key = _http_attempt_digest( + "mcp-operation", + str(id(endpoint)), + name, + arguments_key, + ) + return challenge_key, operation_key + + def _http_attempt_digest(*parts: str) -> str: return hashlib.sha256("\0".join(parts).encode()).hexdigest() @@ -1247,6 +1614,13 @@ def _compact_request(request: httpx.Request) -> httpx.Request: return httpx.Request(request.method, request.url) +def _weakref(value: Any) -> weakref.ReferenceType[Any] | None: + try: + return weakref.ref(value) + except TypeError: + return None + + def _challenge_is_expired(challenge: Any) -> bool: expires_value = getattr(challenge, "expires", None) if expires_value is None: @@ -1265,13 +1639,44 @@ def _challenge_is_expired(challenge: Any) -> bool: class _AllowedOrigins: def __init__(self, allowed_origins: Sequence[str] | None) -> None: self._allow_all = allowed_origins is None - self._origins = { - origin for value in allowed_origins or () if (origin := _origin(str(value))) is not None - } + self._origins = set[tuple[str, str, int | None]]() + self._origin_hosts = set[str]() + self._realms = set[str]() + if allowed_origins is None: + return + for value in allowed_origins: + origin = _origin(str(value)) + if origin is not None: + self._origins.add(origin) + self._origin_hosts.add(origin[1]) + else: + realm = str(value) + self._realms.add(realm.casefold()) + if host := _bare_host(realm): + self._realms.add(host) def http_url(self, url: httpx.URL) -> bool: return self._allow_all or _httpx_origin(url) in self._origins + def mcp_realm(self, realm: str) -> bool: + if not isinstance(realm, str): + return False + origin = _origin(realm) + if "://" in realm and origin is None: + return False + if self._allow_all: + return True + if origin is not None: + return origin in self._origins or origin[1] in self._realms + normalized = realm.casefold() + host = _bare_host(realm) + return ( + normalized in self._realms + or normalized in self._origin_hosts + or host is not None + and (host in self._realms or host in self._origin_hosts) + ) + def _origin(value: str) -> tuple[str, str, int | None] | None: try: @@ -1283,6 +1688,15 @@ def _origin(value: str) -> tuple[str, str, int | None] | None: return _httpx_origin(url) +def _bare_host(value: str) -> str | None: + if not value or any(character.isspace() or character in "/@?#%" for character in value): + return None + try: + return httpx.URL(scheme="https", host=value).raw_host.decode("ascii").casefold() + except (httpx.InvalidURL, TypeError, UnicodeError): + return None + + def _httpx_origin(url: httpx.URL) -> tuple[str, str, int | None]: scheme = url.scheme.casefold() port = url.port diff --git a/tests/test_mcp_client.py b/tests/test_mcp_client.py index c1af214..2b11b30 100644 --- a/tests/test_mcp_client.py +++ b/tests/test_mcp_client.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import timedelta from typing import Any from unittest.mock import AsyncMock @@ -18,6 +19,7 @@ ) from mpp.extensions.mcp.client import _extract_challenges, _is_payment_required_error from mpp.extensions.mcp.types import MCPChallenge, MCPReceipt +from mpp.runtime import PaymentRuntime # --------------------------------------------------------------------------- # Helpers @@ -147,6 +149,15 @@ def test_real_current_mcp_sdk_error_data(self) -> None: assert _is_payment_required_error(err) is True assert _extract_challenges(err) == [_make_challenge()] + def test_args_error_data(self) -> None: + detail = FakeMcpErrorData( + -32042, + data={"challenges": [_make_challenge_dict()]}, + ) + + assert _is_payment_required_error(Exception(detail)) + assert _extract_challenges(Exception(detail)) == [_make_challenge()] + def test_wrong_code(self) -> None: err = FakeMcpError(-32000, data={"challenges": [_make_challenge_dict()]}) assert _is_payment_required_error(err) is False @@ -413,7 +424,31 @@ async def test_timeout_forwarded(self) -> None: await client.call_tool("tool", {}, timeout=60.0) _, kwargs = session.call_tool.call_args - assert kwargs.get("read_timeout_seconds") == 60.0 + assert kwargs.get("read_timeout_seconds") == timedelta(seconds=60) + + @pytest.mark.asyncio + @pytest.mark.parametrize("positional", [True, False]) + async def test_supported_call_shapes_are_forwarded(self, positional: bool) -> None: + session = AsyncMock() + session.call_tool = AsyncMock(return_value=FakeCallToolResult()) + timeout = timedelta(seconds=30) + progress = AsyncMock() + client = McpClient(session, methods=[FakeMethod()]) + + if positional: + await client.call_tool("tool", {}, timeout, progress, meta={"trace": "abc"}) + assert session.call_tool.call_args.args == ("tool", {}, timeout, progress) + else: + await client.call_tool( + "tool", + {}, + read_timeout_seconds=timeout, + progress_callback=progress, + ) + assert session.call_tool.call_args.kwargs == { + "read_timeout_seconds": timeout, + "progress_callback": progress, + } @pytest.mark.asyncio async def test_meta_forwarded(self) -> None: @@ -538,14 +573,14 @@ async def test_retry_failure_raises_payment_outcome_unknown(self) -> None: sys.modules[mod_name] = orig -class TestMcpClientMethodMatching: +class TestPaymentRuntimeMethodMatching: """Tests for challenge-to-method matching logic.""" def test_match_by_method_and_intent(self) -> None: method = FakeMethod(name="tempo", _intents={"charge": True}) - client = McpClient(AsyncMock(), methods=[method]) + runtime = PaymentRuntime([method]) - challenge, matched = client._match_challenge([_make_challenge()]) + challenge, matched = runtime.match_challenge([_make_challenge()]) assert isinstance(challenge, MCPChallenge) assert matched is method @@ -554,28 +589,28 @@ def test_match_prefers_client_order(self) -> None: stripe_method = FakeMethod(name="stripe", _intents={"charge": True}) tempo_method = FakeMethod(name="tempo", _intents={"charge": True}) - client = McpClient(AsyncMock(), methods=[stripe_method, tempo_method]) + runtime = PaymentRuntime([stripe_method, tempo_method]) challenges = [ _make_challenge(method="tempo"), _make_challenge(method="stripe"), ] - _, matched = client._match_challenge(challenges) + _, matched = runtime.match_challenge(challenges) assert matched is stripe_method def test_no_match_raises(self) -> None: method = FakeMethod(name="tempo", _intents={"charge": True}) - client = McpClient(AsyncMock(), methods=[method]) + runtime = PaymentRuntime([method]) with pytest.raises(ValueError, match="No compatible payment method"): - client._match_challenge([_make_challenge(method="stripe")]) + runtime.match_challenge([_make_challenge(method="stripe")]) def test_intent_mismatch(self) -> None: method = FakeMethod(name="tempo", _intents={"session": True}) - client = McpClient(AsyncMock(), methods=[method]) + runtime = PaymentRuntime([method]) with pytest.raises(ValueError, match="No compatible payment method"): - client._match_challenge([_make_challenge(method="tempo", intent="charge")]) + runtime.match_challenge([_make_challenge(method="tempo", intent="charge")]) def test_extract_challenges_skips_malformed_entries(self) -> None: err = FakeMcpError( diff --git a/tests/test_mcp_runtime.py b/tests/test_mcp_runtime.py new file mode 100644 index 0000000..992a20d --- /dev/null +++ b/tests/test_mcp_runtime.py @@ -0,0 +1,441 @@ +"""Focused tests for explicit MCP use of ``PaymentRuntime``.""" + +from __future__ import annotations + +import asyncio +import threading +from types import SimpleNamespace +from typing import Any + +import httpx +import pytest + +from mpp import Challenge, Credential, PaymentOutcomeUnknownError +from mpp.extensions.mcp import META_CREDENTIAL, McpClient +from mpp.runtime import PaymentRuntime, payment_flow_active + + +class McpError(Exception): + def __init__(self, challenges: list[dict[str, Any]]) -> None: + super().__init__("Payment required") + self.code = -32042 + self.data = {"challenges": challenges} + + +class Result: + meta = None + + +class Session: + def __init__(self, *items: Any) -> None: + self.items = list(items) + self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] + + async def call_tool(self, *args: Any, **kwargs: Any) -> Any: + self.calls.append((args, kwargs)) + item = self.items.pop(0) + if isinstance(item, BaseException): + raise item + return item + + +class Method: + name = "tempo" + intents = {"charge": object()} + + def __init__(self) -> None: + self.calls = 0 + self.loops: list[asyncio.AbstractEventLoop] = [] + + async def create_credential(self, challenge: Challenge) -> Credential: + self.calls += 1 + self.loops.append(asyncio.get_running_loop()) + return Credential( + challenge=challenge.to_echo(), + payload={"type": "transaction", "signature": "0xabc"}, + ) + + +def challenge( + challenge_id: str = "challenge-1", + *, + realm: str = "api.example.com", + expires: Any = None, +) -> dict[str, Any]: + value = { + "id": challenge_id, + "realm": realm, + "method": "tempo", + "intent": "charge", + "request": {"amount": "1"}, + } + if expires is not None: + value["expires"] = expires + return value + + +def payment_error(*challenges: dict[str, Any]) -> McpError: + return McpError(list(challenges) or [challenge()]) + + +@pytest.mark.asyncio +async def test_runtime_pays_and_preserves_result_metadata_and_events() -> None: + method = Method() + result = Result() + session = Session(payment_error(), result) + events: list[Any] = [] + runtime = PaymentRuntime([method], allowed_origins=["https://api.example.com"]) + runtime.events.on("*", events.append) + progress = object() + + try: + actual = await runtime.call_mcp_tool( + session.call_tool, + "premium", + {"query": "test"}, + None, + progress, + meta={"trace": "abc"}, + ) + finally: + await runtime.aclose() + + assert actual is result + assert session.calls[1][0] == ("premium", {"query": "test"}, None, progress) + assert session.calls[1][1]["meta"]["trace"] == "abc" + assert META_CREDENTIAL in session.calls[1][1]["meta"] + assert [event.name for event in events] == [ + "challenge.received", + "credential.created", + "payment.response", + ] + assert all(event.payload["protocol"] == "mcp" for event in events) + + +@pytest.mark.asyncio +async def test_payment_flow_scope_excludes_mcp_session_calls() -> None: + states: list[tuple[str, bool]] = [] + + class ScopedMethod(Method): + async def create_credential(self, challenge: Challenge) -> Credential: + states.append(("method", payment_flow_active())) + return await super().create_credential(challenge) + + calls = 0 + + async def call_tool(*_args: Any, **_kwargs: Any) -> Any: + nonlocal calls + states.append(("session", payment_flow_active())) + calls += 1 + if calls == 1: + raise payment_error() + return Result() + + runtime = PaymentRuntime([ScopedMethod()]) + runtime.events.on("*", lambda event: states.append((event.name, payment_flow_active()))) + try: + await runtime.call_mcp_tool(call_tool, "premium") + finally: + await runtime.aclose() + + assert states == [ + ("session", False), + ("challenge.received", True), + ("method", True), + ("credential.created", True), + ("session", False), + ("payment.response", True), + ] + + +@pytest.mark.asyncio +async def test_unexpired_offer_wins_over_compatible_expired_offer() -> None: + method = Method() + offered = payment_error( + challenge("expired", expires="2020-01-01T00:00:00Z"), + challenge("current"), + ) + session = Session(offered, Result()) + runtime = PaymentRuntime([method]) + try: + await runtime.call_mcp_tool(session.call_tool, "premium") + finally: + await runtime.aclose() + + credential = session.calls[1][1]["meta"][META_CREDENTIAL] + assert credential["challenge"]["id"] == "current" + + +@pytest.mark.asyncio +async def test_expired_challenge_reports_selected_offer_without_paying() -> None: + method = Method() + failed: list[dict[str, Any]] = [] + session = Session(payment_error(challenge(expires="2020-01-01T00:00:00Z"))) + runtime = PaymentRuntime([method]) + runtime.events.on("payment.failed", failed.append) + try: + with pytest.raises(ValueError, match="Challenge expired"): + await runtime.call_mcp_tool(session.call_tool, "premium") + finally: + await runtime.aclose() + + assert failed[0]["challenge"].id == "challenge-1" + assert failed[0]["credential"] is None + assert method.calls == 0 + + +@pytest.mark.asyncio +async def test_disallowed_or_invalid_realm_fails_without_paying() -> None: + for realm in ("other.example", "https://[malformed"): + method = Method() + session = Session(payment_error(challenge(realm=realm))) + runtime = PaymentRuntime([method], allowed_origins=["https://api.example.com"]) + try: + with pytest.raises(ValueError, match="disallowed"): + await runtime.call_mcp_tool(session.call_tool, "premium") + finally: + await runtime.aclose() + assert method.calls == 0 + assert len(session.calls) == 1 + + +@pytest.mark.asyncio +async def test_unknown_operation_is_session_scoped() -> None: + method = Method() + first = Session(payment_error(challenge("first")), TimeoutError("lost")) + second = Session(payment_error(challenge("second")), Result()) + runtime = PaymentRuntime([method]) + try: + with pytest.raises(PaymentOutcomeUnknownError): + await runtime.call_mcp_tool(first.call_tool, "premium", {"query": "same"}) + + result = await runtime.call_mcp_tool(second.call_tool, "premium", {"query": "same"}) + finally: + await runtime.aclose() + + assert isinstance(result, Result) + assert method.calls == 2 + + +@pytest.mark.asyncio +async def test_unknown_operation_blocks_repayment_until_reset() -> None: + method = Method() + session = Session( + payment_error(challenge("first")), + payment_error(challenge("retry")), + payment_error(challenge("second")), + payment_error(challenge("after-reset")), + Result(), + ) + runtime = PaymentRuntime([method]) + try: + with pytest.raises(PaymentOutcomeUnknownError): + await runtime.call_mcp_tool(session.call_tool, "premium", {"query": "same"}) + with pytest.raises(PaymentOutcomeUnknownError): + await runtime.call_mcp_tool(session.call_tool, "premium", {"query": "same"}) + with pytest.raises(ValueError, match="externally reconciled"): + runtime.reset_unknown_outcomes(reconciled=False) + + runtime.reset_unknown_outcomes(reconciled=True) + result = await runtime.call_mcp_tool(session.call_tool, "premium", {"query": "same"}) + finally: + await runtime.aclose() + + assert isinstance(result, Result) + assert method.calls == 2 + assert len(session.calls) == 5 + + +@pytest.mark.asyncio +async def test_concurrent_same_challenge_only_creates_one_credential() -> None: + entered = threading.Event() + release = threading.Event() + + class BlockingMethod(Method): + async def create_credential(self, challenge: Challenge) -> Credential: + entered.set() + await asyncio.to_thread(release.wait) + return await super().create_credential(challenge) + + method = BlockingMethod() + first = Session(payment_error(), Result()) + second = Session(payment_error(), Result()) + runtime = PaymentRuntime([method]) + first_call = asyncio.create_task(runtime.call_mcp_tool(first.call_tool, "premium")) + try: + assert await asyncio.to_thread(entered.wait, 1) + with pytest.raises(PaymentOutcomeUnknownError): + await runtime.call_mcp_tool(second.call_tool, "premium") + release.set() + assert isinstance(await first_call, Result) + finally: + release.set() + await runtime.aclose() + + assert method.calls == 1 + assert len(first.calls) == 2 + assert len(second.calls) == 1 + + +@pytest.mark.asyncio +async def test_close_waits_for_committed_retry() -> None: + retry_started = asyncio.Event() + retry_release = asyncio.Event() + calls = 0 + + async def call_tool(*_args: Any, **_kwargs: Any) -> Any: + nonlocal calls + calls += 1 + if calls == 1: + raise payment_error() + retry_started.set() + await retry_release.wait() + return Result() + + runtime = PaymentRuntime([Method()]) + request = asyncio.create_task(runtime.call_mcp_tool(call_tool, "premium")) + await asyncio.wait_for(retry_started.wait(), 1) + close = asyncio.create_task(runtime.aclose()) + await asyncio.sleep(0) + assert not close.done() + + retry_release.set() + assert isinstance(await request, Result) + await asyncio.wait_for(close, 1) + + +@pytest.mark.asyncio +async def test_mcp_client_runtime_ownership_and_caller_loop_compatibility() -> None: + caller_loop = asyncio.get_running_loop() + method = Method() + session = Session(payment_error(), Result()) + + async with McpClient(session, methods=[method]) as client: + await client.call_tool("premium") + assert client._runtime._bridge._thread is None + + assert method.loops == [caller_loop] + assert client._runtime._bridge._closed + + runtime = PaymentRuntime([Method()]) + borrowed = McpClient(Session(payment_error(), Result()), runtime=runtime) + await borrowed.call_tool("premium") + thread = runtime._bridge._thread + assert thread is not None and thread.is_alive() + await borrowed.aclose() + assert thread.is_alive() + await runtime.aclose() + + +@pytest.mark.asyncio +async def test_mcp_client_sync_close_requires_synchronous_context() -> None: + client = McpClient(Session(), methods=[]) + with pytest.raises(RuntimeError, match="await client.aclose"): + client.close() + await client.aclose() + + +def test_mcp_client_sync_close_outside_event_loop() -> None: + client = McpClient(Session(), methods=[]) + client.close() + with pytest.raises(RuntimeError, match="closed"): + client._runtime.start() + + +def test_mcp_client_requires_exactly_one_method_source() -> None: + runtime = PaymentRuntime([]) + with pytest.raises(ValueError, match="methods or runtime"): + McpClient(Session()) + with pytest.raises(ValueError, match="either methods or runtime"): + McpClient(Session(), [], runtime=runtime) + runtime.close() + + +def test_mcp_realm_policy_does_not_broaden_http_policy() -> None: + runtime = PaymentRuntime( + [], + allowed_origins=["https://bücher.example", "mcp.example.com"], + ) + try: + assert runtime._allowed.mcp_realm("xn--bcher-kva.example") + assert runtime._allowed.mcp_realm("mcp.example.com") + assert runtime.allows_http_payment(httpx.URL("https://xn--bcher-kva.example")) + assert not runtime.allows_http_payment(httpx.URL("https://mcp.example.com")) + assert not runtime._allowed.mcp_realm("https://bücher.example:8443") + finally: + runtime.close() + + +def test_send_boundary_rechecks_unknown_operation_and_bounded_circuit() -> None: + runtime = PaymentRuntime([], max_unknown_outcomes=1) + + async def endpoint(*_args: Any, **_kwargs: Any) -> None: + return None + + first = runtime._begin_mcp_payment( + SimpleNamespace(id="first", realm="api.example.com"), + endpoint, + "premium", + {}, + ) + raced = runtime._begin_mcp_payment( + SimpleNamespace(id="raced", realm="api.example.com"), + endpoint, + "premium", + {}, + ) + runtime._mark_mcp_payment_sent(first) + runtime._mark_mcp_payment_unknown(first, TimeoutError("lost")) + with pytest.raises(PaymentOutcomeUnknownError): + runtime._mark_mcp_payment_sent(raced) + assert not runtime._mcp_challenges + + second = runtime._begin_mcp_payment( + SimpleNamespace(id="second", realm="api.example.com"), + object(), + "other", + {}, + ) + runtime._mark_mcp_payment_sent(second) + runtime._mark_mcp_payment_unknown(second, TimeoutError("also lost")) + assert runtime._mcp_unknown_circuit is not None + with pytest.raises(PaymentOutcomeUnknownError): + runtime._begin_mcp_payment( + SimpleNamespace(id="fresh", realm="api.example.com"), + endpoint, + "fresh", + {}, + ) + + runtime.reset_unknown_outcomes(reconciled=True) + assert runtime._mcp_unknown_circuit is None + runtime.close() + + +def test_colliding_sent_operations_keep_each_challenge_tombstone() -> None: + runtime = PaymentRuntime([]) + + async def endpoint(*_args: Any, **_kwargs: Any) -> None: + return None + + attempts = [ + runtime._begin_mcp_payment( + SimpleNamespace(id=challenge_id, realm="api.example.com"), + endpoint, + "premium", + {}, + ) + for challenge_id in ("first", "second") + ] + for attempt in attempts: + runtime._mark_mcp_payment_sent(attempt) + for attempt in attempts: + runtime._mark_mcp_payment_unknown(attempt, TimeoutError(f"{attempt.challenge.id} lost")) + + assert len(runtime._mcp_unknown_operations) == 1 + assert {tombstone.challenge.id for tombstone in runtime._mcp_unknown_challenges.values()} == { + "first", + "second", + } + assert not runtime._mcp_challenges + runtime.close()