Skip to content
Merged
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
37 changes: 18 additions & 19 deletions python/x402/http/clients/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ class x402HTTPAdapter(HTTPAdapter):
Note: Uses synchronous payment creation.
"""

RETRY_HEADER = "X-x402-Payment-Retry"

def __init__(
self,
client: x402Client | x402HTTPClient,
Expand All @@ -69,7 +71,6 @@ def __init__(
self._http_client = HTTPClient(client)

self._client = client
self._is_retry = False

def send(
self,
Expand All @@ -88,10 +89,8 @@ def send(
Raises:
PaymentError: If payment handling fails.
"""
# If this is a retry, just send it
if self._is_retry:
self._is_retry = False
return super().send(request, **kwargs)
# Check if this is already a retry (per-request state via header)
is_retry = request.headers.get(self.RETRY_HEADER) == "1"

# Make initial request
response = super().send(request, **kwargs)
Expand All @@ -100,6 +99,10 @@ def send(
if response.status_code != 402:
return response

# Already retried with payment, return the 402
if is_retry:
return response

try:
# Save content before parsing (avoid consuming stream)
content = copy.deepcopy(response.content)
Expand All @@ -122,26 +125,22 @@ def get_header(name: str) -> str | None:
# Encode payment headers
payment_headers = self._http_client.encode_payment_signature_header(payment_payload)

# Mark as retry and add payment headers
self._is_retry = True
request.headers.update(payment_headers)
request.headers["Access-Control-Expose-Headers"] = "PAYMENT-RESPONSE,X-PAYMENT-RESPONSE"
# Create a copy of the request for retry (don't modify original)
retry_request = request.copy()
retry_request.headers.update(payment_headers)
retry_request.headers["Access-Control-Expose-Headers"] = (
"PAYMENT-RESPONSE,X-PAYMENT-RESPONSE"
)
retry_request.headers[self.RETRY_HEADER] = "1"

# Retry request
retry_response = super().send(request, **kwargs)
# Retry request with payment
retry_response = super().send(retry_request, **kwargs)

# Copy retry response to original
response.status_code = retry_response.status_code
response.headers = retry_response.headers
response._content = retry_response.content

return response
return retry_response

except PaymentError:
self._is_retry = False
raise
except Exception as e:
self._is_retry = False
raise PaymentError(f"Failed to handle payment: {e}") from e


Expand Down
Empty file.
Empty file.
301 changes: 301 additions & 0 deletions python/x402/tests/unit/http/clients/test_requests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
"""Tests for x402HTTPAdapter (requests library integration).

Related issues:
- https://github.com/coinbase/x402/pull/399
- https://github.com/coinbase/x402/pull/879
"""

from unittest.mock import MagicMock, patch

import pytest
import requests
from requests import PreparedRequest, Response

from x402.http.clients.requests import (
PaymentError,
wrapRequestsWithPayment,
x402_http_adapter,
x402_requests,
x402HTTPAdapter,
)


class MockX402Client:
"""Mock x402Client for testing."""

def __init__(self):
self.create_payment_payload_call_count = 0

def create_payment_payload(self, payment_required):
self.create_payment_payload_call_count += 1
return MagicMock(x402_version=2, accepted=payment_required)


class MockX402HTTPClient:
"""Mock x402HTTPClient for testing."""

def __init__(self):
self.get_payment_required_response_call_count = 0

def get_payment_required_response(self, _get_header, _body):
self.get_payment_required_response_call_count += 1
return MagicMock(
scheme="exact",
network="base-sepolia",
asset="0x036CbD53842c5426634e7929541eC2318f3dCF7e",
amount="10000",
pay_to="0x0000000000000000000000000000000000000000",
)

def encode_payment_signature_header(self, _payload):
return {"X-Payment": "mock_payment_header"}


@pytest.fixture(scope="function")
def mock_client():
"""Create a mock x402Client."""
return MockX402Client()


@pytest.fixture(scope="function")
def mock_http_client():
"""Create a mock x402HTTPClient."""
return MockX402HTTPClient()


@pytest.fixture(scope="function")
def adapter(mock_client, mock_http_client):
"""Create an x402HTTPAdapter with mocked dependencies.

Uses MagicMock spec to create a valid adapter instance, then injects
mock dependencies for isolated unit testing.
"""
adapter = MagicMock(spec=x402HTTPAdapter)
adapter._client = mock_client
adapter._http_client = mock_http_client
adapter.send = x402HTTPAdapter.send.__get__(adapter, x402HTTPAdapter)
adapter.RETRY_HEADER = x402HTTPAdapter.RETRY_HEADER
return adapter


def _create_response(status_code: int, content: bytes = b"") -> Response:
"""Create a mock Response object."""
response = Response()
response.status_code = status_code
response._content = content
response.headers = {}
return response


def _create_request(url: str = "https://example.com") -> PreparedRequest:
"""Create a PreparedRequest object."""
request = PreparedRequest()
request.prepare("GET", url)
return request


class TestRetryHeaderConstant:
"""Test the RETRY_HEADER class constant."""

def test_should_have_retry_header_constant(self):
"""Should have RETRY_HEADER constant defined."""
assert hasattr(x402HTTPAdapter, "RETRY_HEADER")
assert x402HTTPAdapter.RETRY_HEADER == "X-x402-Payment-Retry"


class TestConsecutivePayments:
"""Test consecutive payment requests (bug fix verification).

Verifies the fix for the bug where the second consecutive payment request
would skip payment handling due to instance-level state persisting across
requests. Fix: Use request-level header instead of instance variable.
"""

def test_should_handle_all_consecutive_402_requests(self, adapter):
"""Should handle all consecutive 402 requests with payment retry."""
call_count = 0

def mock_send(request, **_kwargs):
nonlocal call_count
call_count += 1
is_retry = request.headers.get(x402HTTPAdapter.RETRY_HEADER) == "1"
if is_retry:
return _create_response(200, b'{"success": true}')
return _create_response(402, b"{}")

with patch("requests.adapters.HTTPAdapter.send", side_effect=mock_send):
for i in range(3):
request = _create_request(f"https://example.com/resource{i}")
response = adapter.send(request)
assert response.status_code == 200, f"Request {i + 1} failed"

assert call_count == 6 # 3 initial + 3 retries
assert adapter._client.create_payment_payload_call_count == 3

def test_should_set_retry_header_on_retry_request(self, adapter):
"""Should set retry header on the retry request."""
captured_requests = []

def mock_send(request, **_kwargs):
captured_requests.append(request)
is_retry = request.headers.get(x402HTTPAdapter.RETRY_HEADER) == "1"
if is_retry:
return _create_response(200, b'{"success": true}')
return _create_response(402, b"{}")

with patch("requests.adapters.HTTPAdapter.send", side_effect=mock_send):
adapter.send(_create_request())

assert x402HTTPAdapter.RETRY_HEADER not in captured_requests[0].headers
assert captured_requests[1].headers.get(x402HTTPAdapter.RETRY_HEADER) == "1"

def test_should_not_modify_original_request(self, adapter):
"""Should not modify original request during retry."""

def mock_send(request, **_kwargs):
is_retry = request.headers.get(x402HTTPAdapter.RETRY_HEADER) == "1"
if is_retry:
return _create_response(200, b'{"success": true}')
return _create_response(402, b"{}")

with patch("requests.adapters.HTTPAdapter.send", side_effect=mock_send):
original_request = _create_request()
adapter.send(original_request)

assert x402HTTPAdapter.RETRY_HEADER not in original_request.headers
assert "X-Payment" not in original_request.headers

def test_should_handle_mixed_200_and_402_requests(self, adapter):
"""Should handle alternating free (200) and paid (402) requests."""
call_sequence = []

def mock_send(request, **_kwargs):
url = request.url
is_retry = request.headers.get(x402HTTPAdapter.RETRY_HEADER) == "1"
call_sequence.append((url, is_retry))

if "/free" in url:
return _create_response(200, b'{"free": true}')
elif is_retry:
return _create_response(200, b'{"paid": true}')
return _create_response(402, b"{}")

with patch("requests.adapters.HTTPAdapter.send", side_effect=mock_send):
urls = [
"https://example.com/free",
"https://example.com/paid1",
"https://example.com/free",
"https://example.com/paid2",
]
for url in urls:
response = adapter.send(_create_request(url))
assert response.status_code == 200

expected = [
("https://example.com/free", False),
("https://example.com/paid1", False),
("https://example.com/paid1", True),
("https://example.com/free", False),
("https://example.com/paid2", False),
("https://example.com/paid2", True),
]
assert call_sequence == expected


class TestBasicFunctionality:
"""Test basic adapter functionality."""

@pytest.mark.parametrize(
("status_code", "content"),
[
(200, b"success"),
(404, b"not found"),
(500, b"server error"),
(301, b"redirect"),
],
)
def test_should_return_non_402_response_directly(self, adapter, status_code, content):
"""Should return non-402 responses without payment handling."""
mock_response = _create_response(status_code, content)

with patch("requests.adapters.HTTPAdapter.send", return_value=mock_response):
response = adapter.send(_create_request())

assert response.status_code == status_code
assert response.content == content
assert adapter._client.create_payment_payload_call_count == 0

def test_should_return_402_directly_when_retry_header_present(self, adapter):
"""Should return 402 directly when retry header is present.

This prevents infinite retry loops when payment is rejected.
"""
mock_response = _create_response(402, b"payment rejected")

with patch("requests.adapters.HTTPAdapter.send", return_value=mock_response):
request = _create_request()
request.headers[x402HTTPAdapter.RETRY_HEADER] = "1"

response = adapter.send(request)

assert response.status_code == 402
assert adapter._client.create_payment_payload_call_count == 0


class TestErrorHandling:
"""Test error handling in the adapter."""

def test_should_raise_payment_error_on_client_error(self, adapter):
"""Should raise PaymentError when client fails."""
adapter._client.create_payment_payload = MagicMock(side_effect=Exception("Client error"))
mock_402 = _create_response(402, b"{}")

with patch("requests.adapters.HTTPAdapter.send", return_value=mock_402):
with pytest.raises(PaymentError, match="Failed to handle payment"):
adapter.send(_create_request())

def test_should_propagate_payment_error(self, adapter):
"""Should propagate PaymentError from client."""
adapter._client.create_payment_payload = MagicMock(
side_effect=PaymentError("Custom payment error")
)
mock_402 = _create_response(402, b"{}")

with patch("requests.adapters.HTTPAdapter.send", return_value=mock_402):
with pytest.raises(PaymentError, match="Custom payment error"):
adapter.send(_create_request())


class TestFactoryFunctions:
"""Test factory functions for creating adapters and sessions."""

def test_x402_http_adapter_should_create_adapter(self):
"""Should create x402HTTPAdapter instance."""
mock_client = MagicMock()

with patch.object(x402HTTPAdapter, "__init__", return_value=None):
adapter = x402_http_adapter(mock_client)
assert isinstance(adapter, x402HTTPAdapter)

def test_x402_requests_should_create_session_with_adapters(self):
"""Should create session with HTTP and HTTPS adapters mounted."""
mock_client = MagicMock()

with patch.object(x402HTTPAdapter, "__init__", return_value=None):
session = x402_requests(mock_client)

assert isinstance(session, requests.Session)
assert "http://" in session.adapters
assert "https://" in session.adapters

def test_wrap_requests_with_payment_should_mount_adapters(self):
"""Should mount adapters on existing session."""
mock_client = MagicMock()
session = requests.Session()

with patch.object(x402HTTPAdapter, "__init__", return_value=None):
wrapped = wrapRequestsWithPayment(session, mock_client)

assert wrapped is session
assert "http://" in wrapped.adapters
assert "https://" in wrapped.adapters