diff --git a/src/mpp/client/transport.py b/src/mpp/client/transport.py index 3fa83db..ecb812e 100644 --- a/src/mpp/client/transport.py +++ b/src/mpp/client/transport.py @@ -117,6 +117,13 @@ def on_payment_failed(self, handler: EventHandler) -> Unsubscribe: async def handle_async_request(self, request: httpx.Request) -> httpx.Response: """Handle request, automatically retrying on 402 with credentials.""" + # Buffer the request body up front. Request bodies (e.g. a POST payload) + # are streamed and one-shot: the inner transport consumes the stream when + # it sends the initial request, so a paid retry that reused the original + # stream would transmit an empty/garbled body. Reading here replaces the + # stream with a replayable ByteStream, so the retry carries the real body. + await request.aread() + response = await self._inner.handle_async_request(request) if response.status_code != 402: @@ -240,7 +247,7 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response: method=request.method, url=request.url, headers=headers, - stream=request.stream, + content=request.content, extensions=request.extensions, ) diff --git a/tests/test_client.py b/tests/test_client.py index 2164399..0657cc5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -89,6 +89,38 @@ 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 request body. + + 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"}, + ) + 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"}'), + ] + ) + transport = PaymentTransport(methods=[MockMethod()], inner=inner) + + body = b'{"prompt": "expensive question"}' + request = httpx.Request("POST", "https://example.com", content=body) + await transport.handle_async_request(request) + + assert len(inner.requests) == 2 + retry_request = inner.requests[1] + assert retry_request.content == body + assert retry_request.headers["content-length"] == str(len(body)) + @pytest.mark.asyncio async def test_emits_client_payment_events(self) -> None: """Should emit mppx-compatible client payment lifecycle events."""