Skip to content

Commit 0fe31c6

Browse files
authored
Merge pull request #285 from ayomustap/Handle-empty-appraisal-API-responses-deterministically
Handle empty appraisal API responses deterministically
2 parents 5944134 + cb641b6 commit 0fe31c6

4 files changed

Lines changed: 204 additions & 13 deletions

File tree

services/appraisal-api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"main": "src/index.ts",
88
"scripts": {
99
"start": "node --import tsx src/run.ts",
10-
"test": "node --import tsx --test src/appraisal.test.ts src/appraisal-fixtures.test.ts src/config.test.ts",
10+
"test": "node --import tsx --test src/appraisal.test.ts src/appraisal-fixtures.test.ts src/config.test.ts src/client.test.ts",
1111
"typecheck": "tsc --noEmit -p tsconfig.json"
1212
},
1313
"dependencies": {
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import assert from "node:assert/strict";
2+
import { x402HTTPClient } from "@x402/core/client";
3+
import { Keypair } from "@stellar/stellar-sdk";
4+
import { describe, test } from "node:test";
5+
6+
import { createPaidFetch, X402PaymentError, AppraisalResponseParseError } from "./client.js";
7+
8+
const TEST_SECRET = Keypair.random().secret();
9+
const VALID_402_BODY = {
10+
x402Version: 2,
11+
resource: "https://example.com/appraise",
12+
accepts: [
13+
{
14+
scheme: "exact",
15+
network: "stellar:testnet",
16+
payTo: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
17+
price: "0.10",
18+
asset: "USDC",
19+
maxTimeoutSeconds: 60,
20+
extra: {},
21+
},
22+
],
23+
error: "Payment required",
24+
metadata: {},
25+
};
26+
27+
function buildResponse(status: number, body: string | undefined, headers?: Record<string, string>) {
28+
const headersMap = new Headers(headers ?? {});
29+
return new Response(body ?? "", {
30+
status,
31+
headers: headersMap,
32+
});
33+
}
34+
35+
describe("createPaidFetch response parsing", () => {
36+
test("throws a typed parse error for an unpaid empty response body", async () => {
37+
const paidFetch = createPaidFetch({ secret: TEST_SECRET });
38+
const originalFetch = globalThis.fetch;
39+
globalThis.fetch = async () => buildResponse(200, "");
40+
41+
try {
42+
await assert.rejects(
43+
() => paidFetch("https://example.com/appraise"),
44+
(err: unknown) => {
45+
assert.ok(err instanceof AppraisalResponseParseError);
46+
assert.equal(err.status, 200);
47+
assert.equal(err.name, "AppraisalResponseParseError");
48+
assert.match(err.message, /invalid JSON body/i);
49+
return true;
50+
},
51+
);
52+
} finally {
53+
globalThis.fetch = originalFetch;
54+
}
55+
});
56+
57+
test("throws a typed parse error for a paid non-JSON response body without exposing raw content", async () => {
58+
const paidFetch = createPaidFetch({ secret: TEST_SECRET });
59+
const originalFetch = globalThis.fetch;
60+
const originalGetPaymentRequiredResponse = x402HTTPClient.prototype.getPaymentRequiredResponse;
61+
const originalCreatePaymentPayload = x402HTTPClient.prototype.createPaymentPayload;
62+
const originalEncodePaymentSignatureHeader =
63+
x402HTTPClient.prototype.encodePaymentSignatureHeader;
64+
const originalGetPaymentSettleResponse = x402HTTPClient.prototype.getPaymentSettleResponse;
65+
66+
x402HTTPClient.prototype.getPaymentRequiredResponse = () => VALID_402_BODY as never;
67+
x402HTTPClient.prototype.createPaymentPayload = async () => ({
68+
x402Version: 2,
69+
payload: "stub-payload",
70+
resource: "https://example.com/appraise",
71+
accepted: VALID_402_BODY.accepts[0],
72+
extensions: {},
73+
}) as never;
74+
x402HTTPClient.prototype.encodePaymentSignatureHeader = () => ({
75+
"X-PAYMENT": "stub-signature",
76+
});
77+
x402HTTPClient.prototype.getPaymentSettleResponse = () => ({
78+
transaction: "stub-tx",
79+
network: "stellar:testnet",
80+
payer: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
81+
}) as never;
82+
83+
let calls = 0;
84+
globalThis.fetch = async (_url, _init) => {
85+
calls += 1;
86+
if (calls === 1) {
87+
return buildResponse(402, JSON.stringify(VALID_402_BODY), {
88+
"x402-version": "2",
89+
"x402-payment-required": "1",
90+
});
91+
}
92+
if (calls === 2) {
93+
return buildResponse(200, "not-json");
94+
}
95+
throw new Error("unexpected extra fetch call");
96+
};
97+
98+
try {
99+
await assert.rejects(
100+
() => paidFetch("https://example.com/appraise"),
101+
(err: unknown) => {
102+
assert.ok(err instanceof AppraisalResponseParseError);
103+
assert.equal(err.status, 200);
104+
assert.equal(err.name, "AppraisalResponseParseError");
105+
assert.doesNotMatch(err.message, /not-json|raw|payload/i);
106+
return true;
107+
},
108+
);
109+
} finally {
110+
globalThis.fetch = originalFetch;
111+
x402HTTPClient.prototype.getPaymentRequiredResponse = originalGetPaymentRequiredResponse;
112+
x402HTTPClient.prototype.createPaymentPayload = originalCreatePaymentPayload;
113+
x402HTTPClient.prototype.encodePaymentSignatureHeader = originalEncodePaymentSignatureHeader;
114+
x402HTTPClient.prototype.getPaymentSettleResponse = originalGetPaymentSettleResponse;
115+
}
116+
});
117+
118+
test("preserves x402 payment errors for non-JSON 402 responses", async () => {
119+
const paidFetch = createPaidFetch({ secret: TEST_SECRET });
120+
const originalFetch = globalThis.fetch;
121+
const originalGetPaymentRequiredResponse = x402HTTPClient.prototype.getPaymentRequiredResponse;
122+
x402HTTPClient.prototype.getPaymentRequiredResponse = () => {
123+
throw new Error("invalid x402 payment required response");
124+
};
125+
126+
globalThis.fetch = async () => buildResponse(402, "not-json");
127+
128+
try {
129+
await assert.rejects(
130+
() => paidFetch("https://example.com/appraise"),
131+
(err: unknown) => {
132+
assert.ok(err instanceof X402PaymentError);
133+
assert.match(err.message, /payment|402/i);
134+
return true;
135+
},
136+
);
137+
} finally {
138+
globalThis.fetch = originalFetch;
139+
x402HTTPClient.prototype.getPaymentRequiredResponse = originalGetPaymentRequiredResponse;
140+
}
141+
});
142+
});

services/appraisal-api/src/client.ts

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
// pay the appraisal API per call.
99

1010
import { x402Client, x402HTTPClient } from "@x402/core/client";
11-
import type { Network, SettleResponse } from "@x402/core/types";
11+
import type { Network, PaymentRequired, SettleResponse } from "@x402/core/types";
1212
import { createEd25519Signer } from "@x402/stellar";
1313
import { ExactStellarScheme as ClientStellarScheme } from "@x402/stellar/exact/client";
1414

@@ -28,7 +28,38 @@ export interface PaidResult<T = unknown> {
2828
settlement?: SettleResponse;
2929
}
3030

31-
export class X402PaymentError extends Error {}
31+
export class AppraisalResponseParseError extends Error {
32+
readonly name = "AppraisalResponseParseError";
33+
readonly status: number;
34+
35+
constructor(status: number, options?: ErrorOptions) {
36+
super(`appraisal api returned ${status} with invalid JSON body`, options);
37+
this.status = status;
38+
}
39+
}
40+
41+
export class X402PaymentError extends Error {
42+
readonly name = "X402PaymentError";
43+
readonly status?: number;
44+
45+
constructor(message: string, status?: number) {
46+
super(message);
47+
this.status = status;
48+
}
49+
}
50+
51+
async function parseJsonResponse<T>(res: Response): Promise<T> {
52+
const text = await res.text();
53+
if (!text.trim()) {
54+
throw new AppraisalResponseParseError(res.status);
55+
}
56+
57+
try {
58+
return JSON.parse(text) as T;
59+
} catch (cause) {
60+
throw new AppraisalResponseParseError(res.status, { cause });
61+
}
62+
}
3263

3364
/** Build a paid-fetch function bound to a payer wallet. */
3465
export function createPaidFetch(config: PaidClientConfig) {
@@ -47,28 +78,45 @@ export function createPaidFetch(config: PaidClientConfig) {
4778
): Promise<PaidResult<T>> {
4879
const first = await fetch(url, init);
4980
if (first.status !== 402) {
50-
return { status: first.status, body: (await first.json()) as T };
81+
return { status: first.status, body: await parseJsonResponse<T>(first) };
5182
}
5283

5384
// 402 → build the signed payment and retry.
54-
const bodyForParse = await first.clone().json().catch(() => undefined);
55-
const paymentRequired = http.getPaymentRequiredResponse(
56-
(name) => first.headers.get(name),
57-
bodyForParse,
58-
);
85+
let bodyForParse: unknown;
86+
try {
87+
bodyForParse = await parseJsonResponse<unknown>(first.clone());
88+
} catch (error) {
89+
if (error instanceof AppraisalResponseParseError) {
90+
bodyForParse = undefined;
91+
} else {
92+
throw error;
93+
}
94+
}
95+
96+
let paymentRequired: PaymentRequired;
97+
try {
98+
paymentRequired = http.getPaymentRequiredResponse(
99+
(name) => first.headers.get(name),
100+
bodyForParse,
101+
);
102+
} catch {
103+
throw new X402PaymentError(
104+
`x402 payment required response was invalid (${first.status})`,
105+
first.status,
106+
);
107+
}
108+
59109
const payload = await http.createPaymentPayload(paymentRequired);
60110
const payHeaders = http.encodePaymentSignatureHeader(payload);
61111

62112
const paid = await fetch(url, {
63113
...init,
64114
headers: { ...(init.headers ?? {}), ...payHeaders },
65115
});
66-
const body = (await paid.json()) as T;
116+
const body = await parseJsonResponse<T>(paid);
67117

68118
if (paid.status !== 200) {
69-
throw new X402PaymentError(
70-
`paid request failed (${paid.status}): ${JSON.stringify(body)}`,
71-
);
119+
throw new X402PaymentError(`paid request failed (${paid.status})`, paid.status);
72120
}
73121
const settlement = http.getPaymentSettleResponse((name) => paid.headers.get(name));
74122
return { status: paid.status, body, settlement };

services/appraisal-api/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export {
1717
} from "./config.js";
1818
export {
1919
createPaidFetch,
20+
AppraisalResponseParseError,
2021
X402PaymentError,
2122
type PaidClientConfig,
2223
type PaidResult,

0 commit comments

Comments
 (0)