@nextrush/errors 3.0.7 → 4.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,8 @@
5
5
  import type { Context } from '@nextrush/types';
6
6
  import { describe, expect, it, vi } from 'vitest';
7
7
  import { BadRequestError, InternalServerError, NotFoundError } from '../http-errors';
8
- import { catchAsync, errorHandler, notFoundHandler } from '../middleware';
8
+ import { errorHandler, notFoundHandler } from '../middleware';
9
+ import { ValidationError } from '../validation';
9
10
 
10
11
  function createMockContext(): Context {
11
12
  const ctx = {
@@ -119,6 +120,88 @@ describe('errorHandler', () => {
119
120
  const jsonCall = (ctx.json as ReturnType<typeof vi.fn>).mock.calls[0][0];
120
121
  expect(jsonCall.stack).toBeUndefined();
121
122
  });
123
+
124
+ // SEC-14: includeStack: true must be a no-op in production, not honored
125
+ // unconditionally — a stack trace is a map of internal paths/dependency
126
+ // versions, and the previous behavior handed that map to any client the
127
+ // moment a deploy forgot to flip includeStack off.
128
+ describe('production guard (SEC-14)', () => {
129
+ it('ignores includeStack: true in production and logs exactly one warning', async () => {
130
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
131
+ const handler = errorHandler({ includeStack: true, isProduction: true });
132
+ const ctx = createMockContext();
133
+
134
+ await handler(ctx, async () => {
135
+ throw new BadRequestError('Invalid');
136
+ });
137
+
138
+ const jsonCall = (ctx.json as ReturnType<typeof vi.fn>).mock.calls[0][0];
139
+ expect(jsonCall.stack).toBeUndefined();
140
+ // The default 4xx logger also calls console.warn — isolate the
141
+ // SEC-14 guard's own warning by content rather than call count.
142
+ const sec14Warnings = warnSpy.mock.calls.filter((call) =>
143
+ String(call[0]).includes('includeStack')
144
+ );
145
+ expect(sec14Warnings).toHaveLength(1);
146
+ warnSpy.mockRestore();
147
+ });
148
+
149
+ it('warns only once across multiple requests in production, not once per request', async () => {
150
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
151
+ const handler = errorHandler({ includeStack: true, isProduction: true });
152
+
153
+ for (let i = 0; i < 3; i++) {
154
+ const ctx = createMockContext();
155
+ await handler(ctx, async () => {
156
+ throw new BadRequestError('Invalid');
157
+ });
158
+ }
159
+
160
+ const sec14Warnings = warnSpy.mock.calls.filter((call) =>
161
+ String(call[0]).includes('includeStack')
162
+ );
163
+ expect(sec14Warnings).toHaveLength(1);
164
+ warnSpy.mockRestore();
165
+ });
166
+
167
+ it('preserves development behavior — stack is present when isProduction is false', async () => {
168
+ const handler = errorHandler({ includeStack: true, isProduction: false });
169
+ const ctx = createMockContext();
170
+
171
+ await handler(ctx, async () => {
172
+ throw new BadRequestError('Invalid');
173
+ });
174
+
175
+ const jsonCall = (ctx.json as ReturnType<typeof vi.fn>).mock.calls[0][0];
176
+ expect(jsonCall.stack).toBeDefined();
177
+ });
178
+
179
+ it('preserves development behavior when isProduction is omitted (default)', async () => {
180
+ const handler = errorHandler({ includeStack: true });
181
+ const ctx = createMockContext();
182
+
183
+ await handler(ctx, async () => {
184
+ throw new BadRequestError('Invalid');
185
+ });
186
+
187
+ const jsonCall = (ctx.json as ReturnType<typeof vi.fn>).mock.calls[0][0];
188
+ expect(jsonCall.stack).toBeDefined();
189
+ });
190
+
191
+ it('a plain (non-HttpError) Error never exposes its message in production, with or without includeStack', async () => {
192
+ const handler = errorHandler({ includeStack: true, isProduction: true });
193
+ const ctx = createMockContext();
194
+
195
+ await handler(ctx, async () => {
196
+ throw new Error('internal db connection string: postgres://secret');
197
+ });
198
+
199
+ const jsonCall = (ctx.json as ReturnType<typeof vi.fn>).mock.calls[0][0];
200
+ expect(jsonCall.stack).toBeUndefined();
201
+ expect(JSON.stringify(jsonCall)).not.toContain('postgres://secret');
202
+ expect(jsonCall.message).toBe('Internal Server Error');
203
+ });
204
+ });
122
205
  });
123
206
 
124
207
  describe('logger option', () => {
@@ -177,6 +260,42 @@ describe('errorHandler', () => {
177
260
  });
178
261
  });
179
262
 
263
+ describe('ValidationError serialization (regression)', () => {
264
+ it('includes `issues` in the response body for a ValidationError', async () => {
265
+ const handler = errorHandler();
266
+ const ctx = createMockContext();
267
+
268
+ await handler(ctx, async () => {
269
+ throw new ValidationError([
270
+ { path: 'body.email', message: 'Invalid email address' },
271
+ { path: 'body.name', message: 'Name is required' },
272
+ ]);
273
+ });
274
+
275
+ const jsonCall = (ctx.json as ReturnType<typeof vi.fn>).mock.calls[0][0];
276
+ expect(jsonCall.issues).toEqual([
277
+ { path: 'body.email', message: 'Invalid email address' },
278
+ { path: 'body.name', message: 'Name is required' },
279
+ ]);
280
+ expect(jsonCall.code).toBe('VALIDATION_ERROR');
281
+ expect(ctx.status).toBe(400);
282
+ });
283
+
284
+ it('never leaks the raw `received` value for a ValidationError', async () => {
285
+ const handler = errorHandler();
286
+ const ctx = createMockContext();
287
+
288
+ await handler(ctx, async () => {
289
+ throw new ValidationError([
290
+ { path: 'body.password', message: 'Invalid', received: 'super-secret-value' },
291
+ ]);
292
+ });
293
+
294
+ const jsonCall = (ctx.json as ReturnType<typeof vi.fn>).mock.calls[0][0];
295
+ expect(JSON.stringify(jsonCall)).not.toContain('super-secret-value');
296
+ });
297
+ });
298
+
180
299
  describe('handlers option', () => {
181
300
  it('should use custom handler for specific error type', async () => {
182
301
  const customHandler = vi.fn();
@@ -303,38 +422,6 @@ describe('notFoundHandler', () => {
303
422
  });
304
423
  });
305
424
 
306
- describe('catchAsync', () => {
307
- it('should pass through successful handlers', async () => {
308
- const innerHandler = vi.fn().mockResolvedValue(undefined);
309
- const handler = catchAsync(innerHandler);
310
- const ctx = createMockContext();
311
-
312
- await handler(ctx, noop);
313
-
314
- expect(innerHandler).toHaveBeenCalledWith(ctx, noop);
315
- });
316
-
317
- it('should re-throw errors', async () => {
318
- const error = new NotFoundError('Not found');
319
- const innerHandler = vi.fn().mockRejectedValue(error);
320
- const handler = catchAsync(innerHandler);
321
- const ctx = createMockContext();
322
-
323
- await expect(handler(ctx, noop)).rejects.toThrow(error);
324
- });
325
-
326
- it('should pass next to inner handler', async () => {
327
- const innerHandler = vi.fn().mockResolvedValue(undefined);
328
- const handler = catchAsync(innerHandler);
329
- const ctx = createMockContext();
330
- const next = vi.fn();
331
-
332
- await handler(ctx, next);
333
-
334
- expect(innerHandler).toHaveBeenCalledWith(ctx, next);
335
- });
336
- });
337
-
338
425
  describe('Integration scenarios', () => {
339
426
  it('should work with errorHandler and notFoundHandler together', async () => {
340
427
  const errHandler = errorHandler();
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @nextrush/errors - Public API surface test
3
+ *
4
+ * Locks the exported symbol set from `src/index.ts`. If this test fails, the
5
+ * public API has changed. Intentional changes require an explicit update to
6
+ * the expected list below, plus a changeset for a published package.
7
+ */
8
+ import { describe, expect, expectTypeOf, it } from 'vitest';
9
+ import * as errorsApi from '../index';
10
+ import type { ErrorHandlerOptions } from '../index';
11
+ import type { HttpErrorOptions } from '../index';
12
+ import type { ValidationIssue } from '../index';
13
+
14
+ describe('Public API surface (runtime exports)', () => {
15
+ it('exports exactly the intended runtime symbols', () => {
16
+ const actualExports = Object.keys(errorsApi).sort();
17
+
18
+ // SEALED: intentional public runtime API surface.
19
+ const expectedRuntime = [
20
+ // Base classes + helper
21
+ 'HttpError',
22
+ 'NextRushError',
23
+ 'getHttpStatusMessage',
24
+
25
+ // Header validation
26
+ 'HeaderValidationError',
27
+
28
+ // Central error-code registry
29
+ 'ERROR_CODES',
30
+ 'GENERIC_ERROR_CODE',
31
+ 'VALIDATION_ERROR_CODE',
32
+ 'codeForStatus',
33
+
34
+ // HTTP errors — 4xx
35
+ 'BadRequestError',
36
+ 'ConflictError',
37
+ 'ExpectationFailedError',
38
+ 'FailedDependencyError',
39
+ 'ForbiddenError',
40
+ 'GoneError',
41
+ 'ImATeapotError',
42
+ 'LengthRequiredError',
43
+ 'LockedError',
44
+ 'MethodNotAllowedError',
45
+ 'NotAcceptableError',
46
+ 'NotFoundError',
47
+ 'PayloadTooLargeError',
48
+ 'PaymentRequiredError',
49
+ 'PreconditionFailedError',
50
+ 'PreconditionRequiredError',
51
+ 'ProxyAuthRequiredError',
52
+ 'RangeNotSatisfiableError',
53
+ 'RequestHeaderFieldsTooLargeError',
54
+ 'RequestTimeoutError',
55
+ 'TooEarlyError',
56
+ 'TooManyRequestsError',
57
+ 'UnauthorizedError',
58
+ 'UnavailableForLegalReasonsError',
59
+ 'UnprocessableEntityError',
60
+ 'UnsupportedMediaTypeError',
61
+ 'UpgradeRequiredError',
62
+ 'UriTooLongError',
63
+
64
+ // HTTP errors — 5xx
65
+ 'BadGatewayError',
66
+ 'GatewayTimeoutError',
67
+ 'HttpVersionNotSupportedError',
68
+ 'InsufficientStorageError',
69
+ 'InternalServerError',
70
+ 'LoopDetectedError',
71
+ 'NetworkAuthRequiredError',
72
+ 'NotExtendedError',
73
+ 'NotImplementedError',
74
+ 'ServiceUnavailableError',
75
+ 'VariantAlsoNegotiatesError',
76
+
77
+ // Validation errors
78
+ 'InvalidEmailError',
79
+ 'InvalidUrlError',
80
+ 'LengthError',
81
+ 'PatternError',
82
+ 'RangeValidationError',
83
+ 'RequiredFieldError',
84
+ 'TypeMismatchError',
85
+ 'ValidationError',
86
+
87
+ // Factory functions
88
+ 'badGateway',
89
+ 'badRequest',
90
+ 'conflict',
91
+ 'createError',
92
+ 'forbidden',
93
+ 'gatewayTimeout',
94
+ 'getErrorStatus',
95
+ 'getSafeErrorMessage',
96
+ 'internalError',
97
+ 'isHttpError',
98
+ 'methodNotAllowed',
99
+ 'notFound',
100
+ 'serviceUnavailable',
101
+ 'tooManyRequests',
102
+ 'unauthorized',
103
+ 'unprocessableEntity',
104
+
105
+ // Middleware
106
+ 'errorHandler',
107
+ 'notFoundHandler',
108
+ ].sort();
109
+
110
+ expect(actualExports).toEqual(expectedRuntime);
111
+ });
112
+ });
113
+
114
+ describe('Public API surface (type-only exports)', () => {
115
+ it('the type-only surface stays importable from the barrel', () => {
116
+ // Compile-time only: removing/renaming any of these in src/index.ts fails
117
+ // this file to type-check.
118
+ type Surface = [HttpErrorOptions, ValidationIssue, ErrorHandlerOptions];
119
+ expectTypeOf<Surface>().not.toBeNever();
120
+ });
121
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @nextrush/errors - Boot-time security audit contribution (task 8.1/8.2)
3
+ *
4
+ * `errorHandler()` tags its returned middleware with a {@link SECURITY_AUDIT}
5
+ * check so `Application.ready()` warns, in production, when `includeStack:
6
+ * true` was configured but `isProduction` was never wired to this instance —
7
+ * the per-request guard (task 7.8) only fires when the caller explicitly
8
+ * passes `isProduction: true`, so an app that forgets to thread it never
9
+ * gets the per-request warning even in a real production deployment. The
10
+ * boot audit catches exactly that omission.
11
+ */
12
+ import { describe, expect, it } from 'vitest';
13
+ import { SECURITY_AUDIT, type SecurityAuditCheck } from '@nextrush/types';
14
+ import { errorHandler } from '../middleware';
15
+
16
+ function auditOf(mw: unknown): SecurityAuditCheck {
17
+ const check = (mw as Record<typeof SECURITY_AUDIT, SecurityAuditCheck>)[SECURITY_AUDIT];
18
+ expect(typeof check).toBe('function');
19
+ return check;
20
+ }
21
+
22
+ describe('errorHandler() — security-boundaries boot audit contribution', () => {
23
+ it('reports a warn-level verdict for includeStack: true with isProduction never wired', () => {
24
+ const mw = errorHandler({ includeStack: true });
25
+ const verdict = auditOf(mw)();
26
+
27
+ expect(verdict.level).toBe('warn');
28
+ expect(verdict).toMatchObject({ message: expect.stringContaining('includeStack') });
29
+ });
30
+
31
+ it('reports ok when includeStack: true is paired with an explicit isProduction', () => {
32
+ const mw = errorHandler({ includeStack: true, isProduction: true });
33
+ expect(auditOf(mw)()).toEqual({ level: 'ok' });
34
+ });
35
+
36
+ it('reports ok for the default options (includeStack: false)', () => {
37
+ const mw = errorHandler();
38
+ expect(auditOf(mw)()).toEqual({ level: 'ok' });
39
+ });
40
+ });
package/src/base.ts CHANGED
@@ -6,10 +6,52 @@
6
6
  * @packageDocumentation
7
7
  */
8
8
 
9
+ import { codeForStatus } from './codes';
10
+
11
+ /** Options accepted by {@link NextRushError.hydrate} when reconstructing errors. */
12
+ interface HydrateOptions {
13
+ status?: number;
14
+ code?: string;
15
+ details?: Record<string, unknown>;
16
+ requestId?: string;
17
+ traceId?: string;
18
+ timestamp?: string;
19
+ }
20
+
9
21
  const V8Error = Error as ErrorConstructor & {
10
22
  captureStackTrace?: (targetObject: object, constructorOpt?: Function) => void;
11
23
  };
12
24
 
25
+ /** Maximum depth walked when serializing a `cause` chain (audit E-2). */
26
+ const MAX_CAUSE_DEPTH = 5;
27
+
28
+ /**
29
+ * Serialize an error `cause` chain into a plain, JSON-safe object.
30
+ *
31
+ * @remarks
32
+ * Walks nested `cause` links up to {@link MAX_CAUSE_DEPTH}, guards against
33
+ * cyclic chains via a visited set, and only surfaces `name`/`message`/`code`
34
+ * so no unexpected enumerable properties (or secrets on ad-hoc error objects)
35
+ * leak. Returns `undefined` when there is nothing meaningful to serialize.
36
+ */
37
+ function serializeCause(cause: unknown, seen: Set<unknown>, depth: number): unknown {
38
+ if (cause === undefined || cause === null || depth >= MAX_CAUSE_DEPTH) return undefined;
39
+ if (typeof cause !== 'object') return { message: String(cause) };
40
+ if (seen.has(cause)) return undefined; // cycle guard
41
+ seen.add(cause);
42
+
43
+ const err = cause as { name?: unknown; message?: unknown; code?: unknown; cause?: unknown };
44
+ const out: Record<string, unknown> = {};
45
+ if (typeof err.name === 'string') out.name = err.name;
46
+ if (typeof err.message === 'string') out.message = err.message;
47
+ if (typeof err.code === 'string') out.code = err.code;
48
+
49
+ const nested = serializeCause(err.cause, seen, depth + 1);
50
+ if (nested !== undefined) out.cause = nested;
51
+
52
+ return out;
53
+ }
54
+
13
55
  /**
14
56
  * Base error class for all NextRush errors
15
57
  */
@@ -29,6 +71,15 @@ export class NextRushError extends Error {
29
71
  /** Original error that caused this error */
30
72
  readonly cause?: unknown;
31
73
 
74
+ /** Correlation/request identifier for tracing across boundaries (audit E-5). */
75
+ readonly requestId?: string;
76
+
77
+ /** Distributed-trace identifier (audit E-5). */
78
+ readonly traceId?: string;
79
+
80
+ /** ISO-8601 timestamp of when the error was created, when supplied. */
81
+ readonly timestamp?: string;
82
+
32
83
  constructor(
33
84
  message: string,
34
85
  options: {
@@ -37,15 +88,25 @@ export class NextRushError extends Error {
37
88
  expose?: boolean;
38
89
  details?: Record<string, unknown>;
39
90
  cause?: unknown;
91
+ requestId?: string;
92
+ traceId?: string;
93
+ timestamp?: string;
40
94
  } = {}
41
95
  ) {
42
- super(message);
96
+ // Pass cause to the native Error constructor so `util.inspect` / logging
97
+ // tooling walks the chain natively, in addition to our own `cause` field.
98
+ super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
43
99
  this.name = this.constructor.name;
44
100
  this.status = options.status ?? 500;
45
101
  this.code = options.code ?? 'INTERNAL_ERROR';
46
102
  this.expose = options.expose ?? this.status < 500;
47
- this.details = options.details;
103
+ // Freeze a shallow snapshot so the error owns immutable details and the
104
+ // caller's object cannot be mutated through the error (audit E-6).
105
+ this.details = options.details ? Object.freeze({ ...options.details }) : undefined;
48
106
  this.cause = options.cause;
107
+ this.requestId = options.requestId;
108
+ this.traceId = options.traceId;
109
+ this.timestamp = options.timestamp;
49
110
 
50
111
  // Maintain proper stack trace for V8
51
112
  // Skip for common client errors (4xx with expose=true) to reduce overhead.
@@ -71,9 +132,66 @@ export class NextRushError extends Error {
71
132
  json.details = this.details;
72
133
  }
73
134
 
135
+ // Surface the cause chain for diagnosability, but only on exposed errors —
136
+ // a non-exposed 5xx cause may contain internal detail (audit E-2). Server
137
+ // -side logging should read `error.cause` directly, which is never gated.
138
+ if (this.expose && this.cause !== undefined) {
139
+ const serialized = serializeCause(this.cause, new Set(), 0);
140
+ if (serialized !== undefined) {
141
+ json.cause = serialized;
142
+ }
143
+ }
144
+
145
+ // Correlation identifiers are safe to surface and are the primary handle
146
+ // for cross-service debugging (audit E-5). Only emitted when set, so the
147
+ // default serialized shape is unchanged.
148
+ if (this.requestId !== undefined) json.requestId = this.requestId;
149
+ if (this.traceId !== undefined) json.traceId = this.traceId;
150
+ if (this.timestamp !== undefined) json.timestamp = this.timestamp;
151
+
74
152
  return json;
75
153
  }
76
154
 
155
+ /**
156
+ * Reconstruct a {@link NextRushError} from a serialized {@link toJSON} payload
157
+ * (audit E-7).
158
+ *
159
+ * @remarks
160
+ * Enables cross-service transport: a downstream service can rebuild a typed
161
+ * error (with a working `instanceof NextRushError`) from the JSON it received,
162
+ * rather than being left with an opaque plain object. `message` falls back to
163
+ * a generic string when the source error was not exposed.
164
+ *
165
+ * @param json - A payload previously produced by {@link toJSON}.
166
+ * @returns A reconstructed {@link NextRushError}.
167
+ */
168
+ static fromJSON(json: Record<string, unknown>): NextRushError {
169
+ return NextRushError.hydrate(json, (message, options) => new NextRushError(message, options));
170
+ }
171
+
172
+ /**
173
+ * Shared hydration logic for {@link fromJSON} across the hierarchy.
174
+ * @internal
175
+ */
176
+ protected static hydrate<T extends NextRushError>(
177
+ json: Record<string, unknown>,
178
+ build: (message: string, options: HydrateOptions) => T
179
+ ): T {
180
+ const status = typeof json.status === 'number' ? json.status : 500;
181
+ const message = typeof json.message === 'string' ? json.message : getHttpStatusMessage(status);
182
+ return build(message, {
183
+ status,
184
+ code: typeof json.code === 'string' ? json.code : undefined,
185
+ details:
186
+ json.details !== null && typeof json.details === 'object'
187
+ ? (json.details as Record<string, unknown>)
188
+ : undefined,
189
+ requestId: typeof json.requestId === 'string' ? json.requestId : undefined,
190
+ traceId: typeof json.traceId === 'string' ? json.traceId : undefined,
191
+ timestamp: typeof json.timestamp === 'string' ? json.timestamp : undefined,
192
+ });
193
+ }
194
+
77
195
  /**
78
196
  * Create a response-safe version of the error
79
197
  */
@@ -149,14 +267,41 @@ export class HttpError extends NextRushError {
149
267
  expose?: boolean;
150
268
  details?: Record<string, unknown>;
151
269
  cause?: unknown;
270
+ requestId?: string;
271
+ traceId?: string;
272
+ timestamp?: string;
152
273
  } = {}
153
274
  ) {
154
275
  super(message ?? getHttpStatusMessage(status), {
155
276
  status,
156
- code: options.code ?? `HTTP_${status}`,
277
+ code: options.code ?? codeForStatus(status),
157
278
  expose: options.expose ?? status < 500,
158
279
  details: options.details,
159
280
  cause: options.cause,
281
+ requestId: options.requestId,
282
+ traceId: options.traceId,
283
+ timestamp: options.timestamp,
160
284
  });
161
285
  }
286
+
287
+ /**
288
+ * Reconstruct an {@link HttpError} from a serialized {@link NextRushError.toJSON}
289
+ * payload (audit E-7).
290
+ *
291
+ * @param json - A payload previously produced by `toJSON()`.
292
+ * @returns A reconstructed {@link HttpError}.
293
+ */
294
+ static override fromJSON(json: Record<string, unknown>): HttpError {
295
+ return NextRushError.hydrate(
296
+ json,
297
+ (message, options) =>
298
+ new HttpError(options.status ?? 500, message, {
299
+ code: options.code,
300
+ details: options.details,
301
+ requestId: options.requestId,
302
+ traceId: options.traceId,
303
+ timestamp: options.timestamp,
304
+ })
305
+ );
306
+ }
162
307
  }
package/src/codes.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @nextrush/errors - Central Error Code Registry
3
+ *
4
+ * Single source of truth mapping HTTP status codes to their canonical,
5
+ * machine-readable error codes (audit E-4). Both the typed error classes and
6
+ * the {@link createError} factory resolve codes through this registry, so a
7
+ * given status maps to exactly one code regardless of construction path.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+
12
+ /**
13
+ * Canonical error code for each supported HTTP status.
14
+ *
15
+ * @remarks
16
+ * Keep this in sync with the typed classes in `http-errors.ts`. The
17
+ * `audit-fixes` test suite asserts `createError(status).code === ERROR_CODES[status]`
18
+ * for every entry, so drift between a class and this registry fails CI.
19
+ */
20
+ export const ERROR_CODES: Readonly<Record<number, string>> = Object.freeze({
21
+ 400: 'BAD_REQUEST',
22
+ 401: 'UNAUTHORIZED',
23
+ 402: 'PAYMENT_REQUIRED',
24
+ 403: 'FORBIDDEN',
25
+ 404: 'NOT_FOUND',
26
+ 405: 'METHOD_NOT_ALLOWED',
27
+ 406: 'NOT_ACCEPTABLE',
28
+ 407: 'PROXY_AUTH_REQUIRED',
29
+ 408: 'REQUEST_TIMEOUT',
30
+ 409: 'CONFLICT',
31
+ 410: 'GONE',
32
+ 411: 'LENGTH_REQUIRED',
33
+ 412: 'PRECONDITION_FAILED',
34
+ 413: 'PAYLOAD_TOO_LARGE',
35
+ 414: 'URI_TOO_LONG',
36
+ 415: 'UNSUPPORTED_MEDIA_TYPE',
37
+ 416: 'RANGE_NOT_SATISFIABLE',
38
+ 417: 'EXPECTATION_FAILED',
39
+ 418: 'IM_A_TEAPOT',
40
+ 422: 'UNPROCESSABLE_ENTITY',
41
+ 423: 'LOCKED',
42
+ 424: 'FAILED_DEPENDENCY',
43
+ 425: 'TOO_EARLY',
44
+ 426: 'UPGRADE_REQUIRED',
45
+ 428: 'PRECONDITION_REQUIRED',
46
+ 429: 'TOO_MANY_REQUESTS',
47
+ 431: 'REQUEST_HEADER_FIELDS_TOO_LARGE',
48
+ 451: 'UNAVAILABLE_FOR_LEGAL_REASONS',
49
+ 500: 'INTERNAL_SERVER_ERROR',
50
+ 501: 'NOT_IMPLEMENTED',
51
+ 502: 'BAD_GATEWAY',
52
+ 503: 'SERVICE_UNAVAILABLE',
53
+ 504: 'GATEWAY_TIMEOUT',
54
+ 505: 'HTTP_VERSION_NOT_SUPPORTED',
55
+ 506: 'VARIANT_ALSO_NEGOTIATES',
56
+ 507: 'INSUFFICIENT_STORAGE',
57
+ 508: 'LOOP_DETECTED',
58
+ 510: 'NOT_EXTENDED',
59
+ 511: 'NETWORK_AUTH_REQUIRED',
60
+ });
61
+
62
+ /**
63
+ * Resolve the canonical error code for an HTTP status.
64
+ *
65
+ * @param status - HTTP status code.
66
+ * @returns The registered canonical code, or `HTTP_<status>` for statuses with
67
+ * no dedicated class.
68
+ */
69
+ export function codeForStatus(status: number): string {
70
+ return ERROR_CODES[status] ?? `HTTP_${status}`;
71
+ }
72
+
73
+ /** Generic internal-error code used when no status-specific code applies. */
74
+ export const GENERIC_ERROR_CODE = 'INTERNAL_ERROR';
75
+
76
+ /** Canonical code for validation failures. */
77
+ export const VALIDATION_ERROR_CODE = 'VALIDATION_ERROR';