From 9bbedc91045c5b942bb346392eb025c5cf6b81f4 Mon Sep 17 00:00:00 2001 From: SashaMIT Date: Thu, 6 Aug 2026 23:35:13 +0700 Subject: [PATCH] fix(x402): do not silently flip HTTP method on 404 make_http_request retried a 404 with the method flipped (GET becomes POST, POST becomes GET) and the caller's body attached. On services that map both methods to one path, that converts an intended read into a write the agent never chose to make. Return the 404 with a hint naming the method tried, so the agent retries with a different method only as an explicit decision. Made-with: Cursor --- .../x402/x402ActionProvider.test.ts | 27 ++++++++++++++++ .../x402/x402ActionProvider.ts | 31 ++++++++++++------- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/typescript/agentkit/src/action-providers/x402/x402ActionProvider.test.ts b/typescript/agentkit/src/action-providers/x402/x402ActionProvider.test.ts index 1cdc9771e..b8b5ba80d 100644 --- a/typescript/agentkit/src/action-providers/x402/x402ActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/x402/x402ActionProvider.test.ts @@ -215,6 +215,33 @@ describe("X402ActionProvider", () => { expect(parsedResult.registeredServices).toBeDefined(); }); + it("should not silently flip the HTTP method on 404", async () => { + mockFetch.mockResolvedValue( + createMockResponse({ + status: 404, + data: { error: "not found" }, + headers: { "content-type": "application/json" }, + }), + ); + + const result = await provider.makeHttpRequest(makeMockWalletProvider("base-sepolia"), { + url: "https://api.example.com/items/123", + method: "GET", + headers: null, + queryParams: null, + body: JSON.stringify({ role: "admin" }), + }); + + // Exactly one request, with the method the caller chose. A silent + // GET->POST retry would turn an intended read into a write. + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0][1]?.method).toBe("GET"); + const parsed = JSON.parse(result); + expect(parsed.status).toBe(404); + expect(parsed.method).toBe("GET"); + expect(parsed.hint).toContain("explicitly"); + }); + it("should handle successful non-payment requests", async () => { mockFetch.mockResolvedValue( createMockResponse({ diff --git a/typescript/agentkit/src/action-providers/x402/x402ActionProvider.ts b/typescript/agentkit/src/action-providers/x402/x402ActionProvider.ts index 7a4363cd7..0137e9c6f 100644 --- a/typescript/agentkit/src/action-providers/x402/x402ActionProvider.ts +++ b/typescript/agentkit/src/action-providers/x402/x402ActionProvider.ts @@ -217,24 +217,33 @@ If you receive a 402 Payment Required response, use retry_http_request_with_x402 } const finalUrl = buildUrlWithParams(args.url, args.queryParams); - let method = args.method; - let canHaveBody = ["POST", "PUT", "PATCH"].includes(method); + const method = args.method; + const canHaveBody = ["POST", "PUT", "PATCH"].includes(method); - let response = await fetch(finalUrl, { + const response = await fetch(finalUrl, { method, headers: args.headers ?? undefined, body: canHaveBody && args.body ? JSON.stringify(args.body) : undefined, }); - // 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. if (response.status === 404) { - method = method === "GET" ? "POST" : "GET"; - canHaveBody = ["POST", "PUT", "PATCH"].includes(method); - response = await fetch(finalUrl, { - method, - headers: args.headers ?? undefined, - body: canHaveBody && args.body ? JSON.stringify(args.body) : undefined, - }); + const data = await this.parseResponseData(response); + return JSON.stringify( + { + success: false, + url: finalUrl, + method, + status: 404, + data, + hint: `The service returned 404 for ${method}. If it expects a different method, call this action again with that method explicitly.`, + }, + null, + 2, + ); } if (response.status !== 402) {