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
11 changes: 9 additions & 2 deletions lib/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,15 @@ export const request = async <T>(url: string, options?: RequestInit & { data?: a
throw new UnauthorizedError("Unauthorized");
}
if (!response.ok) {
const error = (await response.text()).slice(0, 10000);
console.info("HTTP request", { ...details, error });
const rawError = (await response.text()).slice(0, 10000);
const isHtml =
rawError.trimStart().startsWith("<!DOCTYPE") ||
rawError.trimStart().startsWith("<html") ||
(response.headers.get("content-type") ?? "").includes("text/html");
const error = isHtml
? rawError.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.trim() || "HTML error page"
: rawError.slice(0, 200);
console.info("HTTP request", { ...details, error: rawError.slice(0, 500) });
throw new Error(`Request failed: ${response.status} ${error}`);
}
console.info("HTTP request", details);
Expand Down
52 changes: 52 additions & 0 deletions tests/lib/request.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { request, UnauthorizedError } from "@/lib/request";

beforeEach(() => {
jest.restoreAllMocks();
});

const mockFetch = (status: number, body: string, headers: Record<string, string> = {}) => {
jest.spyOn(globalThis, "fetch").mockResolvedValue({
ok: status >= 200 && status < 300,
status,
headers: new Headers(headers),
json: () => Promise.resolve(JSON.parse(body)),
text: () => Promise.resolve(body),
} as Response);
};

describe("request", () => {
it("returns parsed JSON on success", async () => {
mockFetch(200, '{"id": 1}');
const result = await request<{ id: number }>("https://api.example.com/test");
expect(result).toEqual({ id: 1 });
});

it("throws UnauthorizedError on 401", async () => {
mockFetch(401, "Unauthorized");
await expect(request("https://api.example.com/test")).rejects.toThrow(UnauthorizedError);
});

it("includes plain-text error body in thrown error (truncated to 200 chars)", async () => {
const longError = "x".repeat(300);
mockFetch(500, longError);
await expect(request("https://api.example.com/test")).rejects.toThrow(`Request failed: 500 ${"x".repeat(200)}`);
});

it("extracts <title> from HTML error responses instead of including raw HTML", async () => {
const html = "<!DOCTYPE html><html><head><title>Page not found</title></head><body><h1>404</h1></body></html>";
mockFetch(404, html);
await expect(request("https://api.example.com/test")).rejects.toThrow("Request failed: 404 Page not found");
});

it("falls back to 'HTML error page' when HTML has no <title>", async () => {
const html = "<!DOCTYPE html><html><body><h1>Error</h1></body></html>";
mockFetch(404, html);
await expect(request("https://api.example.com/test")).rejects.toThrow("Request failed: 404 HTML error page");
});

it("detects HTML via content-type header even without DOCTYPE", async () => {
const html = "<div>Some error</div>";
mockFetch(500, html, { "content-type": "text/html; charset=utf-8" });
await expect(request("https://api.example.com/test")).rejects.toThrow("Request failed: 500 HTML error page");
});
});
Loading