1
9
10
11export const ErrorCodes = {
12 VALIDATION_ERROR: "VALIDATION_ERROR",
13 UNAUTHORIZED: "UNAUTHORIZED",
14 FORBIDDEN: "FORBIDDEN",
15 NOT_FOUND: "NOT_FOUND",
16 CONFLICT: "CONFLICT",
17 RATE_LIMITED: "RATE_LIMITED",
18 NOT_IMPLEMENTED: "NOT_IMPLEMENTED",
19 SEARCH_UNAVAILABLE: "SEARCH_UNAVAILABLE",
20 STORAGE_ERROR: "STORAGE_ERROR",
21 STORAGE_TIMEOUT: "STORAGE_TIMEOUT",
22
23 FILE_TOO_LARGE: "FILE_TOO_LARGE",
24 QUOTA_EXCEEDED: "QUOTA_EXCEEDED",
25 MIME_MISMATCH: "MIME_MISMATCH",
26 UPLOAD_INCOMPLETE: "UPLOAD_INCOMPLETE",
27 UPLOAD_EXPIRED: "UPLOAD_EXPIRED",
28 FILE_QUARANTINED: "FILE_QUARANTINED",
29 AI_ERROR: "AI_ERROR",
30 TIMEOUT: "TIMEOUT",
31 INTERNAL: "INTERNAL",
32} as const;
33
34export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
35
36
40export const ERROR_STATUS: Record<ErrorCode, number> = {
41 VALIDATION_ERROR: 422,
42 UNAUTHORIZED: 401,
43 FORBIDDEN: 403,
44 NOT_FOUND: 404,
45 CONFLICT: 409,
46 RATE_LIMITED: 429,
47 NOT_IMPLEMENTED: 501,
48 SEARCH_UNAVAILABLE: 503,
49 STORAGE_ERROR: 502,
50 STORAGE_TIMEOUT: 504,
51
52 FILE_TOO_LARGE: 413,
53 QUOTA_EXCEEDED: 413,
54 MIME_MISMATCH: 415,
55 UPLOAD_INCOMPLETE: 422,
56 UPLOAD_EXPIRED: 410,
57 FILE_QUARANTINED: 403,
58 AI_ERROR: 502,
59 TIMEOUT: 504,
60 INTERNAL: 500,
61};
62
63
64export interface ApiErrorJson {
65 error: {
66 code: ErrorCode;
67 message: string;
68 status: number;
69 requestId?: string;
70 details?: unknown;
71 };
72}
73
74export interface ApiErrorOptions {
75
76 message: string;
77
78 status?: number;
79
80 details?: unknown;
81
82 requestId?: string;
83
84 cause?: unknown;
85}
86
87
94export class ApiError extends Error {
95 readonly code: ErrorCode;
96 readonly status: number;
97 readonly details?: unknown;
98 readonly requestId?: string;
99
100 constructor(code: ErrorCode, options: ApiErrorOptions) {
101 super(options.message, options.cause !== undefined ? { cause: options.cause } : undefined);
102 this.name = "ApiError";
103 this.code = code;
104 this.status = options.status ?? ERROR_STATUS[code];
105 if (options.details !== undefined) this.details = options.details;
106 if (options.requestId !== undefined) this.requestId = options.requestId;
107
108 if (
109 typeof (Error as { captureStackTrace?: (t: object, f?: Function) => void })
110 .captureStackTrace === "function"
111 ) {
112 (
113 Error as unknown as { captureStackTrace: (t: object, f?: Function) => void }
114 ).captureStackTrace(this, ApiError);
115 }
116 }
117
118
119 toJSON(): ApiErrorJson["error"] {
120 const out: ApiErrorJson["error"] = {
121 code: this.code,
122 message: this.message,
123 status: this.status,
124 };
125 if (this.requestId !== undefined) out.requestId = this.requestId;
126 if (this.details !== undefined) out.details = this.details;
127 return out;
128 }
129}
130
131
136export function toApiError(json: Partial<ApiErrorJson> | ApiError): ApiError {
137 if (json instanceof ApiError) return json;
138
139
140
141 const body = ((json as Partial<ApiErrorJson> | undefined)?.error ?? {}) as ApiErrorJson["error"];
142 const code = (Object.values(ErrorCodes) as string[]).includes(body.code)
143 ? (body.code as ErrorCode)
144 : "INTERNAL";
145 const message =
146 typeof body.message === "string" && body.message.length > 0 ? body.message : "Internal error";
147 const requestId = typeof body.requestId === "string" ? body.requestId : undefined;
148 return new ApiError(code, {
149 message,
150 ...(typeof body.status === "number" ? { status: body.status } : {}),
151 ...(body.details !== undefined ? { details: body.details } : {}),
152 ...(requestId !== undefined ? { requestId } : {}),
153 });
154}
155
156
165export function fromException(err: unknown, requestId?: string): ApiError {
166 if (err instanceof ApiError) {
167 const id = err.requestId ?? requestId;
168 return id !== undefined ? withRequestId(err, id) : err;
169 }
170
171
172
173
174 if (isZodError(err)) {
175 return new ApiError("VALIDATION_ERROR", {
176 message: "Request validation failed",
177 details: err.issues.map((issue) => ({
178 path: issue.path.join("."),
179 message: issue.message,
180 })),
181 ...(requestId !== undefined ? { requestId } : {}),
182 });
183 }
184
185 if (err instanceof Error) {
186 const anyErr = err as Error & { code?: string; status?: number; details?: unknown };
187 const code = (Object.values(ErrorCodes) as string[]).includes(anyErr.code ?? "")
188 ? (anyErr.code as ErrorCode)
189 : "INTERNAL";
190 return new ApiError(code, {
191 message: code === "INTERNAL" ? "Internal error" : err.message,
192 ...(typeof anyErr.status === "number" ? { status: anyErr.status } : {}),
193 ...(anyErr.details !== undefined ? { details: anyErr.details } : {}),
194 ...(requestId !== undefined ? { requestId } : {}),
195 ...(code === "INTERNAL" ? { cause: err } : {}),
196 });
197 }
198
199 return new ApiError("INTERNAL", {
200 message: "Internal error",
201 ...(requestId !== undefined ? { requestId } : {}),
202 });
203}
204
205function withRequestId(err: ApiError, requestId: string): ApiError {
206 if (err.requestId === requestId) return err;
207 const copy = new ApiError(err.code, {
208 message: err.message,
209 status: err.status,
210 ...(err.details !== undefined ? { details: err.details } : {}),
211 requestId,
212 cause: err,
213 });
214 return copy;
215}
216
217function isZodError(
218 err: unknown,
219): err is { issues: { path: (string | number)[]; message: string }[] } {
220 return (
221 typeof err === "object" &&
222 err !== null &&
223 "issues" in err &&
224 Array.isArray((err as { issues?: unknown }).issues)
225 );
226}
227