Skip to content
Open
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
1 change: 1 addition & 0 deletions src/mpp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,4 +412,5 @@ def success(
from . import _body_digest as BodyDigest # noqa: E402
from . import _expires as Expires # noqa: E402
from . import stores # noqa: E402
from .client.retry import RetryPolicy # noqa: E402
from .store import MemoryStore, Store # noqa: E402
1 change: 1 addition & 0 deletions src/mpp/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"""

from mpp import _expires as Expires
from mpp.client.retry import RetryPolicy
from mpp.client.transport import Client, PaymentTransport, get, post, request
from mpp.events import (
CHALLENGE_RECEIVED,
Expand Down
28 changes: 28 additions & 0 deletions src/mpp/client/retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Retry policy for transient errors in PaymentTransport."""

from __future__ import annotations

from dataclasses import dataclass, field


@dataclass
class RetryPolicy:
"""Exponential backoff retry policy for transient 5xx errors.

Example:
transport = PaymentTransport(
methods=[tempo(...)],
retry_policy=RetryPolicy(max_attempts=5, backoff_delays=[0.1, 0.5, 1.0, 2.0]),
)

# Disable retry entirely:
async with Client(methods=[tempo(...)], retry_policy=None) as client:
response = await client.get(url)
"""

max_attempts: int = 3
backoff_delays: list[float] = field(default_factory=lambda: [0.5, 1.0])
retryable_status_codes: frozenset[int] = field(
default_factory=lambda: frozenset({500, 502, 503, 504})
)
retry_on_connect_error: bool = True
59 changes: 56 additions & 3 deletions src/mpp/client/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from __future__ import annotations

import asyncio
import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
Expand All @@ -17,6 +18,7 @@

from mpp import Challenge, Credential
from mpp._parsing import ParseError
from mpp.client.retry import RetryPolicy
from mpp.events import (
CHALLENGE_RECEIVED,
CREDENTIAL_CREATED,
Expand Down Expand Up @@ -90,10 +92,12 @@ def __init__(
methods: Sequence[Method],
inner: httpx.AsyncBaseTransport | None = None,
events: EventDispatcher | None = None,
retry_policy: RetryPolicy | None = None,
) -> None:
self._methods = {m.name: m for m in methods}
self._inner = inner or httpx.AsyncHTTPTransport()
self._events = events or EventDispatcher()
self._retry_policy = retry_policy

def on(self, name: str, handler: EventHandler) -> Unsubscribe:
"""Register a client payment event handler."""
Expand All @@ -115,9 +119,49 @@ def on_payment_failed(self, handler: EventHandler) -> Unsubscribe:
"""Register a handler for failed automatic payment handling."""
return self.on(PAYMENT_FAILED, handler)

async def _dispatch(self, request: httpx.Request) -> httpx.Response:
"""Dispatch request to inner transport, retrying on transient 5xx errors."""
if self._retry_policy is None:
return await self._inner.handle_async_request(request)

policy = self._retry_policy

for attempt in range(policy.max_attempts):
is_last = attempt == policy.max_attempts - 1
delay = policy.backoff_delays[min(attempt, len(policy.backoff_delays) - 1)]

try:
response = await self._inner.handle_async_request(request)
except (httpx.ConnectError, httpx.RemoteProtocolError) as exc:
if not policy.retry_on_connect_error or is_last:
raise
logger.warning(
"Retrying request (attempt %d/%d) after %s %s",
attempt + 1,
policy.max_attempts,
type(exc).__name__,
request.url,
)
await asyncio.sleep(delay)
continue

if response.status_code not in policy.retryable_status_codes or is_last:
return response

logger.warning(
"Retrying request (attempt %d/%d) after %s %s",
attempt + 1,
policy.max_attempts,
response.status_code,
request.url,
)
await asyncio.sleep(delay)

raise AssertionError("unreachable: max_attempts must be >= 1") # pragma: no cover

async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
"""Handle request, automatically retrying on 402 with credentials."""
response = await self._inner.handle_async_request(request)
response = await self._dispatch(request)

if response.status_code != 402:
return response
Expand Down Expand Up @@ -288,8 +332,17 @@ class Client:
response = await client.get("https://api.example.com/resource")
"""

def __init__(self, methods: Sequence[Method]) -> None:
self._transport = PaymentTransport(methods)
# Sentinel so that Client() defaults to RetryPolicy() while Client(retry_policy=None) disables retry.
_RETRY_DEFAULT: Any = object()

def __init__(
self,
methods: Sequence[Method],
retry_policy: RetryPolicy | None = _RETRY_DEFAULT, # type: ignore[assignment]
) -> None:
if retry_policy is Client._RETRY_DEFAULT:
retry_policy = RetryPolicy()
self._transport = PaymentTransport(methods, retry_policy=retry_policy)
self._client = httpx.AsyncClient(transport=self._transport)

def on(self, name: str, handler: EventHandler) -> Unsubscribe:
Expand Down
108 changes: 106 additions & 2 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
"""Tests for client-side transport."""

from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, patch

import httpx
import pytest
from pytest_httpx import HTTPXMock

from mpp import Challenge, Credential
from mpp import Challenge, Credential, RetryPolicy
from mpp.client import Client, PaymentTransport, get, post, request
from tests import make_credential

Expand Down Expand Up @@ -430,6 +430,99 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:

assert events == ["failed:test-id:RuntimeError"]

@pytest.mark.asyncio
async def test_5xx_retried_with_backoff(self) -> None:
"""Should retry on retryable 5xx responses with exponential backoff."""
inner = MockTransport(
[
httpx.Response(503),
httpx.Response(503),
httpx.Response(200, content=b'{"data": "ok"}'),
]
)
policy = RetryPolicy(max_attempts=3, backoff_delays=[0.5, 1.0])
transport = PaymentTransport(methods=[], inner=inner, retry_policy=policy)

with patch("mpp.client.transport.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
response = await transport.handle_async_request(
httpx.Request("GET", "https://example.com")
)

assert response.status_code == 200
assert len(inner.requests) == 3
mock_sleep.assert_any_call(0.5)
mock_sleep.assert_any_call(1.0)

@pytest.mark.asyncio
async def test_5xx_exhausted_returns_last_response(self) -> None:
"""Should return the last 5xx response when max_attempts is exhausted."""
inner = MockTransport(
[
httpx.Response(503),
httpx.Response(503),
]
)
policy = RetryPolicy(max_attempts=2, backoff_delays=[0.5])
transport = PaymentTransport(methods=[], inner=inner, retry_policy=policy)

with patch("mpp.client.transport.asyncio.sleep", new_callable=AsyncMock):
response = await transport.handle_async_request(
httpx.Request("GET", "https://example.com")
)

assert response.status_code == 503
assert len(inner.requests) == 2

@pytest.mark.asyncio
async def test_connect_error_retried(self) -> None:
"""Should retry on ConnectError and succeed if a later attempt returns 200."""

class ConnectErrorThenOkTransport(MockTransport):
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
self.requests.append(request)
if len(self.requests) == 1:
raise httpx.ConnectError("Connection refused")
return httpx.Response(200, content=b'{"data": "ok"}')

inner = ConnectErrorThenOkTransport([])
policy = RetryPolicy(max_attempts=3, backoff_delays=[0.1])
transport = PaymentTransport(methods=[], inner=inner, retry_policy=policy)

with patch("mpp.client.transport.asyncio.sleep", new_callable=AsyncMock):
response = await transport.handle_async_request(
httpx.Request("GET", "https://example.com")
)

assert response.status_code == 200
assert len(inner.requests) == 2

@pytest.mark.asyncio
async def test_retry_disabled_when_policy_is_none(self) -> None:
"""Should not retry when retry_policy=None; first 503 is returned immediately."""
inner = MockTransport([httpx.Response(503)])
transport = PaymentTransport(methods=[], inner=inner, retry_policy=None)

response = await transport.handle_async_request(
httpx.Request("GET", "https://example.com")
)

assert response.status_code == 503
assert len(inner.requests) == 1

@pytest.mark.asyncio
async def test_non_retryable_5xx_not_retried(self) -> None:
"""Should not retry 501 which is not in retryable_status_codes by default."""
inner = MockTransport([httpx.Response(501)])
policy = RetryPolicy()
transport = PaymentTransport(methods=[], inner=inner, retry_policy=policy)

response = await transport.handle_async_request(
httpx.Request("GET", "https://example.com")
)

assert response.status_code == 501
assert len(inner.requests) == 1


class TestClient:
@pytest.mark.asyncio
Expand Down Expand Up @@ -493,6 +586,17 @@ async def test_on_delegates_to_transport(self) -> None:
assert callable(client.on_credential_created(lambda payload: None))
assert callable(client.on_payment_response(lambda payload: None))

@pytest.mark.asyncio
async def test_client_retry_disabled_when_policy_is_none(
self, httpx_mock: HTTPXMock
) -> None:
"""Client(retry_policy=None) should not retry; first 503 is returned as-is."""
httpx_mock.add_response(url="https://example.com/test", status_code=503)

async with Client(methods=[], retry_policy=None) as client:
response = await client.get("https://example.com/test")
assert response.status_code == 503


class TestConvenienceFunctions:
@pytest.mark.asyncio
Expand Down