Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changelog/httpx-client-adapters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pympp: minor
---

Added fail-fast per-client adapters for existing HTTPX 0.27.x and 0.28.x clients.
34 changes: 33 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
ci-gate:
name: CI Gate
if: always()
needs: [test, integration, package, install-smoke]
needs: [test, httpx-compat, integration, package, install-smoke]
runs-on: ubuntu-latest
steps:
- env:
Expand Down Expand Up @@ -90,6 +90,38 @@ jobs:
htmlcov/
if-no-files-found: ignore

httpx-compat:
name: HTTPX ${{ matrix.httpx-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
httpx-version: ["0.27.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 --python .venv/bin/python -e . "httpx==$HTTPX_VERSION" pytest pytest-asyncio
env:
HTTPX_VERSION: ${{ matrix.httpx-version }}

- name: Run HTTPX adapter tests
run: .venv/bin/python -m pytest --noconftest -q tests/test_httpx_adapters.py

integration:
name: Integration Test
runs-on: ubuntu-latest
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@ with OwnedPaymentRuntime([method]) as runtime:
For loop-bound resources, pass async-context-manager factories with
`method_factories=` so they are created and closed on the owned loop.

Existing HTTPX clients can be made payment-aware without replacing their
configured transports:

```python
with OwnedPaymentRuntime([method]) as runtime:
with runtime.wrap_client(httpx.Client()) as client:
response = client.get("https://api.example.com/paid")
```

Use `runtime.wrap_async_client(httpx.AsyncClient())` for asynchronous clients.
These adapters support HTTPX 0.27.x and 0.28.x and fail before changing a client
when its version or private send seam is incompatible. Explicit `PaymentTransport`
and `SyncPaymentTransport` integrations do not depend on those private seams.

## Examples

| Example | Description |
Expand Down
275 changes: 275 additions & 0 deletions src/mpp/_httpx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
"""Fail-fast adapters for existing HTTPX clients."""

from __future__ import annotations

import inspect
import threading
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from functools import wraps
from importlib.metadata import version
from typing import TYPE_CHECKING, Any

import httpx

if TYPE_CHECKING:
from collections.abc import Awaitable, Callable

from mpp.runtime import OwnedPaymentRuntime

HTTPX_ADAPTER_VERSIONS = ">=0.27,<0.29"
_SUPPORTED_HTTPX_MINORS = {(0, 27), (0, 28)}
_MARKER = "_mpp_httpx_adapter"
_MISSING = object()
_LOCK = threading.RLock()
_POSITIONAL = inspect.Parameter.POSITIONAL_OR_KEYWORD
_KEYWORD_ONLY = inspect.Parameter.KEYWORD_ONLY
_PRIVATE = (("self", _POSITIONAL), ("request", _POSITIONAL))
_BOUND_PRIVATE = _PRIVATE[1:]
_PUBLIC = (
("self", _POSITIONAL),
("request", _POSITIONAL),
("stream", _KEYWORD_ONLY),
("auth", _KEYWORD_ONLY),
("follow_redirects", _KEYWORD_ONLY),
)
_BOUND_PUBLIC = _PUBLIC[1:]


class HttpxCompatibilityError(RuntimeError):
"""The installed HTTPX client cannot be safely adapted."""


@dataclass(frozen=True, slots=True)
class _Adapter:
runtime: OwnedPaymentRuntime
private: Any
public: Any


@dataclass(slots=True)
class _SendOperation:
paid: bool = False


_SEND_OPERATION: ContextVar[_SendOperation | None] = ContextVar(
"mpp_httpx_send_operation",
default=None,
)


@contextmanager
def _send_operation():
token = _SEND_OPERATION.set(_SendOperation())
try:
yield
finally:
_SEND_OPERATION.reset(token)


@contextmanager
def _payment_budget(request: httpx.Request):
from mpp.client._http import _PAYMENT_SENT

operation = _SEND_OPERATION.get()
if operation is not None:
source = request.extensions.get(_PAYMENT_SENT)
if operation.paid:
request.extensions[_PAYMENT_SENT] = 0
elif isinstance(source, int):
request.extensions.pop(_PAYMENT_SENT, None)
try:
yield
finally:
if operation is not None and isinstance(request.extensions.get(_PAYMENT_SENT), int):
operation.paid = True


class _SyncSend(httpx.BaseTransport):
def __init__(self, send: Callable[[httpx.Request], httpx.Response]) -> None:
self._send = send

def handle_request(self, request: httpx.Request) -> httpx.Response:
return self._send(request)


class _AsyncSend(httpx.AsyncBaseTransport):
def __init__(
self,
send: Callable[[httpx.Request], Awaitable[httpx.Response]],
) -> None:
self._send = send

async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
return await self._send(request)


def wrap_client(client: httpx.Client, runtime: OwnedPaymentRuntime) -> httpx.Client:
"""Make one synchronous HTTPX client payment-aware."""
if not isinstance(client, httpx.Client):
raise TypeError("wrap_client requires an httpx.Client")
return _wrap(client, runtime, asynchronous=False)


def wrap_async_client(
client: httpx.AsyncClient,
runtime: OwnedPaymentRuntime,
) -> httpx.AsyncClient:
"""Make one asynchronous HTTPX client payment-aware."""
if not isinstance(client, httpx.AsyncClient):
raise TypeError("wrap_async_client requires an httpx.AsyncClient")
return _wrap(client, runtime, asynchronous=True)


def _wrap(client: Any, runtime: OwnedPaymentRuntime, *, asynchronous: bool) -> Any:
with _LOCK:
existing = client.__dict__.get(_MARKER, _MISSING)
if existing is not _MISSING:
if (
not isinstance(existing, _Adapter)
or inspect.getattr_static(client, "_send_single_request") is not existing.private
or inspect.getattr_static(client, "send") is not existing.public
):
raise HttpxCompatibilityError("HTTPX client adapter was replaced or corrupted")
if existing.runtime is not runtime:
raise RuntimeError("HTTPX client is already bound to another payment runtime")
return client

original_private, original_public = _validate_client(
client,
asynchronous=asynchronous,
)
if asynchronous:
from mpp.client import PaymentTransport

transport = PaymentTransport(inner=_AsyncSend(original_private), runtime=runtime)

@wraps(original_private)
async def async_private(request: httpx.Request) -> httpx.Response:
with _payment_budget(request):
return await transport.handle_async_request(request)

@wraps(original_public)
async def async_public(
request: httpx.Request,
*args: Any,
**kwargs: Any,
) -> httpx.Response:
with _send_operation():
return await original_public(request, *args, **kwargs)

private, public = async_private, async_public
else:
from mpp.client import SyncPaymentTransport

transport = SyncPaymentTransport(inner=_SyncSend(original_private), runtime=runtime)

@wraps(original_private)
def sync_private(request: httpx.Request) -> httpx.Response:
with _payment_budget(request):
return transport.handle_request(request)

@wraps(original_public)
def sync_public(
request: httpx.Request,
*args: Any,
**kwargs: Any,
) -> httpx.Response:
with _send_operation():
return original_public(request, *args, **kwargs)

private, public = sync_private, sync_public

_set_attributes(
client,
_send_single_request=private,
send=public,
**{_MARKER: _Adapter(runtime, private, public)},
)
return client


def _validate_client(client: Any, *, asynchronous: bool) -> tuple[Any, Any]:
_validate_version()
owner = type(client)
bound = []
for name, expected, bound_expected in (
("_send_single_request", _PRIVATE, _BOUND_PRIVATE),
("send", _PUBLIC, _BOUND_PUBLIC),
):
try:
seam = inspect.getattr_static(owner, name)
except AttributeError as error:
raise HttpxCompatibilityError(
f"HTTPX adapter seam {owner.__name__}.{name} is missing"
) from error
_validate_seam(f"{owner.__name__}.{name}", seam, expected, asynchronous)
value = getattr(client, name)
_validate_seam(
f"{owner.__name__} instance.{name}",
value,
bound_expected,
asynchronous,
)
bound.append(value)
return bound[0], bound[1]


def _validate_version() -> None:
installed = version("httpx")
try:
supported = tuple(map(int, installed.split(".")[:2])) 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_seam(
name: str,
seam: Any,
expected: tuple[tuple[str, inspect._ParameterKind], ...],
asynchronous: bool,
) -> None:
try:
shape = tuple(
(parameter.name, parameter.kind)
for parameter in inspect.signature(seam).parameters.values()
)
except (TypeError, ValueError) as error:
raise HttpxCompatibilityError(f"HTTPX adapter seam {name} is not inspectable") from error
if not callable(seam) or shape != expected:
raise HttpxCompatibilityError(f"HTTPX adapter seam {name} has an unsupported signature")
if inspect.iscoroutinefunction(seam) is not asynchronous:
kind = "async" if asynchronous else "sync"
raise HttpxCompatibilityError(f"HTTPX adapter seam {name} is not {kind}")


def _set_attributes(client: Any, **values: Any) -> None:
previous = {name: client.__dict__.get(name, _MISSING) for name in values}
changed: list[str] = []
try:
for name, value in values.items():
changed.append(name)
setattr(client, name, value)
except BaseException as error:
rollback_error: BaseException | None = None
for name in reversed(changed):
try:
if previous[name] is _MISSING:
if name in client.__dict__:
delattr(client, name)
else:
setattr(client, name, previous[name])
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
14 changes: 14 additions & 0 deletions src/mpp/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
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.events import (
CHALLENGE_RECEIVED,
CREDENTIAL_CREATED,
Expand Down Expand Up @@ -542,6 +544,18 @@ def emit_event_sync(self, name: str, payload: EventPayload) -> Any:
def allows_http_payment(self, url: httpx.URL) -> bool:
return self._allowed_origins.allows(url)

def wrap_client(self, client: httpx.Client) -> httpx.Client:
"""Make one existing synchronous HTTPX client payment-aware."""
from mpp._httpx import wrap_client

return wrap_client(client, self)

def wrap_async_client(self, client: httpx.AsyncClient) -> httpx.AsyncClient:
"""Make one existing asynchronous HTTPX client payment-aware."""
from mpp._httpx import wrap_async_client

return wrap_async_client(client, self)

def reset_unknown_outcomes(self, *, reconciled: bool) -> None:
self._core().reset_unknown_outcomes(reconciled=reconciled)

Expand Down
Loading
Loading