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
5 changes: 5 additions & 0 deletions .changeset/preserve-http-error-details.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sapiom/core": patch
---

Preserve structured HTTP error details on core client request failures.
43 changes: 41 additions & 2 deletions packages/core/src/client/HttpClient.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { randomUUID } from "node:crypto";

import type { HttpError, HttpResponse } from "../types/http.js";

/**
* Request configuration for the HTTP client
*/
Expand Down Expand Up @@ -35,6 +37,31 @@ export interface HttpClientConfig {
versionPrefix?: string | null;
}

class HttpRequestError extends Error implements HttpError {
public readonly status: number;
public readonly statusText: string;
public readonly headers: Record<string, string>;
public readonly data: any;
public readonly response: HttpResponse;

constructor(response: Response, data: any, headers: Record<string, string>) {
super(
`Request failed with status ${response.status}: ${JSON.stringify(data)}`,
);
this.name = "HttpRequestError";
this.status = response.status;
this.statusText = response.statusText || "";
this.headers = headers;
this.data = data;
this.response = {
status: response.status,
statusText: this.statusText,
headers,
data,
};
}
}

/**
* Core HTTP client for making API requests.
* Uses native fetch for zero external dependencies.
Expand Down Expand Up @@ -172,8 +199,10 @@ export class HttpClient {

if (!response.ok) {
const errorData = await this.parseResponse(response);
throw new Error(
`Request failed with status ${response.status}: ${JSON.stringify(errorData)}`,
throw new HttpRequestError(
response,
errorData,
this.responseHeaders(response),
);
}

Expand Down Expand Up @@ -264,6 +293,16 @@ export class HttpClient {
return text;
}

private responseHeaders(response: Response): Record<string, string> {
const headers: Record<string, string> = {};
if (typeof response.headers.forEach === "function") {
response.headers.forEach((value, key) => {
headers[key] = value;
});
}
return headers;
}

/**
* Build full URL with query parameters.
* Automatically prefixes paths with the configured version prefix.
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/client/SapiomClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,40 @@ describe("SapiomClient", () => {
);
});

it("should preserve structured HTTP error details", async () => {
const errorData = { error: "Payment Required", transactionId: "tx-123" };
mockFetch.mockResolvedValueOnce({
ok: false,
status: 402,
statusText: "Payment Required",
headers: new Headers({
"content-type": "application/json",
"x-sapiom-transaction-id": "tx-123",
}),
json: jest.fn().mockResolvedValue(errorData),
text: jest.fn().mockResolvedValue(JSON.stringify(errorData)),
});

await expect(client.request({ url: "/paid" })).rejects.toMatchObject({
name: "HttpRequestError",
message: expect.stringMatching(/Request failed with status 402/),
status: 402,
statusText: "Payment Required",
data: errorData,
headers: expect.objectContaining({
"x-sapiom-transaction-id": "tx-123",
}),
response: expect.objectContaining({
status: 402,
statusText: "Payment Required",
data: errorData,
headers: expect.objectContaining({
"x-sapiom-transaction-id": "tx-123",
}),
}),
});
});

it("should handle error responses with text content", async () => {
const errorText = "Internal Server Error";
mockFetch.mockResolvedValueOnce({
Expand Down
Loading