Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 71 additions & 10 deletions typescript/packages/core/src/http/httpFacilitatorClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,40 @@ function responseExcerpt(text: string, limit: number = 200): string {
return `${compact.slice(0, limit - 3)}...`;
}

/**
* Produces a compact explanation for an unknown thrown value.
*
* @param error - The thrown value
* @returns A short message suitable for user-facing error context
*/
function errorDetail(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
}

if (typeof error === "string") {
return error;
}

try {
return responseExcerpt(JSON.stringify(error));
} catch {
return String(error);
}
}

/**
* Builds a contextual error while preserving the original cause when available.
*
* @param context - The contextual prefix for the error
* @param error - The original thrown value
* @returns An Error with contextual detail
*/
function contextualError(context: string, error: unknown): Error {
const message = `${context}: ${errorDetail(error)}`;
return error instanceof Error ? new Error(message, { cause: error }) : new Error(message);
}

/**
* Parses and validates a successful facilitator response body.
*
Expand Down Expand Up @@ -208,6 +242,7 @@ export class HTTPFacilitatorClient implements FacilitatorClient {
paymentPayload: PaymentPayload,
paymentRequirements: PaymentRequirements,
): Promise<VerifyResponse> {
const verifyUrl = `${this.url}/verify`;
let headers: Record<string, string> = {
"Content-Type": "application/json",
};
Expand All @@ -217,7 +252,7 @@ export class HTTPFacilitatorClient implements FacilitatorClient {
headers = { ...headers, ...authHeaders.headers };
}

const response = await fetch(`${this.url}/verify`, {
const response = await this.fetchWithContext("verify", verifyUrl, {
method: "POST",
headers,
redirect: "follow",
Expand Down Expand Up @@ -260,6 +295,7 @@ export class HTTPFacilitatorClient implements FacilitatorClient {
paymentPayload: PaymentPayload,
paymentRequirements: PaymentRequirements,
): Promise<SettleResponse> {
const settleUrl = `${this.url}/settle`;
let headers: Record<string, string> = {
"Content-Type": "application/json",
};
Expand All @@ -269,7 +305,7 @@ export class HTTPFacilitatorClient implements FacilitatorClient {
headers = { ...headers, ...authHeaders.headers };
}

const response = await fetch(`${this.url}/settle`, {
const response = await this.fetchWithContext("settle", settleUrl, {
method: "POST",
headers,
redirect: "follow",
Expand Down Expand Up @@ -308,6 +344,7 @@ export class HTTPFacilitatorClient implements FacilitatorClient {
* @returns Supported payment kinds and extensions
*/
async getSupported(): Promise<SupportedResponse> {
const supportedUrl = `${this.url}/supported`;
let headers: Record<string, string> = {
"Content-Type": "application/json",
};
Expand All @@ -319,7 +356,7 @@ export class HTTPFacilitatorClient implements FacilitatorClient {

let lastError: Error | null = null;
for (let attempt = 0; attempt < GET_SUPPORTED_RETRIES; attempt++) {
const response = await fetch(`${this.url}/supported`, {
const response = await this.fetchWithContext("supported", supportedUrl, {
method: "GET",
headers,
redirect: "follow",
Expand Down Expand Up @@ -357,19 +394,43 @@ export class HTTPFacilitatorClient implements FacilitatorClient {
headers: Record<string, string>;
}> {
if (this._createAuthHeaders) {
const authHeaders = (await this._createAuthHeaders()) as Record<
string,
Record<string, string>
>;
return {
headers: authHeaders[path] ?? {},
};
try {
const authHeaders = (await this._createAuthHeaders()) as Record<
string,
Record<string, string>
>;
return {
headers: authHeaders[path] ?? {},
};
} catch (error) {
throw contextualError(`Facilitator ${path} auth header setup failed`, error);
}
}
return {
headers: {},
};
}

/**
* Executes a facilitator HTTP request with operation-specific transport context.
*
* @param operation - The facilitator operation name
* @param url - The request URL
* @param init - The fetch request options
* @returns The HTTP response
*/
private async fetchWithContext(
operation: "verify" | "settle" | "supported",
url: string,
init: RequestInit,
): Promise<Response> {
try {
return await fetch(url, init);
} catch (error) {
throw contextualError(`Facilitator ${operation} request to ${url} failed`, error);
}
}

/**
* Helper to convert objects to JSON-safe format.
* Handles BigInt and other non-JSON types.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,66 @@ describe("HTTPFacilitatorClient", () => {
expect(error.message).toContain("Facilitator supported returned invalid data");
});

it("adds verify request context when fetch fails before a response arrives", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network unreachable")));

const client = new HTTPFacilitatorClient({ url: "https://facilitator.test" });
const error = await client
.verify(paymentPayload, paymentRequirements)
.catch(caught => caught as Error);

expect(error.message).toContain(
"Facilitator verify request to https://facilitator.test/verify failed",
);
expect(error.message).toContain("network unreachable");
});

it("adds settle request context when fetch fails before a response arrives", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("connection reset")));

const client = new HTTPFacilitatorClient({ url: "https://facilitator.test" });
const error = await client
.settle(paymentPayload, paymentRequirements)
.catch(caught => caught as Error);

expect(error.message).toContain(
"Facilitator settle request to https://facilitator.test/settle failed",
);
expect(error.message).toContain("connection reset");
});

it("adds supported request context when fetch fails before a response arrives", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("dns lookup failed")));

const client = new HTTPFacilitatorClient({ url: "https://facilitator.test" });
const error = await client.getSupported().catch(caught => caught as Error);

expect(error.message).toContain(
"Facilitator supported request to https://facilitator.test/supported failed",
);
expect(error.message).toContain("dns lookup failed");
});

it("adds verify auth-header context when auth setup fails", async () => {
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);

const client = new HTTPFacilitatorClient({
url: "https://facilitator.test",
createAuthHeaders: async () => {
throw new Error("missing API key");
},
});

const error = await client
.verify(paymentPayload, paymentRequirements)
.catch(caught => caught as Error);

expect(error.message).toContain("Facilitator verify auth header setup failed");
expect(error.message).toContain("missing API key");
expect(mockFetch).not.toHaveBeenCalled();
});

it("preserves VerifyError semantics for valid non-200 verify responses", async () => {
vi.stubGlobal(
"fetch",
Expand Down
Loading