Suite of AT Protocol TypeScript libraries built on web standards
1import { z } from "zod";
2import {
3 httpResponseCodeToName,
4 httpResponseCodeToString,
5 ResponseType,
6 ResponseTypeStrings,
7 XRPCError as XRPCClientError,
8} from "@atp/xrpc";
9
10// @NOTE Do not depend (directly or indirectly) on "./types" here, as it would
11// create a circular dependency.
12
13/**
14 * Zod schema for error result objects.
15 * Defines the structure of error responses with status code and optional error/message fields.
16 */
17export const errorResult: z.ZodObject<{
18 status: z.ZodNumber;
19 error: z.ZodOptional<z.ZodString>;
20 message: z.ZodOptional<z.ZodString>;
21}> = z.object({
22 status: z.number(),
23 error: z.string().optional(),
24 message: z.string().optional(),
25});
26
27/**
28 * Type representing an error result object.
29 * Contains HTTP status code and optional error identifier and message.
30 */
31export type ErrorResult = z.infer<typeof errorResult>;
32
33/**
34 * Type guard to check if a value is an ErrorResult.
35 * @param v - The value to check
36 * @returns True if the value matches the ErrorResult schema
37 */
38export function isErrorResult(v: unknown): v is ErrorResult {
39 return errorResult.safeParse(v).success;
40}
41
42/**
43 * Type guard to check if a value is an HTTP Error-like object.
44 */
45function isHttpErrorLike(
46 value: unknown,
47): value is { status: number; message: string; name: string } {
48 return (
49 typeof value === "object" &&
50 value !== null &&
51 "status" in value &&
52 "message" in value &&
53 "name" in value &&
54 typeof (value as Record<string, unknown>).status === "number" &&
55 typeof (value as Record<string, unknown>).message === "string" &&
56 typeof (value as Record<string, unknown>).name === "string"
57 );
58}
59
60/**
61 * Excludes ErrorResult from a value type and throws if the value is an ErrorResult.
62 * @template V - The value type
63 * @param v - The value to check and exclude
64 * @returns The value if it's not an ErrorResult
65 * @throws {XRPCError} If the value is an ErrorResult
66 */
67export function excludeErrorResult<V>(v: V): Exclude<V, ErrorResult> {
68 if (isErrorResult(v)) throw XRPCError.fromErrorResult(v);
69 return v as Exclude<V, ErrorResult>;
70}
71
72export { ResponseType };
73
74/**
75 * Base class for all XRPC errors.
76 * Extends the standard Error class with XRPC-specific properties and methods.
77 */
78export class XRPCError extends Error {
79 constructor(
80 public type: ResponseType,
81 public errorMessage?: string,
82 public customErrorName?: string,
83 options?: ErrorOptions,
84 ) {
85 super(errorMessage, options);
86 }
87
88 get statusCode(): number {
89 const { type } = this;
90
91 if (type < 400 || type >= 600 || !Number.isFinite(type)) {
92 return 500;
93 }
94
95 return type;
96 }
97
98 /**
99 * Gets the error payload for HTTP responses.
100 * For internal server errors (500), returns generic message instead of error details.
101 * @returns Object containing error name and message for the response
102 */
103 get payload(): {
104 error: string | undefined;
105 message: string | undefined;
106 } {
107 return {
108 error: this.customErrorName ?? this.typeName,
109 message: this.type === ResponseType.InternalServerError
110 ? this.typeStr // Do not respond with error details for 500s
111 : this.errorMessage || this.typeStr,
112 };
113 }
114
115 get typeName(): string | undefined {
116 return ResponseType[this.type];
117 }
118
119 get typeStr(): string | undefined {
120 return ResponseTypeStrings[this.type];
121 }
122
123 static fromError(cause: unknown): XRPCError {
124 if (cause instanceof XRPCError) {
125 return cause;
126 }
127
128 if (cause instanceof XRPCClientError) {
129 const { error, message, type } = mapFromClientError(cause);
130 return new XRPCError(type, message, error, { cause });
131 }
132
133 if (isHttpErrorLike(cause)) {
134 return new XRPCError(cause.status, cause.message, cause.name, { cause });
135 }
136
137 if (isErrorResult(cause)) {
138 return this.fromErrorResult(cause);
139 }
140
141 if (cause instanceof Error) {
142 return new InternalServerError(cause.message, undefined, { cause });
143 }
144
145 return new InternalServerError(
146 "Unexpected internal server error",
147 undefined,
148 { cause },
149 );
150 }
151
152 static fromErrorResult(err: ErrorResult): XRPCError {
153 return new XRPCError(err.status, err.message, err.error, { cause: err });
154 }
155}
156
157/**
158 * Error class for invalid request errors (HTTP 400).
159 * Used when the client request is malformed or invalid.
160 */
161export class InvalidRequestError extends XRPCError {
162 constructor(
163 errorMessage?: string,
164 customErrorName?: string,
165 options?: ErrorOptions,
166 ) {
167 super(ResponseType.InvalidRequest, errorMessage, customErrorName, options);
168 }
169
170 [Symbol.hasInstance](instance: unknown): boolean {
171 return (
172 instance instanceof XRPCError &&
173 instance.type === ResponseType.InvalidRequest
174 );
175 }
176}
177
178/**
179 * Error class for authentication required errors (HTTP 401).
180 * Used when the request requires authentication but none was provided or it was invalid.
181 */
182export class AuthRequiredError extends XRPCError {
183 constructor(
184 errorMessage?: string,
185 customErrorName?: string,
186 options?: ErrorOptions,
187 ) {
188 super(
189 ResponseType.AuthenticationRequired,
190 errorMessage,
191 customErrorName,
192 options,
193 );
194 }
195
196 [Symbol.hasInstance](instance: unknown): boolean {
197 return (
198 instance instanceof XRPCError &&
199 instance.type === ResponseType.AuthenticationRequired
200 );
201 }
202}
203
204/**
205 * Error class for forbidden errors (HTTP 403).
206 * Used when the client is authenticated but doesn't have permission to access the resource.
207 */
208export class ForbiddenError extends XRPCError {
209 constructor(
210 errorMessage?: string,
211 customErrorName?: string,
212 options?: ErrorOptions,
213 ) {
214 super(ResponseType.Forbidden, errorMessage, customErrorName, options);
215 }
216
217 [Symbol.hasInstance](instance: unknown): boolean {
218 return (
219 instance instanceof XRPCError && instance.type === ResponseType.Forbidden
220 );
221 }
222}
223
224/**
225 * Error class for internal server errors (HTTP 500).
226 * Used when an unexpected error occurs on the server side.
227 */
228export class InternalServerError extends XRPCError {
229 constructor(
230 errorMessage?: string,
231 customErrorName?: string,
232 options?: ErrorOptions,
233 ) {
234 super(
235 ResponseType.InternalServerError,
236 errorMessage,
237 customErrorName,
238 options,
239 );
240 }
241
242 [Symbol.hasInstance](instance: unknown): boolean {
243 return (
244 instance instanceof XRPCError &&
245 instance.type === ResponseType.InternalServerError
246 );
247 }
248}
249
250/**
251 * Error class for upstream failure errors (HTTP 502).
252 * Used when a dependent service fails or returns an invalid response.
253 */
254export class UpstreamFailureError extends XRPCError {
255 constructor(
256 errorMessage?: string,
257 customErrorName?: string,
258 options?: ErrorOptions,
259 ) {
260 super(ResponseType.UpstreamFailure, errorMessage, customErrorName, options);
261 }
262
263 [Symbol.hasInstance](instance: unknown): boolean {
264 return (
265 instance instanceof XRPCError &&
266 instance.type === ResponseType.UpstreamFailure
267 );
268 }
269}
270
271/**
272 * Error class for not enough resources errors (HTTP 507).
273 * Used when the server temporarily cannot handle the request due to resource constraints.
274 */
275export class NotEnoughResourcesError extends XRPCError {
276 constructor(
277 errorMessage?: string,
278 customErrorName?: string,
279 options?: ErrorOptions,
280 ) {
281 super(
282 ResponseType.NotEnoughResources,
283 errorMessage,
284 customErrorName,
285 options,
286 );
287 }
288
289 [Symbol.hasInstance](instance: unknown): boolean {
290 return (
291 instance instanceof XRPCError &&
292 instance.type === ResponseType.NotEnoughResources
293 );
294 }
295}
296
297/**
298 * Error class for upstream timeout errors (HTTP 504).
299 * Used when a dependent service times out or takes too long to respond.
300 */
301export class UpstreamTimeoutError extends XRPCError {
302 constructor(
303 errorMessage?: string,
304 customErrorName?: string,
305 options?: ErrorOptions,
306 ) {
307 super(ResponseType.UpstreamTimeout, errorMessage, customErrorName, options);
308 }
309
310 [Symbol.hasInstance](instance: unknown): boolean {
311 return (
312 instance instanceof XRPCError &&
313 instance.type === ResponseType.UpstreamTimeout
314 );
315 }
316}
317
318/**
319 * Error class for method not implemented errors (HTTP 501).
320 * Used when the requested XRPC method is not implemented by the server.
321 */
322export class MethodNotImplementedError extends XRPCError {
323 constructor(
324 errorMessage?: string,
325 customErrorName?: string,
326 options?: ErrorOptions,
327 ) {
328 super(
329 ResponseType.MethodNotImplemented,
330 errorMessage,
331 customErrorName,
332 options,
333 );
334 }
335
336 [Symbol.hasInstance](instance: unknown): boolean {
337 return (
338 instance instanceof XRPCError &&
339 instance.type === ResponseType.MethodNotImplemented
340 );
341 }
342}
343
344function mapFromClientError(error: XRPCClientError): {
345 error: string;
346 message: string;
347 type: ResponseType;
348} {
349 switch (error.status) {
350 case ResponseType.InvalidResponse:
351 // Upstream server returned an XRPC response that is not compatible with our internal lexicon definitions for that XRPC method.
352 // @NOTE This could be reflected as both a 500 ("we" are at fault) and 502 ("they" are at fault). Let's be gents about it.
353 return {
354 error: httpResponseCodeToName(ResponseType.InternalServerError),
355 message: httpResponseCodeToString(ResponseType.InternalServerError),
356 type: ResponseType.InternalServerError,
357 };
358 case ResponseType.Unknown:
359 // Typically a network error / unknown host
360 return {
361 error: httpResponseCodeToName(ResponseType.InternalServerError),
362 message: httpResponseCodeToString(ResponseType.InternalServerError),
363 type: ResponseType.InternalServerError,
364 };
365 default:
366 return {
367 error: error.error,
368 message: error.message,
369 type: error.status,
370 };
371 }
372}