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) {