From 18a5dbe05844adfd81b71feb24d6c42ee8f6611a Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 14 Jul 2026 19:46:25 -0300 Subject: [PATCH] fix: preserve ky error semantics --- playwright.config.ts | 3 +- src/api/api-error.ts | 31 ++++-------------- src/test/api/api-error.test.ts | 60 ++++++++++++++++++++++++---------- 3 files changed, 51 insertions(+), 43 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index aa56aca3..5a79ae6d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ workers: process.env.CI ? 1 : undefined, reporter: process.env.CI ? "github" : "list", use: { + // Port 4173 isolates Playwright from the app's normal port 3000 server. baseURL: "http://localhost:4173", trace: "on-first-retry", }, @@ -20,7 +21,7 @@ export default defineConfig({ webServer: { command: "npm run dev -- --port 4173 --strictPort", url: "http://localhost:4173", - reuseExistingServer: false, + reuseExistingServer: !process.env.CI, timeout: 120_000, }, }); diff --git a/src/api/api-error.ts b/src/api/api-error.ts index 2ea92dd1..4dc288e3 100644 --- a/src/api/api-error.ts +++ b/src/api/api-error.ts @@ -1,14 +1,4 @@ -import { HTTPError } from "ky"; - -export class ApiError extends Error { - readonly status: number; - - constructor(message: string, status: number) { - super(message); - this.name = "ApiError"; - this.status = status; - } -} +import { isHTTPError, isNetworkError } from "ky"; export function mapHttpStatusToMessage(status: number, data?: unknown): string { if (status === 401) { @@ -36,22 +26,15 @@ export function mapHttpStatusToMessage(status: number, data?: unknown): string { } export function normalizeApiRequestError(error: Error): Error { - if (!(error instanceof HTTPError)) { + if (isHTTPError(error)) { + error.message = mapHttpStatusToMessage(error.response.status, error.data); + return error; + } + + if (isNetworkError(error)) { error.message = "No response from server. Please check your connection."; return error; } - error.message = mapHttpStatusToMessage(error.response.status, error.data); return error; } - -export async function createApiErrorFromResponse(response: Response): Promise { - let data: unknown; - try { - data = await response.json(); - } catch { - data = undefined; - } - - return new ApiError(mapHttpStatusToMessage(response.status, data), response.status); -} diff --git a/src/test/api/api-error.test.ts b/src/test/api/api-error.test.ts index e138b6c1..17ef48a7 100644 --- a/src/test/api/api-error.test.ts +++ b/src/test/api/api-error.test.ts @@ -1,30 +1,54 @@ -import { HTTPError } from "ky"; -import { describe, expect, it } from "vitest"; +import ky from "ky"; +import { describe, expect, it, vi } from "vitest"; import { normalizeApiRequestError } from "@/api/api-error"; -function createHttpError(status: number, data: unknown): HTTPError { - const response = new Response(JSON.stringify(data), { - status, - headers: { "Content-Type": "application/json" }, +const RESOURCE_URL = "https://example.test/api/resource"; + +function createClient(fetchImpl: typeof fetch) { + return ky.create({ + fetch: fetchImpl, + retry: 0, + timeout: 50, + hooks: { beforeError: [({ error }) => normalizeApiRequestError(error)] }, }); - const request = new Request("https://example.test/api/resource"); - const error = new HTTPError(response, request, {} as never); - error.data = data; - return error; } describe("normalizeApiRequestError", () => { - it("uses Ky 2 pre-parsed response data for API messages", () => { - const error = createHttpError(422, { message: "Validation failed" }); + it("surfaces Ky's pre-parsed JSON body for HTTP errors via the hook", async () => { + const fetchImpl = vi.fn(() => + Promise.resolve( + new Response(JSON.stringify({ message: "Validation failed" }), { + status: 422, + headers: { "Content-Type": "application/json" }, + }), + ), + ) as unknown as typeof fetch; - expect(normalizeApiRequestError(error)).toBe(error); - expect(error.message).toBe("Validation failed"); + await expect(createClient(fetchImpl).get(RESOURCE_URL).json()).rejects.toThrow( + "Validation failed", + ); }); - it("preserves the connection-error message for non-HTTP failures", () => { - const error = new TypeError("fetch failed"); + it("reports a connection failure for network errors", async () => { + const fetchImpl = vi.fn(() => + Promise.reject(new TypeError("fetch failed")), + ) as unknown as typeof fetch; - expect(normalizeApiRequestError(error)).toBe(error); - expect(error.message).toBe("No response from server. Please check your connection."); + await expect(createClient(fetchImpl).get(RESOURCE_URL).json()).rejects.toThrow( + "No response from server. Please check your connection.", + ); + }); + + it("preserves the original message for timeouts", async () => { + const fetchImpl = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + const signal = init?.signal; + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(signal?.reason)); + }); + }) as unknown as typeof fetch; + + await expect(createClient(fetchImpl).get(RESOURCE_URL).json()).rejects.toThrow( + "Request timed out", + ); }); });