@shipstatic/types 0.9.5 → 0.9.7
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.
- package/README.md +5 -1
- package/dist/index.d.ts +47 -29
- package/dist/index.js +80 -53
- package/package.json +1 -1
- package/src/index.ts +87 -63
package/README.md
CHANGED
|
@@ -35,6 +35,7 @@ import { ShipError, ErrorType, isShipError } from '@shipstatic/types';
|
|
|
35
35
|
|
|
36
36
|
throw ShipError.validation('File too large');
|
|
37
37
|
throw ShipError.notFound('Deployment', id);
|
|
38
|
+
throw ShipError.forbidden('Account terminated');
|
|
38
39
|
throw ShipError.authentication();
|
|
39
40
|
throw ShipError.business('Plan limit reached');
|
|
40
41
|
|
|
@@ -43,7 +44,8 @@ if (isShipError(error)) {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
if (error.isClientError()) { /* Business | Config | File | Validation */ }
|
|
46
|
-
if (error.isAuthError())
|
|
47
|
+
if (error.isAuthError()) { /* handle auth */ }
|
|
48
|
+
if (error.type === ErrorType.Validation) { /* specific-type checks */ }
|
|
47
49
|
```
|
|
48
50
|
|
|
49
51
|
**HTTP client integration.** Both producer and consumer sides of the wire have first-class helpers, so every HTTP client across the platform reconstructs the same `ShipError` shape:
|
|
@@ -54,6 +56,8 @@ return c.json(error.toResponse(), error.status ?? 500);
|
|
|
54
56
|
|
|
55
57
|
// Consumer side — two symmetric helpers cover both HTTP error modes:
|
|
56
58
|
|
|
59
|
+
// Both helpers take an optional operationName for context-aware fallback messages.
|
|
60
|
+
|
|
57
61
|
// 1. Server returned a non-OK response
|
|
58
62
|
if (!response.ok) {
|
|
59
63
|
throw await ShipError.fromHttpResponse(response, 'Get account');
|
package/dist/index.d.ts
CHANGED
|
@@ -305,25 +305,27 @@ export interface AccountOverrides {
|
|
|
305
305
|
* (`DeploymentStatus`, `DomainStatus`, `AccountPlan`, `AuthMethod`) follow.
|
|
306
306
|
*/
|
|
307
307
|
export declare const ErrorType: {
|
|
308
|
-
/** Validation failed (400) */
|
|
308
|
+
/** Validation failed (400). Input shape is wrong. */
|
|
309
309
|
readonly Validation: "validation_failed";
|
|
310
|
-
/** Resource not found (404) */
|
|
310
|
+
/** Resource not found (404). */
|
|
311
311
|
readonly NotFound: "not_found";
|
|
312
|
-
/**
|
|
312
|
+
/** Authenticated but not allowed (403). User lacks permission for this action. */
|
|
313
|
+
readonly Forbidden: "forbidden";
|
|
314
|
+
/** Rate limit exceeded (429). */
|
|
313
315
|
readonly RateLimit: "rate_limit_exceeded";
|
|
314
|
-
/** Authentication required (401) */
|
|
316
|
+
/** Authentication required or failed (401). Missing/invalid credentials. */
|
|
315
317
|
readonly Authentication: "authentication_failed";
|
|
316
|
-
/** Business
|
|
318
|
+
/** Business rule violation. Catch-all for 4xx state-rule errors that aren't more specific. */
|
|
317
319
|
readonly Business: "business_logic_error";
|
|
318
|
-
/** API server error (500) */
|
|
320
|
+
/** API server error (500). Generic server-side fault. */
|
|
319
321
|
readonly Api: "internal_server_error";
|
|
320
322
|
/** Network/connection error. Client-side only — set by HTTP clients on fetch failure; never produced server-side. */
|
|
321
323
|
readonly Network: "network_error";
|
|
322
324
|
/** Operation was cancelled. Client-side only — set on `AbortSignal` abort; never produced server-side. */
|
|
323
325
|
readonly Cancelled: "operation_cancelled";
|
|
324
|
-
/** File operation error */
|
|
326
|
+
/** File operation error. Client-side only — set by SDK during local file processing; never produced server-side. */
|
|
325
327
|
readonly File: "file_error";
|
|
326
|
-
/** Configuration error */
|
|
328
|
+
/** Configuration error. Client-side only — set by SDK during config parsing/validation; never produced server-side. */
|
|
327
329
|
readonly Config: "config_error";
|
|
328
330
|
};
|
|
329
331
|
export type ErrorType = typeof ErrorType[keyof typeof ErrorType];
|
|
@@ -337,8 +339,8 @@ export interface ErrorResponse {
|
|
|
337
339
|
message: string;
|
|
338
340
|
/** HTTP status code (API contexts) */
|
|
339
341
|
status?: number;
|
|
340
|
-
/** Optional additional error details */
|
|
341
|
-
details?:
|
|
342
|
+
/** Optional additional error details. Untyped by design — narrow at the read site. */
|
|
343
|
+
details?: unknown;
|
|
342
344
|
}
|
|
343
345
|
/**
|
|
344
346
|
* Simple unified error class for both API and SDK
|
|
@@ -346,27 +348,33 @@ export interface ErrorResponse {
|
|
|
346
348
|
export declare class ShipError extends Error {
|
|
347
349
|
readonly type: ErrorType;
|
|
348
350
|
readonly status?: number | undefined;
|
|
349
|
-
readonly details?:
|
|
350
|
-
constructor(type: ErrorType, message: string, status?: number | undefined, details?:
|
|
351
|
+
readonly details?: unknown | undefined;
|
|
352
|
+
constructor(type: ErrorType, message: string, status?: number | undefined, details?: unknown | undefined);
|
|
351
353
|
/** Convert to wire format */
|
|
352
354
|
toResponse(): ErrorResponse;
|
|
353
355
|
/**
|
|
354
356
|
* Construct a `ShipError` from an HTTP error response.
|
|
355
357
|
*
|
|
356
358
|
* Best-effort body parse for `{ message, error?, details? }`. Message
|
|
357
|
-
* resolution: `body.message` → `body.error` → `
|
|
358
|
-
*
|
|
359
|
+
* resolution: `body.message` → `body.error` → `"<operationName> failed with
|
|
360
|
+
* status <N>"`.
|
|
359
361
|
*
|
|
360
|
-
* Type resolution: trusts `body.error` when it's a known
|
|
361
|
-
* (preserves the wire's intent — server's
|
|
362
|
-
* round-trips back to `ErrorType.Validation`
|
|
363
|
-
* status-derived (401 → Authentication,
|
|
364
|
-
*
|
|
362
|
+
* Type resolution: trusts `body.error` when it's a known server-producible
|
|
363
|
+
* `ErrorType` (preserves the wire's intent — server's
|
|
364
|
+
* `ShipError.validation(...)` round-trips back to `ErrorType.Validation`
|
|
365
|
+
* on the client). Falls back to status-derived (401 → Authentication,
|
|
366
|
+
* 403 → Forbidden, 429 → RateLimit, else → Api) for non-API responses
|
|
367
|
+
* (CDN errors, intermediaries) or malformed bodies. Client-only types
|
|
368
|
+
* (`Network`, `Cancelled`, `File`, `Config`) are filtered out of the
|
|
369
|
+
* trusted set — a misbehaving server claiming one of those is ignored.
|
|
370
|
+
*
|
|
371
|
+
* `operationName` (e.g. `"Get account"`) is used to compose the fallback
|
|
372
|
+
* message. Defaults to `"Request"`. Same convention as `fromFetchError`.
|
|
365
373
|
*
|
|
366
374
|
* Async because it reads the response body. Returns rather than throws so
|
|
367
375
|
* callers can compose; most will `throw await ShipError.fromHttpResponse(...)`.
|
|
368
376
|
*/
|
|
369
|
-
static fromHttpResponse(response: Response,
|
|
377
|
+
static fromHttpResponse(response: Response, operationName?: string): Promise<ShipError>;
|
|
370
378
|
/**
|
|
371
379
|
* Construct a `ShipError` from an error caught around a `fetch()` call.
|
|
372
380
|
*
|
|
@@ -386,23 +394,33 @@ export declare class ShipError extends Error {
|
|
|
386
394
|
* `"Request"` when omitted.
|
|
387
395
|
*/
|
|
388
396
|
static fromFetchError(cause: unknown, operationName?: string): ShipError;
|
|
389
|
-
static validation(message: string, details?:
|
|
397
|
+
static validation(message: string, details?: unknown): ShipError;
|
|
390
398
|
static notFound(resource: string, id?: string): ShipError;
|
|
399
|
+
static forbidden(message: string, details?: unknown): ShipError;
|
|
391
400
|
static rateLimit(message?: string): ShipError;
|
|
392
|
-
|
|
401
|
+
/**
|
|
402
|
+
* Construct an Authentication (401) error.
|
|
403
|
+
*
|
|
404
|
+
* **Telemetry pattern — `details: { internal: '<tag>' }`.** When the
|
|
405
|
+
* server creates an auth error with an `internal` key in `details`
|
|
406
|
+
* (e.g. `{ internal: 'jwt_missing_subject' }`), `toResponse()` strips the
|
|
407
|
+
* entire `details` object before serialization. This keeps the wire
|
|
408
|
+
* response a clean "Authentication failed" while preserving granular
|
|
409
|
+
* server-side telemetry (which strategy/check failed) for logs and tests.
|
|
410
|
+
*
|
|
411
|
+
* Use this pattern in API auth code; do not put client-visible info under
|
|
412
|
+
* `internal`. Other `details` keys round-trip normally.
|
|
413
|
+
*/
|
|
414
|
+
static authentication(message?: string, details?: unknown): ShipError;
|
|
393
415
|
static business(message: string, status?: number): ShipError;
|
|
394
|
-
static network(message: string,
|
|
416
|
+
static network(message: string, details?: unknown): ShipError;
|
|
395
417
|
static cancelled(message: string): ShipError;
|
|
396
|
-
static file(message: string,
|
|
397
|
-
static config(message: string, details?:
|
|
418
|
+
static file(message: string, details?: unknown): ShipError;
|
|
419
|
+
static config(message: string, details?: unknown): ShipError;
|
|
398
420
|
static api(message: string, status?: number): ShipError;
|
|
399
|
-
get filePath(): string | undefined;
|
|
400
421
|
isClientError(): boolean;
|
|
401
422
|
isNetworkError(): boolean;
|
|
402
423
|
isAuthError(): boolean;
|
|
403
|
-
isValidationError(): boolean;
|
|
404
|
-
isFileError(): boolean;
|
|
405
|
-
isConfigError(): boolean;
|
|
406
424
|
isType(errorType: ErrorType): boolean;
|
|
407
425
|
}
|
|
408
426
|
/**
|
package/dist/index.js
CHANGED
|
@@ -59,48 +59,59 @@ export const AccountPlan = {
|
|
|
59
59
|
* (`DeploymentStatus`, `DomainStatus`, `AccountPlan`, `AuthMethod`) follow.
|
|
60
60
|
*/
|
|
61
61
|
export const ErrorType = {
|
|
62
|
-
/** Validation failed (400) */
|
|
62
|
+
/** Validation failed (400). Input shape is wrong. */
|
|
63
63
|
Validation: 'validation_failed',
|
|
64
|
-
/** Resource not found (404) */
|
|
64
|
+
/** Resource not found (404). */
|
|
65
65
|
NotFound: 'not_found',
|
|
66
|
-
/**
|
|
66
|
+
/** Authenticated but not allowed (403). User lacks permission for this action. */
|
|
67
|
+
Forbidden: 'forbidden',
|
|
68
|
+
/** Rate limit exceeded (429). */
|
|
67
69
|
RateLimit: 'rate_limit_exceeded',
|
|
68
|
-
/** Authentication required (401) */
|
|
70
|
+
/** Authentication required or failed (401). Missing/invalid credentials. */
|
|
69
71
|
Authentication: 'authentication_failed',
|
|
70
|
-
/** Business
|
|
72
|
+
/** Business rule violation. Catch-all for 4xx state-rule errors that aren't more specific. */
|
|
71
73
|
Business: 'business_logic_error',
|
|
72
|
-
/** API server error (500) */
|
|
74
|
+
/** API server error (500). Generic server-side fault. */
|
|
73
75
|
Api: 'internal_server_error',
|
|
74
76
|
/** Network/connection error. Client-side only — set by HTTP clients on fetch failure; never produced server-side. */
|
|
75
77
|
Network: 'network_error',
|
|
76
78
|
/** Operation was cancelled. Client-side only — set on `AbortSignal` abort; never produced server-side. */
|
|
77
79
|
Cancelled: 'operation_cancelled',
|
|
78
|
-
/** File operation error */
|
|
80
|
+
/** File operation error. Client-side only — set by SDK during local file processing; never produced server-side. */
|
|
79
81
|
File: 'file_error',
|
|
80
|
-
/** Configuration error */
|
|
82
|
+
/** Configuration error. Client-side only — set by SDK during config parsing/validation; never produced server-side. */
|
|
81
83
|
Config: 'config_error',
|
|
82
84
|
};
|
|
85
|
+
/**
|
|
86
|
+
* Error types that originate exclusively on the client (HTTP clients, SDK
|
|
87
|
+
* file processing, local config parsing). These never appear on the wire
|
|
88
|
+
* from the server, so `fromHttpResponse` will not trust them even if a
|
|
89
|
+
* misbehaving server claims one in `body.error`.
|
|
90
|
+
*/
|
|
91
|
+
const CLIENT_ONLY_ERROR_TYPES = new Set([
|
|
92
|
+
ErrorType.Network,
|
|
93
|
+
ErrorType.Cancelled,
|
|
94
|
+
ErrorType.File,
|
|
95
|
+
ErrorType.Config,
|
|
96
|
+
]);
|
|
83
97
|
/**
|
|
84
98
|
* Categorizes error types for the `isClientError` / `isNetworkError` /
|
|
85
99
|
* `isAuthError` helpers. Each `Set` is typed against the wider `ErrorType`
|
|
86
100
|
* union so `.has(error.type)` accepts any value from the union.
|
|
87
101
|
*/
|
|
88
102
|
const ERROR_CATEGORIES = {
|
|
89
|
-
client: new Set([ErrorType.Business, ErrorType.Config, ErrorType.File, ErrorType.Validation]),
|
|
103
|
+
client: new Set([ErrorType.Business, ErrorType.Config, ErrorType.File, ErrorType.Forbidden, ErrorType.Validation]),
|
|
90
104
|
network: new Set([ErrorType.Network]),
|
|
91
105
|
auth: new Set([ErrorType.Authentication]),
|
|
92
106
|
};
|
|
93
107
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* they originate on the client (fetch failure, abort) and should never be
|
|
100
|
-
* reconstructed from a server response, even if a misbehaving server were
|
|
101
|
-
* to send them. A defensive omission, not a theoretical concern.
|
|
108
|
+
* Error types the server can legitimately produce on the wire. Used by
|
|
109
|
+
* `ShipError.fromHttpResponse` to validate the body's `error` field before
|
|
110
|
+
* trusting it as `ShipError.type`. Derived by exclusion from
|
|
111
|
+
* `CLIENT_ONLY_ERROR_TYPES` so adding a new server-producible type to
|
|
112
|
+
* `ErrorType` is automatically picked up.
|
|
102
113
|
*/
|
|
103
|
-
const SERVER_PRODUCIBLE_ERROR_TYPES = new Set(Object.values(ErrorType).filter(t =>
|
|
114
|
+
const SERVER_PRODUCIBLE_ERROR_TYPES = new Set(Object.values(ErrorType).filter(t => !CLIENT_ONLY_ERROR_TYPES.has(t)));
|
|
104
115
|
/**
|
|
105
116
|
* Simple unified error class for both API and SDK
|
|
106
117
|
*/
|
|
@@ -117,8 +128,11 @@ export class ShipError extends Error {
|
|
|
117
128
|
}
|
|
118
129
|
/** Convert to wire format */
|
|
119
130
|
toResponse() {
|
|
120
|
-
//
|
|
121
|
-
|
|
131
|
+
// Strip authentication details when they carry an `internal` telemetry
|
|
132
|
+
// tag (see `ShipError.authentication` JSDoc) — these are server-side
|
|
133
|
+
// diagnostics like 'jwt_missing_subject' that must not leak to clients.
|
|
134
|
+
const authDetails = this.details;
|
|
135
|
+
const details = this.type === ErrorType.Authentication && authDetails?.internal
|
|
122
136
|
? undefined
|
|
123
137
|
: this.details;
|
|
124
138
|
return {
|
|
@@ -132,19 +146,25 @@ export class ShipError extends Error {
|
|
|
132
146
|
* Construct a `ShipError` from an HTTP error response.
|
|
133
147
|
*
|
|
134
148
|
* Best-effort body parse for `{ message, error?, details? }`. Message
|
|
135
|
-
* resolution: `body.message` → `body.error` → `
|
|
136
|
-
*
|
|
149
|
+
* resolution: `body.message` → `body.error` → `"<operationName> failed with
|
|
150
|
+
* status <N>"`.
|
|
137
151
|
*
|
|
138
|
-
* Type resolution: trusts `body.error` when it's a known
|
|
139
|
-
* (preserves the wire's intent — server's
|
|
140
|
-
* round-trips back to `ErrorType.Validation`
|
|
141
|
-
* status-derived (401 → Authentication,
|
|
142
|
-
*
|
|
152
|
+
* Type resolution: trusts `body.error` when it's a known server-producible
|
|
153
|
+
* `ErrorType` (preserves the wire's intent — server's
|
|
154
|
+
* `ShipError.validation(...)` round-trips back to `ErrorType.Validation`
|
|
155
|
+
* on the client). Falls back to status-derived (401 → Authentication,
|
|
156
|
+
* 403 → Forbidden, 429 → RateLimit, else → Api) for non-API responses
|
|
157
|
+
* (CDN errors, intermediaries) or malformed bodies. Client-only types
|
|
158
|
+
* (`Network`, `Cancelled`, `File`, `Config`) are filtered out of the
|
|
159
|
+
* trusted set — a misbehaving server claiming one of those is ignored.
|
|
160
|
+
*
|
|
161
|
+
* `operationName` (e.g. `"Get account"`) is used to compose the fallback
|
|
162
|
+
* message. Defaults to `"Request"`. Same convention as `fromFetchError`.
|
|
143
163
|
*
|
|
144
164
|
* Async because it reads the response body. Returns rather than throws so
|
|
145
165
|
* callers can compose; most will `throw await ShipError.fromHttpResponse(...)`.
|
|
146
166
|
*/
|
|
147
|
-
static async fromHttpResponse(response,
|
|
167
|
+
static async fromHttpResponse(response, operationName) {
|
|
148
168
|
let message;
|
|
149
169
|
let details;
|
|
150
170
|
let bodyType;
|
|
@@ -171,12 +191,13 @@ export class ShipError extends Error {
|
|
|
171
191
|
}
|
|
172
192
|
}
|
|
173
193
|
catch {
|
|
174
|
-
// Body unreadable; fall through to
|
|
194
|
+
// Body unreadable; fall through to operationName-derived message.
|
|
175
195
|
}
|
|
176
|
-
message = message ||
|
|
196
|
+
message = message || `${operationName || 'Request'} failed with status ${response.status}`;
|
|
177
197
|
const type = bodyType ?? (response.status === 401 ? ErrorType.Authentication :
|
|
178
|
-
response.status ===
|
|
179
|
-
ErrorType.
|
|
198
|
+
response.status === 403 ? ErrorType.Forbidden :
|
|
199
|
+
response.status === 429 ? ErrorType.RateLimit :
|
|
200
|
+
ErrorType.Api);
|
|
180
201
|
return new ShipError(type, message, response.status, details);
|
|
181
202
|
}
|
|
182
203
|
/**
|
|
@@ -206,13 +227,16 @@ export class ShipError extends Error {
|
|
|
206
227
|
return ShipError.cancelled(`${op} was cancelled`);
|
|
207
228
|
}
|
|
208
229
|
if (cause instanceof TypeError && cause.message.includes('fetch')) {
|
|
209
|
-
return ShipError.network(`${op} failed: ${cause.message}`, cause);
|
|
230
|
+
return ShipError.network(`${op} failed: ${cause.message}`, { cause });
|
|
210
231
|
}
|
|
211
232
|
return new ShipError(ErrorType.Api, `${op} failed: ${cause.message}`);
|
|
212
233
|
}
|
|
213
234
|
return new ShipError(ErrorType.Api, `${op} failed: Unknown error`);
|
|
214
235
|
}
|
|
215
|
-
// Factory methods
|
|
236
|
+
// Factory methods. Uniform shape `(message, details?)` with two principled
|
|
237
|
+
// exceptions: `notFound` composes its message from (resource, id?), and
|
|
238
|
+
// `business` / `api` accept an optional status because they're the
|
|
239
|
+
// multi-status fallbacks.
|
|
216
240
|
static validation(message, details) {
|
|
217
241
|
return new ShipError(ErrorType.Validation, message, 400, details);
|
|
218
242
|
}
|
|
@@ -220,23 +244,39 @@ export class ShipError extends Error {
|
|
|
220
244
|
const message = id ? `${resource} ${id} not found` : `${resource} not found`;
|
|
221
245
|
return new ShipError(ErrorType.NotFound, message, 404);
|
|
222
246
|
}
|
|
247
|
+
static forbidden(message, details) {
|
|
248
|
+
return new ShipError(ErrorType.Forbidden, message, 403, details);
|
|
249
|
+
}
|
|
223
250
|
static rateLimit(message = "Too many requests") {
|
|
224
251
|
return new ShipError(ErrorType.RateLimit, message, 429);
|
|
225
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* Construct an Authentication (401) error.
|
|
255
|
+
*
|
|
256
|
+
* **Telemetry pattern — `details: { internal: '<tag>' }`.** When the
|
|
257
|
+
* server creates an auth error with an `internal` key in `details`
|
|
258
|
+
* (e.g. `{ internal: 'jwt_missing_subject' }`), `toResponse()` strips the
|
|
259
|
+
* entire `details` object before serialization. This keeps the wire
|
|
260
|
+
* response a clean "Authentication failed" while preserving granular
|
|
261
|
+
* server-side telemetry (which strategy/check failed) for logs and tests.
|
|
262
|
+
*
|
|
263
|
+
* Use this pattern in API auth code; do not put client-visible info under
|
|
264
|
+
* `internal`. Other `details` keys round-trip normally.
|
|
265
|
+
*/
|
|
226
266
|
static authentication(message = "Authentication required", details) {
|
|
227
267
|
return new ShipError(ErrorType.Authentication, message, 401, details);
|
|
228
268
|
}
|
|
229
269
|
static business(message, status = 400) {
|
|
230
270
|
return new ShipError(ErrorType.Business, message, status);
|
|
231
271
|
}
|
|
232
|
-
static network(message,
|
|
233
|
-
return new ShipError(ErrorType.Network, message, undefined,
|
|
272
|
+
static network(message, details) {
|
|
273
|
+
return new ShipError(ErrorType.Network, message, undefined, details);
|
|
234
274
|
}
|
|
235
275
|
static cancelled(message) {
|
|
236
276
|
return new ShipError(ErrorType.Cancelled, message);
|
|
237
277
|
}
|
|
238
|
-
static file(message,
|
|
239
|
-
return new ShipError(ErrorType.File, message, undefined,
|
|
278
|
+
static file(message, details) {
|
|
279
|
+
return new ShipError(ErrorType.File, message, undefined, details);
|
|
240
280
|
}
|
|
241
281
|
static config(message, details) {
|
|
242
282
|
return new ShipError(ErrorType.Config, message, undefined, details);
|
|
@@ -244,11 +284,8 @@ export class ShipError extends Error {
|
|
|
244
284
|
static api(message, status = 500) {
|
|
245
285
|
return new ShipError(ErrorType.Api, message, status);
|
|
246
286
|
}
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
return this.details?.filePath;
|
|
250
|
-
}
|
|
251
|
-
// Helper methods for error type checking using categorization
|
|
287
|
+
// Semantic-category type guards. For specific-type checks, use
|
|
288
|
+
// `error.type === ErrorType.X` directly or the generic `isType(t)`.
|
|
252
289
|
isClientError() {
|
|
253
290
|
return ERROR_CATEGORIES.client.has(this.type);
|
|
254
291
|
}
|
|
@@ -258,16 +295,6 @@ export class ShipError extends Error {
|
|
|
258
295
|
isAuthError() {
|
|
259
296
|
return ERROR_CATEGORIES.auth.has(this.type);
|
|
260
297
|
}
|
|
261
|
-
isValidationError() {
|
|
262
|
-
return this.type === ErrorType.Validation;
|
|
263
|
-
}
|
|
264
|
-
isFileError() {
|
|
265
|
-
return this.type === ErrorType.File;
|
|
266
|
-
}
|
|
267
|
-
isConfigError() {
|
|
268
|
-
return this.type === ErrorType.Config;
|
|
269
|
-
}
|
|
270
|
-
// Generic type checker
|
|
271
298
|
isType(errorType) {
|
|
272
299
|
return this.type === errorType;
|
|
273
300
|
}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -350,55 +350,65 @@ export interface AccountOverrides {
|
|
|
350
350
|
* (`DeploymentStatus`, `DomainStatus`, `AccountPlan`, `AuthMethod`) follow.
|
|
351
351
|
*/
|
|
352
352
|
export const ErrorType = {
|
|
353
|
-
/** Validation failed (400) */
|
|
353
|
+
/** Validation failed (400). Input shape is wrong. */
|
|
354
354
|
Validation: 'validation_failed',
|
|
355
|
-
/** Resource not found (404) */
|
|
355
|
+
/** Resource not found (404). */
|
|
356
356
|
NotFound: 'not_found',
|
|
357
|
-
/**
|
|
357
|
+
/** Authenticated but not allowed (403). User lacks permission for this action. */
|
|
358
|
+
Forbidden: 'forbidden',
|
|
359
|
+
/** Rate limit exceeded (429). */
|
|
358
360
|
RateLimit: 'rate_limit_exceeded',
|
|
359
|
-
/** Authentication required (401) */
|
|
361
|
+
/** Authentication required or failed (401). Missing/invalid credentials. */
|
|
360
362
|
Authentication: 'authentication_failed',
|
|
361
|
-
/** Business
|
|
363
|
+
/** Business rule violation. Catch-all for 4xx state-rule errors that aren't more specific. */
|
|
362
364
|
Business: 'business_logic_error',
|
|
363
|
-
/** API server error (500) */
|
|
365
|
+
/** API server error (500). Generic server-side fault. */
|
|
364
366
|
Api: 'internal_server_error',
|
|
365
367
|
/** Network/connection error. Client-side only — set by HTTP clients on fetch failure; never produced server-side. */
|
|
366
368
|
Network: 'network_error',
|
|
367
369
|
/** Operation was cancelled. Client-side only — set on `AbortSignal` abort; never produced server-side. */
|
|
368
370
|
Cancelled: 'operation_cancelled',
|
|
369
|
-
/** File operation error */
|
|
371
|
+
/** File operation error. Client-side only — set by SDK during local file processing; never produced server-side. */
|
|
370
372
|
File: 'file_error',
|
|
371
|
-
/** Configuration error */
|
|
373
|
+
/** Configuration error. Client-side only — set by SDK during config parsing/validation; never produced server-side. */
|
|
372
374
|
Config: 'config_error',
|
|
373
375
|
} as const;
|
|
374
376
|
|
|
375
377
|
export type ErrorType = typeof ErrorType[keyof typeof ErrorType];
|
|
376
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Error types that originate exclusively on the client (HTTP clients, SDK
|
|
381
|
+
* file processing, local config parsing). These never appear on the wire
|
|
382
|
+
* from the server, so `fromHttpResponse` will not trust them even if a
|
|
383
|
+
* misbehaving server claims one in `body.error`.
|
|
384
|
+
*/
|
|
385
|
+
const CLIENT_ONLY_ERROR_TYPES = new Set<string>([
|
|
386
|
+
ErrorType.Network,
|
|
387
|
+
ErrorType.Cancelled,
|
|
388
|
+
ErrorType.File,
|
|
389
|
+
ErrorType.Config,
|
|
390
|
+
]);
|
|
391
|
+
|
|
377
392
|
/**
|
|
378
393
|
* Categorizes error types for the `isClientError` / `isNetworkError` /
|
|
379
394
|
* `isAuthError` helpers. Each `Set` is typed against the wider `ErrorType`
|
|
380
395
|
* union so `.has(error.type)` accepts any value from the union.
|
|
381
396
|
*/
|
|
382
397
|
const ERROR_CATEGORIES = {
|
|
383
|
-
client: new Set<ErrorType>([ErrorType.Business, ErrorType.Config, ErrorType.File, ErrorType.Validation]),
|
|
398
|
+
client: new Set<ErrorType>([ErrorType.Business, ErrorType.Config, ErrorType.File, ErrorType.Forbidden, ErrorType.Validation]),
|
|
384
399
|
network: new Set<ErrorType>([ErrorType.Network]),
|
|
385
400
|
auth: new Set<ErrorType>([ErrorType.Authentication]),
|
|
386
401
|
} as const;
|
|
387
402
|
|
|
388
403
|
/**
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
* they originate on the client (fetch failure, abort) and should never be
|
|
395
|
-
* reconstructed from a server response, even if a misbehaving server were
|
|
396
|
-
* to send them. A defensive omission, not a theoretical concern.
|
|
404
|
+
* Error types the server can legitimately produce on the wire. Used by
|
|
405
|
+
* `ShipError.fromHttpResponse` to validate the body's `error` field before
|
|
406
|
+
* trusting it as `ShipError.type`. Derived by exclusion from
|
|
407
|
+
* `CLIENT_ONLY_ERROR_TYPES` so adding a new server-producible type to
|
|
408
|
+
* `ErrorType` is automatically picked up.
|
|
397
409
|
*/
|
|
398
410
|
const SERVER_PRODUCIBLE_ERROR_TYPES = new Set<string>(
|
|
399
|
-
Object.values(ErrorType).filter(
|
|
400
|
-
t => t !== ErrorType.Network && t !== ErrorType.Cancelled,
|
|
401
|
-
),
|
|
411
|
+
Object.values(ErrorType).filter(t => !CLIENT_ONLY_ERROR_TYPES.has(t)),
|
|
402
412
|
);
|
|
403
413
|
|
|
404
414
|
/**
|
|
@@ -411,8 +421,8 @@ export interface ErrorResponse {
|
|
|
411
421
|
message: string;
|
|
412
422
|
/** HTTP status code (API contexts) */
|
|
413
423
|
status?: number;
|
|
414
|
-
/** Optional additional error details */
|
|
415
|
-
details?:
|
|
424
|
+
/** Optional additional error details. Untyped by design — narrow at the read site. */
|
|
425
|
+
details?: unknown;
|
|
416
426
|
}
|
|
417
427
|
|
|
418
428
|
/**
|
|
@@ -423,7 +433,7 @@ export class ShipError extends Error {
|
|
|
423
433
|
public readonly type: ErrorType,
|
|
424
434
|
message: string,
|
|
425
435
|
public readonly status?: number,
|
|
426
|
-
public readonly details?:
|
|
436
|
+
public readonly details?: unknown,
|
|
427
437
|
) {
|
|
428
438
|
super(message);
|
|
429
439
|
this.name = 'ShipError';
|
|
@@ -431,8 +441,11 @@ export class ShipError extends Error {
|
|
|
431
441
|
|
|
432
442
|
/** Convert to wire format */
|
|
433
443
|
toResponse(): ErrorResponse {
|
|
434
|
-
//
|
|
435
|
-
|
|
444
|
+
// Strip authentication details when they carry an `internal` telemetry
|
|
445
|
+
// tag (see `ShipError.authentication` JSDoc) — these are server-side
|
|
446
|
+
// diagnostics like 'jwt_missing_subject' that must not leak to clients.
|
|
447
|
+
const authDetails = this.details as { internal?: unknown } | undefined;
|
|
448
|
+
const details = this.type === ErrorType.Authentication && authDetails?.internal
|
|
436
449
|
? undefined
|
|
437
450
|
: this.details;
|
|
438
451
|
|
|
@@ -448,21 +461,27 @@ export class ShipError extends Error {
|
|
|
448
461
|
* Construct a `ShipError` from an HTTP error response.
|
|
449
462
|
*
|
|
450
463
|
* Best-effort body parse for `{ message, error?, details? }`. Message
|
|
451
|
-
* resolution: `body.message` → `body.error` → `
|
|
452
|
-
*
|
|
464
|
+
* resolution: `body.message` → `body.error` → `"<operationName> failed with
|
|
465
|
+
* status <N>"`.
|
|
466
|
+
*
|
|
467
|
+
* Type resolution: trusts `body.error` when it's a known server-producible
|
|
468
|
+
* `ErrorType` (preserves the wire's intent — server's
|
|
469
|
+
* `ShipError.validation(...)` round-trips back to `ErrorType.Validation`
|
|
470
|
+
* on the client). Falls back to status-derived (401 → Authentication,
|
|
471
|
+
* 403 → Forbidden, 429 → RateLimit, else → Api) for non-API responses
|
|
472
|
+
* (CDN errors, intermediaries) or malformed bodies. Client-only types
|
|
473
|
+
* (`Network`, `Cancelled`, `File`, `Config`) are filtered out of the
|
|
474
|
+
* trusted set — a misbehaving server claiming one of those is ignored.
|
|
453
475
|
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
* round-trips back to `ErrorType.Validation` on the client). Falls back to
|
|
457
|
-
* status-derived (401 → Authentication, 429 → RateLimit, else → Api) for
|
|
458
|
-
* non-API responses (CDN errors, intermediaries) or malformed bodies.
|
|
476
|
+
* `operationName` (e.g. `"Get account"`) is used to compose the fallback
|
|
477
|
+
* message. Defaults to `"Request"`. Same convention as `fromFetchError`.
|
|
459
478
|
*
|
|
460
479
|
* Async because it reads the response body. Returns rather than throws so
|
|
461
480
|
* callers can compose; most will `throw await ShipError.fromHttpResponse(...)`.
|
|
462
481
|
*/
|
|
463
482
|
static async fromHttpResponse(
|
|
464
483
|
response: Response,
|
|
465
|
-
|
|
484
|
+
operationName?: string,
|
|
466
485
|
): Promise<ShipError> {
|
|
467
486
|
let message: string | undefined;
|
|
468
487
|
let details: unknown;
|
|
@@ -486,13 +505,14 @@ export class ShipError extends Error {
|
|
|
486
505
|
if (text) message = text;
|
|
487
506
|
}
|
|
488
507
|
} catch {
|
|
489
|
-
// Body unreadable; fall through to
|
|
508
|
+
// Body unreadable; fall through to operationName-derived message.
|
|
490
509
|
}
|
|
491
510
|
|
|
492
|
-
message = message ||
|
|
511
|
+
message = message || `${operationName || 'Request'} failed with status ${response.status}`;
|
|
493
512
|
|
|
494
513
|
const type = bodyType ?? (
|
|
495
514
|
response.status === 401 ? ErrorType.Authentication :
|
|
515
|
+
response.status === 403 ? ErrorType.Forbidden :
|
|
496
516
|
response.status === 429 ? ErrorType.RateLimit :
|
|
497
517
|
ErrorType.Api
|
|
498
518
|
);
|
|
@@ -528,7 +548,7 @@ export class ShipError extends Error {
|
|
|
528
548
|
return ShipError.cancelled(`${op} was cancelled`);
|
|
529
549
|
}
|
|
530
550
|
if (cause instanceof TypeError && cause.message.includes('fetch')) {
|
|
531
|
-
return ShipError.network(`${op} failed: ${cause.message}`, cause);
|
|
551
|
+
return ShipError.network(`${op} failed: ${cause.message}`, { cause });
|
|
532
552
|
}
|
|
533
553
|
return new ShipError(ErrorType.Api, `${op} failed: ${cause.message}`);
|
|
534
554
|
}
|
|
@@ -536,8 +556,12 @@ export class ShipError extends Error {
|
|
|
536
556
|
return new ShipError(ErrorType.Api, `${op} failed: Unknown error`);
|
|
537
557
|
}
|
|
538
558
|
|
|
539
|
-
// Factory methods
|
|
540
|
-
|
|
559
|
+
// Factory methods. Uniform shape `(message, details?)` with two principled
|
|
560
|
+
// exceptions: `notFound` composes its message from (resource, id?), and
|
|
561
|
+
// `business` / `api` accept an optional status because they're the
|
|
562
|
+
// multi-status fallbacks.
|
|
563
|
+
|
|
564
|
+
static validation(message: string, details?: unknown): ShipError {
|
|
541
565
|
return new ShipError(ErrorType.Validation, message, 400, details);
|
|
542
566
|
}
|
|
543
567
|
|
|
@@ -546,11 +570,28 @@ export class ShipError extends Error {
|
|
|
546
570
|
return new ShipError(ErrorType.NotFound, message, 404);
|
|
547
571
|
}
|
|
548
572
|
|
|
573
|
+
static forbidden(message: string, details?: unknown): ShipError {
|
|
574
|
+
return new ShipError(ErrorType.Forbidden, message, 403, details);
|
|
575
|
+
}
|
|
576
|
+
|
|
549
577
|
static rateLimit(message: string = "Too many requests"): ShipError {
|
|
550
578
|
return new ShipError(ErrorType.RateLimit, message, 429);
|
|
551
579
|
}
|
|
552
580
|
|
|
553
|
-
|
|
581
|
+
/**
|
|
582
|
+
* Construct an Authentication (401) error.
|
|
583
|
+
*
|
|
584
|
+
* **Telemetry pattern — `details: { internal: '<tag>' }`.** When the
|
|
585
|
+
* server creates an auth error with an `internal` key in `details`
|
|
586
|
+
* (e.g. `{ internal: 'jwt_missing_subject' }`), `toResponse()` strips the
|
|
587
|
+
* entire `details` object before serialization. This keeps the wire
|
|
588
|
+
* response a clean "Authentication failed" while preserving granular
|
|
589
|
+
* server-side telemetry (which strategy/check failed) for logs and tests.
|
|
590
|
+
*
|
|
591
|
+
* Use this pattern in API auth code; do not put client-visible info under
|
|
592
|
+
* `internal`. Other `details` keys round-trip normally.
|
|
593
|
+
*/
|
|
594
|
+
static authentication(message: string = "Authentication required", details?: unknown): ShipError {
|
|
554
595
|
return new ShipError(ErrorType.Authentication, message, 401, details);
|
|
555
596
|
}
|
|
556
597
|
|
|
@@ -558,19 +599,19 @@ export class ShipError extends Error {
|
|
|
558
599
|
return new ShipError(ErrorType.Business, message, status);
|
|
559
600
|
}
|
|
560
601
|
|
|
561
|
-
static network(message: string,
|
|
562
|
-
return new ShipError(ErrorType.Network, message, undefined,
|
|
602
|
+
static network(message: string, details?: unknown): ShipError {
|
|
603
|
+
return new ShipError(ErrorType.Network, message, undefined, details);
|
|
563
604
|
}
|
|
564
605
|
|
|
565
606
|
static cancelled(message: string): ShipError {
|
|
566
607
|
return new ShipError(ErrorType.Cancelled, message);
|
|
567
608
|
}
|
|
568
609
|
|
|
569
|
-
static file(message: string,
|
|
570
|
-
return new ShipError(ErrorType.File, message, undefined,
|
|
610
|
+
static file(message: string, details?: unknown): ShipError {
|
|
611
|
+
return new ShipError(ErrorType.File, message, undefined, details);
|
|
571
612
|
}
|
|
572
613
|
|
|
573
|
-
static config(message: string, details?:
|
|
614
|
+
static config(message: string, details?: unknown): ShipError {
|
|
574
615
|
return new ShipError(ErrorType.Config, message, undefined, details);
|
|
575
616
|
}
|
|
576
617
|
|
|
@@ -578,12 +619,8 @@ export class ShipError extends Error {
|
|
|
578
619
|
return new ShipError(ErrorType.Api, message, status);
|
|
579
620
|
}
|
|
580
621
|
|
|
581
|
-
//
|
|
582
|
-
|
|
583
|
-
return this.details?.filePath;
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
// Helper methods for error type checking using categorization
|
|
622
|
+
// Semantic-category type guards. For specific-type checks, use
|
|
623
|
+
// `error.type === ErrorType.X` directly or the generic `isType(t)`.
|
|
587
624
|
isClientError(): boolean {
|
|
588
625
|
return ERROR_CATEGORIES.client.has(this.type);
|
|
589
626
|
}
|
|
@@ -596,19 +633,6 @@ export class ShipError extends Error {
|
|
|
596
633
|
return ERROR_CATEGORIES.auth.has(this.type);
|
|
597
634
|
}
|
|
598
635
|
|
|
599
|
-
isValidationError(): boolean {
|
|
600
|
-
return this.type === ErrorType.Validation;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
isFileError(): boolean {
|
|
604
|
-
return this.type === ErrorType.File;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
isConfigError(): boolean {
|
|
608
|
-
return this.type === ErrorType.Config;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
// Generic type checker
|
|
612
636
|
isType(errorType: ErrorType): boolean {
|
|
613
637
|
return this.type === errorType;
|
|
614
638
|
}
|