From 9af7304b18bf31a4f358631b02a38d3b301c0ff5 Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Sat, 8 Aug 2026 19:04:19 +0700 Subject: [PATCH] fix(x402): do not silently flip HTTP method on 404 (Python) Parity with TypeScript #1405. make_http_request retried a 404 with the opposite method, which can turn an intended GET into a POST write on services that map both methods to one path. Return the 404 with an explicit hint instead. Signed-off-by: Sasha Mitchell --- .../x402/x402_action_provider.py | 27 ++++++++++++------ .../x402/test_x402_action_provider.py | 28 +++++++++++++++++++ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402/x402_action_provider.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402/x402_action_provider.py index 731c7cbfd..a682cdd20 100644 --- a/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402/x402_action_provider.py +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/x402/x402_action_provider.py @@ -241,16 +241,25 @@ def make_http_request(self, wallet_provider: WalletProvider, args: dict[str, Any timeout=30, ) - # Retry with other http method for 404 status code + # Never silently retry with a different HTTP method: flipping GET to + # POST turns an intended read into a possible write on services that + # map both methods to the same path. Surface the 404 with a hint and + # let the agent choose explicitly. (Parity with TypeScript #1405.) if response.status_code == 404: - method = "POST" if method == "GET" else "GET" - can_have_body = method in ["POST", "PUT", "PATCH"] - response = requests.request( - url=final_url, - method=method, - headers=args.get("headers"), - json=args.get("body") if can_have_body else None, - timeout=30, + data = self._parse_response_data(response) + return json.dumps( + { + "success": False, + "url": final_url, + "method": method, + "status": 404, + "data": data, + "hint": ( + f"The service returned 404 for {method}. If it expects a " + "different method, call this action again with that method explicitly." + ), + }, + indent=2, ) if response.status_code != 402: diff --git a/python/coinbase-agentkit/tests/action_providers/x402/test_x402_action_provider.py b/python/coinbase-agentkit/tests/action_providers/x402/test_x402_action_provider.py index 318da7033..bee56e517 100644 --- a/python/coinbase-agentkit/tests/action_providers/x402/test_x402_action_provider.py +++ b/python/coinbase-agentkit/tests/action_providers/x402/test_x402_action_provider.py @@ -121,6 +121,34 @@ def test_make_http_request_success(mock_wallet, mock_requests): assert response["data"] == {"data": "success"} +def test_make_http_request_404_does_not_flip_method(mock_wallet, mock_requests): + """404 must not silently retry with the opposite HTTP method (TS #1405 parity).""" + from coinbase_agentkit.action_providers.x402.schemas import X402Config + + not_found = Mock(spec=requests.Response) + not_found.status_code = 404 + not_found.headers = {"content-type": "text/plain"} + not_found.text = "missing" + # Fixture installs a side_effect; replace it so GET is not forced to 200. + mock_requests.side_effect = None + mock_requests.return_value = not_found + + config = X402Config(registered_services=[MOCK_URL]) + provider = x402_action_provider(config) + + response = json.loads( + provider.make_http_request(mock_wallet, {"url": MOCK_URL, "method": "GET"}) + ) + + assert response["success"] is False + assert response["status"] == 404 + assert response["method"] == "GET" + assert "hint" in response + # Single request: no silent GET->POST flip + assert mock_requests.call_count == 1 + assert mock_requests.call_args.kwargs["method"] == "GET" + + def test_make_http_request_402(mock_wallet, mock_requests): """Test HTTP request that returns 402 Payment Required.""" from coinbase_agentkit.action_providers.x402.schemas import X402Config