fix: preserve ky error semantics

This commit is contained in:
Alexandre Brandizzi 2026-07-14 19:46:25 -03:00
parent 5999cb1228
commit 18a5dbe058
3 changed files with 51 additions and 43 deletions

View file

@ -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,
},
});

View file

@ -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<ApiError> {
let data: unknown;
try {
data = await response.json();
} catch {
data = undefined;
}
return new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
}

View file

@ -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<Response>((_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",
);
});
});