diff --git a/src/errors/base.ts b/src/errors/base.ts index e4390e8..5a2d945 100644 --- a/src/errors/base.ts +++ b/src/errors/base.ts @@ -1,9 +1,26 @@ +export interface AssemblyErrorOptions { + /** + * Override the default message for this error class. + * Each subclass provides a sensible default, so this is rarely needed. + */ + message?: string; + /** The original error that caused this one, forwarded to native Error.cause. */ + cause?: unknown; + /** Arbitrary extra context (e.g. raw response body) for debugging. */ + details?: unknown; +} + +interface AssemblyBaseErrorInit extends AssemblyErrorOptions { + message: string; + statusCode: number; +} + export class AssemblyError extends Error { readonly statusCode: number; readonly details?: unknown; - constructor(message: string, statusCode: number, details?: unknown) { - super(message); + constructor({ message, statusCode, cause, details }: AssemblyBaseErrorInit) { + super(message, { cause }); this.name = "AssemblyError"; this.statusCode = statusCode; this.details = details; diff --git a/src/errors/connection.ts b/src/errors/connection.ts index 0f78f5e..5638784 100644 --- a/src/errors/connection.ts +++ b/src/errors/connection.ts @@ -1,13 +1,15 @@ +import type { AssemblyErrorOptions } from "src/errors/base"; import { AssemblyError } from "src/errors/base"; export class AssemblyConnectionError extends AssemblyError { - constructor(messageOverride?: string, details?: unknown) { - super( - messageOverride ?? + constructor({ message, ...rest }: AssemblyErrorOptions = {}) { + super({ + message: + message ?? "A network error occurred while connecting to the Assembly API", - 503, - details - ); + statusCode: 503, + ...rest, + }); this.name = "AssemblyConnectionError"; } } diff --git a/src/errors/forbidden.ts b/src/errors/forbidden.ts index 80623b4..084a1eb 100644 --- a/src/errors/forbidden.ts +++ b/src/errors/forbidden.ts @@ -1,13 +1,15 @@ import { AssemblyError } from "src/errors/base"; +import type { AssemblyErrorOptions } from "src/errors/base"; export class AssemblyForbiddenError extends AssemblyError { - constructor(messageOverride?: string, details?: unknown) { - super( - messageOverride ?? + constructor({ message, ...rest }: AssemblyErrorOptions = {}) { + super({ + message: + message ?? "Forbidden: the API key lacks permission to perform this action", - 403, - details - ); + statusCode: 403, + ...rest, + }); this.name = "AssemblyForbiddenError"; } } diff --git a/src/errors/index.ts b/src/errors/index.ts index c4b66ab..ca81650 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -1,4 +1,5 @@ export { AssemblyError } from "src/errors/base"; +export type { AssemblyErrorOptions } from "src/errors/base"; export { AssemblyConnectionError } from "src/errors/connection"; export { AssemblyForbiddenError } from "src/errors/forbidden"; export { AssemblyInvalidTokenError } from "src/errors/invalid-token"; @@ -6,7 +7,9 @@ export { AssemblyMissingApiKeyError } from "src/errors/missing-api-key"; export { AssemblyNoTokenError } from "src/errors/no-token"; export { AssemblyNotFoundError } from "src/errors/not-found"; export { AssemblyRateLimitError } from "src/errors/rate-limit"; +export type { AssemblyRateLimitErrorOptions } from "src/errors/rate-limit"; export { AssemblyResponseParseError } from "src/errors/response-parse"; +export type { AssemblyResponseParseErrorOptions } from "src/errors/response-parse"; export { AssemblyServerError } from "src/errors/server"; export { AssemblyUnauthorizedError } from "src/errors/unauthorized"; export { AssemblyValidationError } from "src/errors/validation"; diff --git a/src/errors/invalid-token.ts b/src/errors/invalid-token.ts index 7d8587a..d0ad1da 100644 --- a/src/errors/invalid-token.ts +++ b/src/errors/invalid-token.ts @@ -1,13 +1,15 @@ import { AssemblyError } from "src/errors/base"; +import type { AssemblyErrorOptions } from "src/errors/base"; export class AssemblyInvalidTokenError extends AssemblyError { - constructor(messageOverride?: string, details?: unknown) { - super( - messageOverride ?? + constructor({ message, ...rest }: AssemblyErrorOptions = {}) { + super({ + message: + message ?? "The provided token could not be decrypted or has an invalid payload", - 401, - details - ); + statusCode: 401, + ...rest, + }); this.name = "AssemblyInvalidTokenError"; } } diff --git a/src/errors/missing-api-key.ts b/src/errors/missing-api-key.ts index c754238..4b7db92 100644 --- a/src/errors/missing-api-key.ts +++ b/src/errors/missing-api-key.ts @@ -1,12 +1,13 @@ import { AssemblyError } from "src/errors/base"; +import type { AssemblyErrorOptions } from "src/errors/base"; export class AssemblyMissingApiKeyError extends AssemblyError { - constructor(messageOverride?: string, details?: unknown) { - super( - messageOverride ?? "Assembly API key is missing or empty", - 400, - details - ); + constructor({ message, ...rest }: AssemblyErrorOptions = {}) { + super({ + message: message ?? "Assembly API key is missing or empty", + statusCode: 400, + ...rest, + }); this.name = "AssemblyMissingApiKeyError"; } } diff --git a/src/errors/no-token.ts b/src/errors/no-token.ts index bf230b4..4a3efb5 100644 --- a/src/errors/no-token.ts +++ b/src/errors/no-token.ts @@ -1,13 +1,15 @@ import { AssemblyError } from "src/errors/base"; +import type { AssemblyErrorOptions } from "src/errors/base"; export class AssemblyNoTokenError extends AssemblyError { - constructor(messageOverride?: string, details?: unknown) { - super( - messageOverride ?? + constructor({ message, ...rest }: AssemblyErrorOptions = {}) { + super({ + message: + message ?? "A token is required for this operation but was not provided", - 400, - details - ); + statusCode: 400, + ...rest, + }); this.name = "AssemblyNoTokenError"; } } diff --git a/src/errors/not-found.ts b/src/errors/not-found.ts index e598e25..32a4638 100644 --- a/src/errors/not-found.ts +++ b/src/errors/not-found.ts @@ -1,12 +1,13 @@ import { AssemblyError } from "src/errors/base"; +import type { AssemblyErrorOptions } from "src/errors/base"; export class AssemblyNotFoundError extends AssemblyError { - constructor(messageOverride?: string, details?: unknown) { - super( - messageOverride ?? "The requested resource was not found", - 404, - details - ); + constructor({ message, ...rest }: AssemblyErrorOptions = {}) { + super({ + message: message ?? "The requested resource was not found", + statusCode: 404, + ...rest, + }); this.name = "AssemblyNotFoundError"; } } diff --git a/src/errors/rate-limit.ts b/src/errors/rate-limit.ts index 142f58a..4ce3d9a 100644 --- a/src/errors/rate-limit.ts +++ b/src/errors/rate-limit.ts @@ -1,14 +1,23 @@ import { AssemblyError } from "src/errors/base"; +import type { AssemblyErrorOptions } from "src/errors/base"; + +export interface AssemblyRateLimitErrorOptions extends AssemblyErrorOptions { + retryAfter?: number; +} export class AssemblyRateLimitError extends AssemblyError { readonly retryAfter?: number; - constructor( - messageOverride?: string, - retryAfter?: number, - details?: unknown - ) { - super(messageOverride ?? "Rate limit exceeded", 429, details); + constructor({ + message, + retryAfter, + ...rest + }: AssemblyRateLimitErrorOptions = {}) { + super({ + message: message ?? "Rate limit exceeded", + statusCode: 429, + ...rest, + }); this.name = "AssemblyRateLimitError"; this.retryAfter = retryAfter; } diff --git a/src/errors/response-parse.ts b/src/errors/response-parse.ts index 0ec0eee..96d4da5 100644 --- a/src/errors/response-parse.ts +++ b/src/errors/response-parse.ts @@ -1,17 +1,33 @@ import { AssemblyError } from "src/errors/base"; +import type { AssemblyErrorOptions } from "src/errors/base"; +import { z } from "zod"; import type { ZodError } from "zod"; +export interface AssemblyResponseParseErrorOptions extends Omit< + AssemblyErrorOptions, + "cause" +> { + cause: ZodError; +} + export class AssemblyResponseParseError extends AssemblyError { - readonly zodError: ZodError; + readonly validationErrors: string; - constructor(zodError: ZodError, messageOverride?: string) { - super( - messageOverride ?? - "Assembly API response did not match the expected schema", - 500, - zodError - ); + constructor({ message, cause, details }: AssemblyResponseParseErrorOptions) { + super({ + cause, + details, + message: + message ?? "Assembly API response did not match the expected schema", + statusCode: 500, + }); this.name = "AssemblyResponseParseError"; - this.zodError = zodError; + this.validationErrors = z.prettifyError(cause); + } + + // Narrows the inherited Error.cause to ZodError — safe because the + // constructor enforces cause: ZodError at the call site. + override get cause(): ZodError { + return super.cause as ZodError; } } diff --git a/src/errors/server.ts b/src/errors/server.ts index 12af500..0409009 100644 --- a/src/errors/server.ts +++ b/src/errors/server.ts @@ -1,12 +1,13 @@ import { AssemblyError } from "src/errors/base"; +import type { AssemblyErrorOptions } from "src/errors/base"; export class AssemblyServerError extends AssemblyError { - constructor(messageOverride?: string, details?: unknown) { - super( - messageOverride ?? "An unexpected error occurred on the Assembly server", - 500, - details - ); + constructor({ message, ...rest }: AssemblyErrorOptions = {}) { + super({ + message: message ?? "An unexpected error occurred on the Assembly server", + statusCode: 500, + ...rest, + }); this.name = "AssemblyServerError"; } } diff --git a/src/errors/unauthorized.ts b/src/errors/unauthorized.ts index e412fbd..d47034e 100644 --- a/src/errors/unauthorized.ts +++ b/src/errors/unauthorized.ts @@ -1,12 +1,13 @@ import { AssemblyError } from "src/errors/base"; +import type { AssemblyErrorOptions } from "src/errors/base"; export class AssemblyUnauthorizedError extends AssemblyError { - constructor(messageOverride?: string, details?: unknown) { - super( - messageOverride ?? "Unauthorized: the API key was rejected by Assembly", - 401, - details - ); + constructor({ message, ...rest }: AssemblyErrorOptions = {}) { + super({ + message: message ?? "Unauthorized: the API key was rejected by Assembly", + statusCode: 401, + ...rest, + }); this.name = "AssemblyUnauthorizedError"; } } diff --git a/src/errors/validation.ts b/src/errors/validation.ts index 1a39f7b..ce836b6 100644 --- a/src/errors/validation.ts +++ b/src/errors/validation.ts @@ -1,12 +1,14 @@ import { AssemblyError } from "src/errors/base"; +import type { AssemblyErrorOptions } from "src/errors/base"; export class AssemblyValidationError extends AssemblyError { - constructor(messageOverride?: string, details?: unknown) { - super( - messageOverride ?? "The request payload was rejected by the Assembly API", - 422, - details - ); + constructor({ message, ...rest }: AssemblyErrorOptions = {}) { + super({ + message: + message ?? "The request payload was rejected by the Assembly API", + statusCode: 422, + ...rest, + }); this.name = "AssemblyValidationError"; } } diff --git a/test/errors.test.ts b/test/errors.test.ts index 909103b..28abb45 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test"; +import { beforeAll, describe, expect, it } from "bun:test"; import { AssemblyConnectionError, @@ -13,12 +13,14 @@ import { AssemblyServerError, AssemblyUnauthorizedError, AssemblyValidationError, -} from "src/errors/index"; +} from "src/errors"; +import type { AssemblyErrorOptions } from "src/errors"; import { z } from "zod"; +import type { ZodError } from "zod"; describe("AssemblyError (base)", () => { it("has correct name, statusCode, and message", () => { - const err = new AssemblyError("base error", 500); + const err = new AssemblyError({ message: "base error", statusCode: 500 }); expect(err.name).toBe("AssemblyError"); expect(err.statusCode).toBe(500); expect(err.message).toBe("base error"); @@ -27,15 +29,21 @@ describe("AssemblyError (base)", () => { it("stores optional details", () => { const details = { raw: "body" }; - const err = new AssemblyError("msg", 400, details); + const err = new AssemblyError({ details, message: "msg", statusCode: 400 }); expect(err.details).toBe(details); }); + + it("stores optional cause", () => { + const cause = new Error("original"); + const err = new AssemblyError({ cause, message: "msg", statusCode: 400 }); + expect(err.cause).toBe(cause); + }); }); describe("factory-generated error subclasses", () => { const cases: { name: string; - Ctor: new (msg?: string, details?: unknown) => AssemblyError; + Ctor: new (opts?: AssemblyErrorOptions) => AssemblyError; statusCode: number; defaultMessageFragment: string; }[] = [ @@ -117,16 +125,22 @@ describe("factory-generated error subclasses", () => { ); }); - it("accepts a custom message override", () => { - const err = new Ctor("custom message"); + it("accepts a custom message", () => { + const err = new Ctor({ message: "custom message" }); expect(err.message).toBe("custom message"); }); it("stores optional details", () => { const details = { extra: true }; - const err = new Ctor(undefined, details); + const err = new Ctor({ details }); expect(err.details).toEqual(details); }); + + it("stores optional cause", () => { + const cause = new Error("original"); + const err = new Ctor({ cause }); + expect(err.cause).toBe(cause); + }); } ); }); @@ -150,43 +164,71 @@ describe("AssemblyRateLimitError", () => { }); it("stores retryAfter when provided", () => { - const err = new AssemblyRateLimitError(undefined, 30); + const err = new AssemblyRateLimitError({ retryAfter: 30 }); expect(err.retryAfter).toBe(30); }); - it("accepts a custom message override", () => { - const err = new AssemblyRateLimitError("slow down"); + it("accepts a custom message", () => { + const err = new AssemblyRateLimitError({ message: "slow down" }); expect(err.message).toBe("slow down"); }); }); describe("AssemblyResponseParseError", () => { - const parseResult = z.string().safeParse(42); - if (parseResult.success) { - throw new Error("Expected parse to fail"); - } - const zodError = parseResult.error; + let zodError: ZodError; + let nestedZodError: ZodError; + + beforeAll(() => { + const flat = z.string().safeParse(42); + if (flat.success) { + throw new Error("Expected flat parse to fail"); + } + zodError = flat.error; + + const nested = z + .object({ user: z.object({ age: z.number(), name: z.string() }) }) + .safeParse({ user: { age: "x", name: 1 } }); + if (nested.success) { + throw new Error("Expected nested parse to fail"); + } + nestedZodError = nested.error; + }); it("has correct .name and .statusCode", () => { - const err = new AssemblyResponseParseError(zodError); + const err = new AssemblyResponseParseError({ cause: zodError }); expect(err.name).toBe("AssemblyResponseParseError"); expect(err.statusCode).toBe(500); }); it("is instanceof AssemblyError and Error", () => { - const err = new AssemblyResponseParseError(zodError); + const err = new AssemblyResponseParseError({ cause: zodError }); expect(err instanceof AssemblyError).toBe(true); expect(err instanceof Error).toBe(true); }); - it("stores the ZodError on .zodError", () => { - const err = new AssemblyResponseParseError(zodError); - expect(err.zodError).toBe(zodError); - expect(err.zodError.issues.length).toBeGreaterThan(0); + it("exposes the ZodError on .cause", () => { + const err = new AssemblyResponseParseError({ cause: zodError }); + expect(err.cause).toBe(zodError); + expect(err.cause.issues.length).toBeGreaterThan(0); }); - it("accepts a custom message override", () => { - const err = new AssemblyResponseParseError(zodError, "schema mismatch"); + it("accepts a custom message", () => { + const err = new AssemblyResponseParseError({ + cause: zodError, + message: "schema mismatch", + }); expect(err.message).toBe("schema mismatch"); }); + + it("validationErrors is a non-empty string containing a checkmark", () => { + const err = new AssemblyResponseParseError({ cause: zodError }); + expect(err.validationErrors.length).toBeGreaterThan(0); + expect(err.validationErrors).toContain("✖"); + }); + + it("validationErrors includes field paths for nested errors", () => { + const err = new AssemblyResponseParseError({ cause: nestedZodError }); + expect(err.validationErrors).toContain("user.name"); + expect(err.validationErrors).toContain("user.age"); + }); });