diff --git a/src/shared/api/errors.test.ts b/src/shared/api/errors.test.ts new file mode 100644 index 00000000..154198be --- /dev/null +++ b/src/shared/api/errors.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { ApiError, ApiHttpError, ApiTimeoutError, ApiNetworkError } from "./errors.js"; + +describe("ApiError", () => { + it("extends Error with a message", () => { + const err = new ApiError("something broke"); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(ApiError); + expect(err.message).toBe("something broke"); + expect(err.name).toBe("ApiError"); + }); +}); + +describe("ApiHttpError", () => { + it("has status and optional body", () => { + const err = new ApiHttpError("Not Found", 404, { detail: "missing" }); + expect(err).toBeInstanceOf(ApiError); + expect(err).toBeInstanceOf(ApiHttpError); + expect(err.status).toBe(404); + expect(err.body).toEqual({ detail: "missing" }); + expect(err.name).toBe("ApiHttpError"); + }); + + it("body defaults to undefined", () => { + const err = new ApiHttpError("Server Error", 500); + expect(err.status).toBe(500); + expect(err.body).toBeUndefined(); + }); +}); + +describe("ApiTimeoutError", () => { + it("has timeoutMs", () => { + const err = new ApiTimeoutError(5000); + expect(err).toBeInstanceOf(ApiError); + expect(err.timeoutMs).toBe(5000); + expect(err.message).toContain("5000"); + expect(err.name).toBe("ApiTimeoutError"); + }); +}); + +describe("ApiNetworkError", () => { + it("wraps a cause", () => { + const cause = new Error("ECONNREFUSED"); + const err = new ApiNetworkError(cause); + expect(err).toBeInstanceOf(ApiError); + expect(err.cause).toBe(cause); + expect(err.name).toBe("ApiNetworkError"); + }); + + it("works without a cause", () => { + const err = new ApiNetworkError(); + expect(err.cause).toBeUndefined(); + }); +}); diff --git a/src/shared/api/errors.ts b/src/shared/api/errors.ts new file mode 100644 index 00000000..0c5c5eb2 --- /dev/null +++ b/src/shared/api/errors.ts @@ -0,0 +1,38 @@ +export class ApiError extends Error { + constructor(message: string) { + super(message); + this.name = "ApiError"; + } +} + +export class ApiHttpError extends ApiError { + readonly status: number; + readonly body?: unknown; + + constructor(message: string, status: number, body?: unknown) { + super(message); + this.name = "ApiHttpError"; + this.status = status; + this.body = body; + } +} + +export class ApiTimeoutError extends ApiError { + readonly timeoutMs: number; + + constructor(timeoutMs: number) { + super(`Request timed out after ${timeoutMs}ms`); + this.name = "ApiTimeoutError"; + this.timeoutMs = timeoutMs; + } +} + +export class ApiNetworkError extends ApiError { + override readonly cause?: Error | undefined; + + constructor(cause?: Error) { + super(cause ? `Network error: ${cause.message}` : "Network error"); + this.name = "ApiNetworkError"; + this.cause = cause; + } +}