@shipstatic/types 2.7.0-beta.1 → 2.7.0-beta.3
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 +2 -2
- package/dist/index.d.ts +46 -2
- package/dist/index.js +144 -37
- package/package.json +1 -1
- package/src/index.ts +148 -37
package/README.md
CHANGED
|
@@ -78,9 +78,9 @@ try { response = await fetch(url); }
|
|
|
78
78
|
catch (cause) { throw ShipError.fromFetchError(cause, 'Get account'); }
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
-
`fromHttpResponse` trusts the body's `error` field when it's a known server-producible `ErrorType` — so a server's `ShipError.validation(...)` round-trips back to `ErrorType.Validation` on the client. For non-API responses (CDN errors, intermediaries) or malformed bodies it falls back to status-derived (401 → `Authentication`, 403 → `Forbidden`, 429 → `RateLimit`, else → `Api`). Client-only types (`Network`, `Cancelled`, `File`, `Config`) are filtered out of the trusted set. Body's `message` and `details` are preserved best-effort.
|
|
81
|
+
`fromHttpResponse` trusts the body's `error` field when it's a known server-producible `ErrorType` — so a server's `ShipError.validation(...)` round-trips back to `ErrorType.Validation` on the client. For non-API responses (CDN errors, intermediaries) or malformed bodies it falls back to status-derived (401 → `Authentication`, 403 → `Forbidden`, 429 → `RateLimit`, else → `Api`). Client-only types (`Network`, `Timeout`, `Cancelled`, `File`, `Config`) are filtered out of the trusted set. Body's `message` and `details` are preserved best-effort.
|
|
82
82
|
|
|
83
|
-
`fromFetchError` routes by the thrown cause: an existing `ShipError` is returned unchanged, `AbortError` becomes `Cancelled`, a fetch `TypeError` becomes `Network`, anything else becomes `Api` (with no HTTP status — the request never reached the server).
|
|
83
|
+
`fromFetchError` routes by the thrown cause: an existing `ShipError` is returned unchanged, `AbortError` becomes `Cancelled`, `TimeoutError` becomes `Timeout`, a fetch `TypeError` becomes `Network`, anything else becomes `Api` (with no HTTP status — the request never reached the server). `Timeout` is a distinct type inside the network category — `isNetworkError()` is true for it — so a surface can retry it like any transport failure while still saying "timed out" rather than "check your connection".
|
|
84
84
|
|
|
85
85
|
Both helpers accept an optional operation-name string for contextual messages (`"Get account was cancelled"`, `"Get account failed: ..."`).
|
|
86
86
|
|
package/dist/index.d.ts
CHANGED
|
@@ -725,6 +725,23 @@ export declare const ErrorType: {
|
|
|
725
725
|
readonly Maintenance: "maintenance";
|
|
726
726
|
/** Network/connection error. Client-side only — set by HTTP clients on fetch failure; never produced server-side. */
|
|
727
727
|
readonly Network: "network_error";
|
|
728
|
+
/**
|
|
729
|
+
* A deadline expired before the exchange completed. Client-side only — set
|
|
730
|
+
* by HTTP clients when a timeout signal fires; never produced server-side.
|
|
731
|
+
*
|
|
732
|
+
* A member of the NETWORK category rather than a sibling of it:
|
|
733
|
+
* `isNetworkError()` answers "nothing was exchanged", which is true of a
|
|
734
|
+
* deadline exactly as it is of a refused connection, so every consumer that
|
|
735
|
+
* retries, declines to report, or declines to relay a wire message on that
|
|
736
|
+
* category is already right about a timeout. The distinct TYPE exists for
|
|
737
|
+
* the one decision the category cannot make — what to SAY. "Check your
|
|
738
|
+
* internet connection" is the wrong sentence for a five-minute deploy
|
|
739
|
+
* ceiling, and a surface can only tell the two apart by type.
|
|
740
|
+
*
|
|
741
|
+
* The same relationship every comparable SDK ships:
|
|
742
|
+
* `APIConnectionTimeoutError extends APIConnectionError`.
|
|
743
|
+
*/
|
|
744
|
+
readonly Timeout: "timeout_error";
|
|
728
745
|
/** Operation was cancelled. Client-side only — set on `AbortSignal` abort; never produced server-side. */
|
|
729
746
|
readonly Cancelled: "operation_cancelled";
|
|
730
747
|
/** File operation error. Client-side only — set by SDK during local file processing; never produced server-side. */
|
|
@@ -769,7 +786,7 @@ export declare class ShipError extends Error {
|
|
|
769
786
|
* on the client). Falls back to status-derived (401 → Authentication,
|
|
770
787
|
* 403 → Forbidden, 429 → RateLimit, else → Api) for non-API responses
|
|
771
788
|
* (CDN errors, intermediaries) or malformed bodies. Client-only types
|
|
772
|
-
* (`Network`, `Cancelled`, `File`, `Config`) are filtered out of the
|
|
789
|
+
* (`Network`, `Timeout`, `Cancelled`, `File`, `Config`) are filtered out of the
|
|
773
790
|
* trusted set — a misbehaving server claiming one of those is ignored.
|
|
774
791
|
*
|
|
775
792
|
* `operationName` (e.g. `"Get account"`) is used to compose the fallback
|
|
@@ -788,12 +805,31 @@ export declare class ShipError extends Error {
|
|
|
788
805
|
*
|
|
789
806
|
* Routing:
|
|
790
807
|
* - Already a `ShipError` → returned as-is (caller's intent preserved)
|
|
791
|
-
* - `AbortError` → `ShipError.cancelled(...)`
|
|
808
|
+
* - `AbortError` → `ShipError.cancelled(...)` — someone stopped it on purpose
|
|
809
|
+
* - `TimeoutError` → `ShipError.timeout(...)` — a deadline expired; the
|
|
810
|
+
* message names the timeout, and the type is in the network CATEGORY
|
|
811
|
+
* because nothing was exchanged
|
|
792
812
|
* - A transport failure → `ShipError.network(...)` — see `isTransportFailure`
|
|
793
813
|
* for what each runtime offers as evidence
|
|
794
814
|
* - Any other `Error` → `ShipError(Api, ...)` (no HTTP status — fetch never reached the server)
|
|
795
815
|
* - Anything else (string, undefined, etc.) → `ShipError(Api, ...)`
|
|
796
816
|
*
|
|
817
|
+
* **Abort and timeout are read from `name` BEFORE any `instanceof Error`
|
|
818
|
+
* gate.** A `DOMException` satisfies that gate in every runtime measured
|
|
819
|
+
* (Node, Bun, Chromium, Firefox, WebKit, workerd — all six), but the
|
|
820
|
+
* inheritance is a comparatively recent spec change and this classification
|
|
821
|
+
* has no reason to depend on it: `name` is where the meaning lives, and
|
|
822
|
+
* reading it first costs nothing. The suite plants a non-`Error`
|
|
823
|
+
* `DOMException` shape to hold the arm, since no runtime on the table
|
|
824
|
+
* produces one.
|
|
825
|
+
*
|
|
826
|
+
* A caller's own `AbortSignal.timeout()` is the reachable source of
|
|
827
|
+
* `TimeoutError` — and the two are NOT interchangeable per runtime: WebKit
|
|
828
|
+
* reports a fired `AbortSignal.timeout()` as `AbortError`, so on Safari a
|
|
829
|
+
* deadline is indistinguishable from a cancellation and lands on
|
|
830
|
+
* `Cancelled`. Recorded rather than worked around; `Cancelled` is honest
|
|
831
|
+
* there, since the caller's signal is what stopped it.
|
|
832
|
+
*
|
|
797
833
|
* The optional `operationName` is composed into the message for context:
|
|
798
834
|
* `"Get account was cancelled"`, `"Get account failed: ..."`. Defaults to
|
|
799
835
|
* `"Request"` when omitted.
|
|
@@ -819,6 +855,14 @@ export declare class ShipError extends Error {
|
|
|
819
855
|
static authentication(message?: string, details?: unknown): ShipError;
|
|
820
856
|
static business(message: string, status?: number, details?: unknown): ShipError;
|
|
821
857
|
static network(message: string, details?: unknown): ShipError;
|
|
858
|
+
/**
|
|
859
|
+
* A deadline expired before the exchange completed.
|
|
860
|
+
*
|
|
861
|
+
* Statusless like its four client-only siblings: no exchange completed, so
|
|
862
|
+
* there is no HTTP status to report. `isNetworkError()` is true — see
|
|
863
|
+
* `ErrorType.Timeout` for why the category is shared and the type is not.
|
|
864
|
+
*/
|
|
865
|
+
static timeout(message: string, details?: unknown): ShipError;
|
|
822
866
|
static cancelled(message: string, details?: unknown): ShipError;
|
|
823
867
|
static file(message: string, details?: unknown): ShipError;
|
|
824
868
|
static config(message: string, details?: unknown): ShipError;
|
package/dist/index.js
CHANGED
|
@@ -271,6 +271,23 @@ export const ErrorType = {
|
|
|
271
271
|
Maintenance: 'maintenance',
|
|
272
272
|
/** Network/connection error. Client-side only — set by HTTP clients on fetch failure; never produced server-side. */
|
|
273
273
|
Network: 'network_error',
|
|
274
|
+
/**
|
|
275
|
+
* A deadline expired before the exchange completed. Client-side only — set
|
|
276
|
+
* by HTTP clients when a timeout signal fires; never produced server-side.
|
|
277
|
+
*
|
|
278
|
+
* A member of the NETWORK category rather than a sibling of it:
|
|
279
|
+
* `isNetworkError()` answers "nothing was exchanged", which is true of a
|
|
280
|
+
* deadline exactly as it is of a refused connection, so every consumer that
|
|
281
|
+
* retries, declines to report, or declines to relay a wire message on that
|
|
282
|
+
* category is already right about a timeout. The distinct TYPE exists for
|
|
283
|
+
* the one decision the category cannot make — what to SAY. "Check your
|
|
284
|
+
* internet connection" is the wrong sentence for a five-minute deploy
|
|
285
|
+
* ceiling, and a surface can only tell the two apart by type.
|
|
286
|
+
*
|
|
287
|
+
* The same relationship every comparable SDK ships:
|
|
288
|
+
* `APIConnectionTimeoutError extends APIConnectionError`.
|
|
289
|
+
*/
|
|
290
|
+
Timeout: 'timeout_error',
|
|
274
291
|
/** Operation was cancelled. Client-side only — set on `AbortSignal` abort; never produced server-side. */
|
|
275
292
|
Cancelled: 'operation_cancelled',
|
|
276
293
|
/** File operation error. Client-side only — set by SDK during local file processing; never produced server-side. */
|
|
@@ -286,6 +303,7 @@ export const ErrorType = {
|
|
|
286
303
|
*/
|
|
287
304
|
const CLIENT_ONLY_ERROR_TYPES = new Set([
|
|
288
305
|
ErrorType.Network,
|
|
306
|
+
ErrorType.Timeout,
|
|
289
307
|
ErrorType.Cancelled,
|
|
290
308
|
ErrorType.File,
|
|
291
309
|
ErrorType.Config,
|
|
@@ -301,12 +319,20 @@ const ERROR_CATEGORIES = {
|
|
|
301
319
|
* over the statusless ones too — those are raised locally and have no
|
|
302
320
|
* status for `isClientError`'s second arm to read, so omitting one makes it
|
|
303
321
|
* read as a server fault. The rule is the membership test: every type in
|
|
304
|
-
* `CLIENT_ONLY_ERROR_TYPES` except
|
|
305
|
-
*
|
|
322
|
+
* `CLIENT_ONLY_ERROR_TYPES` except the two `isNetworkError` owns belongs
|
|
323
|
+
* here.
|
|
306
324
|
*
|
|
307
325
|
* `Cancelled` was missing until 2026-07-29, which is exactly that failure:
|
|
308
326
|
* a caller who aborted their own deploy was told "server error: please try
|
|
309
327
|
* again" — the CLI's fallback for everything this set does not claim.
|
|
328
|
+
*
|
|
329
|
+
* `Timeout` is deliberately NOT here, and it is the sharper case, because
|
|
330
|
+
* it is the one client-only type that is not the client's fault: the
|
|
331
|
+
* caller set a ceiling, but what exhausted it was the network or the
|
|
332
|
+
* server. Reading it as client-attributable would say the caller erred,
|
|
333
|
+
* and it would silently disarm every consumer whose retry predicate
|
|
334
|
+
* declines `isClientError()` — a deadline is precisely the failure worth
|
|
335
|
+
* a second attempt.
|
|
310
336
|
*/
|
|
311
337
|
client: new Set([
|
|
312
338
|
ErrorType.Business,
|
|
@@ -318,7 +344,14 @@ const ERROR_CATEGORIES = {
|
|
|
318
344
|
ErrorType.RateLimit,
|
|
319
345
|
ErrorType.Validation,
|
|
320
346
|
]),
|
|
321
|
-
|
|
347
|
+
/**
|
|
348
|
+
* The exchange never happened. Two types, one category: a refused
|
|
349
|
+
* connection and an expired deadline differ in what a surface should SAY
|
|
350
|
+
* and in nothing else a consumer decides on — both are retryable, neither
|
|
351
|
+
* carries a wire message to relay, neither is worth reporting as an
|
|
352
|
+
* incident. See `ErrorType.Timeout` for why the type is distinct anyway.
|
|
353
|
+
*/
|
|
354
|
+
network: new Set([ErrorType.Network, ErrorType.Timeout]),
|
|
322
355
|
auth: new Set([ErrorType.Authentication]),
|
|
323
356
|
};
|
|
324
357
|
/**
|
|
@@ -340,38 +373,71 @@ const MAX_FOREIGN_MESSAGE_LENGTH = 200;
|
|
|
340
373
|
/**
|
|
341
374
|
* Did the runtime say the exchange never completed?
|
|
342
375
|
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
* |
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
* The
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
376
|
+
* Clients branch on the TYPE, never on message strings, so a misclassified
|
|
377
|
+
* transport failure is a lie every consumer inherits — and the one that costs
|
|
378
|
+
* most: `Api` claims a server answered when nothing was exchanged, and a
|
|
379
|
+
* retrying caller will not retry it.
|
|
380
|
+
*
|
|
381
|
+
* **Every row below is a transcript, not a belief.** Captured 2026-08-12
|
|
382
|
+
* against real runtimes — Node and Bun by direct run, the three engines by a
|
|
383
|
+
* one-off playwright probe, workerd through miniflare. The capture scripts are
|
|
384
|
+
* in `tests/errors.test.ts`, "runtime failure shapes".
|
|
385
|
+
*
|
|
386
|
+
* | runtime | connection refused / DNS failure | malformed URL |
|
|
387
|
+
* |------------------|------------------------------------------------------|--------------------------------------------------|
|
|
388
|
+
* | Node 22 / undici | `TypeError: fetch failed` | `TypeError: Failed to parse URL from …` |
|
|
389
|
+
* | Bun 1.3.14 | `Error` `code:'ConnectionRefused'` | `TypeError` `code:'ERR_INVALID_URL'` |
|
|
390
|
+
* | Chromium 151 | `TypeError: Failed to fetch` | `TypeError: …Failed to parse URL from …` |
|
|
391
|
+
* | Firefox 153 | `TypeError: NetworkError when attempting to fetch …` | `TypeError: … is not a valid URL.` |
|
|
392
|
+
* | WebKit 26.5 | `TypeError: Load failed` | `TypeError: URL is not valid or contains user …` |
|
|
393
|
+
* | workerd | `Error: Network connection lost.` (DNS: `internal error; reference = …`) | `TypeError: Invalid URL: …` |
|
|
394
|
+
*
|
|
395
|
+
* Reading that table gives the rule, and it is the INVERSE of the obvious one.
|
|
396
|
+
* The transport class is unbounded — every OS, TLS and DNS failure any engine
|
|
397
|
+
* will ever name — while the class fetch raises for its own ARGUMENTS is
|
|
398
|
+
* small, and every runtime names the URL when it complains about one. So the
|
|
399
|
+
* bounded side is the one worth testing, and the residual risk points the safe
|
|
400
|
+
* way: an unrecognised sentence lands on `Network`, which says only that
|
|
401
|
+
* nothing was exchanged.
|
|
402
|
+
*
|
|
403
|
+
* That inversion is what fixes **WebKit**, whose `Load failed` carries no code
|
|
404
|
+
* and no "fetch", and which every browser-SDK and `@shipstatic/drop` user on
|
|
405
|
+
* Safari was hitting as `Api`. It also makes the six runtimes AGREE about a
|
|
406
|
+
* malformed URL, which they did not before: the previous rule tested the
|
|
407
|
+
* message for "fetch", and Chromium's and Firefox's URL complaints both
|
|
408
|
+
* contain it, so the same mistake was `Network` on three engines and `Api` on
|
|
409
|
+
* three.
|
|
410
|
+
*
|
|
411
|
+
* **workerd is the recorded gap.** It rejects with a plain `Error`, no code
|
|
412
|
+
* and no shared sentence — and its two failure modes produce two unrelated
|
|
413
|
+
* ones — so nothing here can classify it and it lands on `Api`. Left alone
|
|
414
|
+
* rather than patched with a dialect string: the one consumer running ship in
|
|
415
|
+
* that runtime (`cloudflare/mcp`) reaches the API through a service BINDING,
|
|
416
|
+
* which is in-process and does not produce transport rejections at all.
|
|
417
|
+
*
|
|
418
|
+
* The accepted trade is unchanged: a caller's `TokenProvider` that throws a
|
|
419
|
+
* coded error (`ENOENT` from a keychain read) is typed `Network` rather than
|
|
420
|
+
* `Api`. Both are wrong for it; `Network` is the cheaper wrong.
|
|
367
421
|
*/
|
|
368
422
|
function isTransportFailure(cause) {
|
|
369
|
-
|
|
423
|
+
const code = cause.code;
|
|
424
|
+
// Bun is the one runtime that puts a CODE on an argument error, so it is
|
|
425
|
+
// excluded before the code arm can claim it.
|
|
426
|
+
if (code === 'ERR_INVALID_URL')
|
|
427
|
+
return false;
|
|
428
|
+
// A string `code` is a runtime naming a transport-level failure. An
|
|
429
|
+
// allowlist of codes was written first and rejected — the TLS row alone
|
|
430
|
+
// would mean enumerating BoringSSL's certificate table, and a code nobody
|
|
431
|
+
// guessed is precisely the bug this closes. A `DOMException`'s code is a
|
|
432
|
+
// NUMBER, so aborts and timeouts never reach here.
|
|
433
|
+
if (typeof code === 'string')
|
|
370
434
|
return true;
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
435
|
+
// WHATWG has fetch reject with a TypeError for BOTH halves — network error
|
|
436
|
+
// and argument error — so among TypeErrors the URL is the discriminator.
|
|
437
|
+
if (cause instanceof TypeError)
|
|
438
|
+
return !/\burl\b/i.test(cause.message);
|
|
439
|
+
// Anything else — an ordinary JS fault, or workerd — is not evidence.
|
|
440
|
+
return false;
|
|
375
441
|
}
|
|
376
442
|
/**
|
|
377
443
|
* Simple unified error class for both API and SDK
|
|
@@ -414,7 +480,7 @@ export class ShipError extends Error {
|
|
|
414
480
|
* on the client). Falls back to status-derived (401 → Authentication,
|
|
415
481
|
* 403 → Forbidden, 429 → RateLimit, else → Api) for non-API responses
|
|
416
482
|
* (CDN errors, intermediaries) or malformed bodies. Client-only types
|
|
417
|
-
* (`Network`, `Cancelled`, `File`, `Config`) are filtered out of the
|
|
483
|
+
* (`Network`, `Timeout`, `Cancelled`, `File`, `Config`) are filtered out of the
|
|
418
484
|
* trusted set — a misbehaving server claiming one of those is ignored.
|
|
419
485
|
*
|
|
420
486
|
* `operationName` (e.g. `"Get account"`) is used to compose the fallback
|
|
@@ -497,12 +563,31 @@ export class ShipError extends Error {
|
|
|
497
563
|
*
|
|
498
564
|
* Routing:
|
|
499
565
|
* - Already a `ShipError` → returned as-is (caller's intent preserved)
|
|
500
|
-
* - `AbortError` → `ShipError.cancelled(...)`
|
|
566
|
+
* - `AbortError` → `ShipError.cancelled(...)` — someone stopped it on purpose
|
|
567
|
+
* - `TimeoutError` → `ShipError.timeout(...)` — a deadline expired; the
|
|
568
|
+
* message names the timeout, and the type is in the network CATEGORY
|
|
569
|
+
* because nothing was exchanged
|
|
501
570
|
* - A transport failure → `ShipError.network(...)` — see `isTransportFailure`
|
|
502
571
|
* for what each runtime offers as evidence
|
|
503
572
|
* - Any other `Error` → `ShipError(Api, ...)` (no HTTP status — fetch never reached the server)
|
|
504
573
|
* - Anything else (string, undefined, etc.) → `ShipError(Api, ...)`
|
|
505
574
|
*
|
|
575
|
+
* **Abort and timeout are read from `name` BEFORE any `instanceof Error`
|
|
576
|
+
* gate.** A `DOMException` satisfies that gate in every runtime measured
|
|
577
|
+
* (Node, Bun, Chromium, Firefox, WebKit, workerd — all six), but the
|
|
578
|
+
* inheritance is a comparatively recent spec change and this classification
|
|
579
|
+
* has no reason to depend on it: `name` is where the meaning lives, and
|
|
580
|
+
* reading it first costs nothing. The suite plants a non-`Error`
|
|
581
|
+
* `DOMException` shape to hold the arm, since no runtime on the table
|
|
582
|
+
* produces one.
|
|
583
|
+
*
|
|
584
|
+
* A caller's own `AbortSignal.timeout()` is the reachable source of
|
|
585
|
+
* `TimeoutError` — and the two are NOT interchangeable per runtime: WebKit
|
|
586
|
+
* reports a fired `AbortSignal.timeout()` as `AbortError`, so on Safari a
|
|
587
|
+
* deadline is indistinguishable from a cancellation and lands on
|
|
588
|
+
* `Cancelled`. Recorded rather than worked around; `Cancelled` is honest
|
|
589
|
+
* there, since the caller's signal is what stopped it.
|
|
590
|
+
*
|
|
506
591
|
* The optional `operationName` is composed into the message for context:
|
|
507
592
|
* `"Get account was cancelled"`, `"Get account failed: ..."`. Defaults to
|
|
508
593
|
* `"Request"` when omitted.
|
|
@@ -511,10 +596,22 @@ export class ShipError extends Error {
|
|
|
511
596
|
if (isShipError(cause))
|
|
512
597
|
return cause;
|
|
513
598
|
const op = operationName || 'Request';
|
|
599
|
+
// Read by NAME, ahead of the Error gate — see the note above.
|
|
600
|
+
const name = cause?.name;
|
|
601
|
+
if (name === 'AbortError') {
|
|
602
|
+
return ShipError.cancelled(`${op} was cancelled`);
|
|
603
|
+
}
|
|
604
|
+
if (name === 'TimeoutError') {
|
|
605
|
+
// A deadline: not a fault, not a cancellation, and — since it has its
|
|
606
|
+
// own type — no longer merely "network". Nothing was exchanged, which
|
|
607
|
+
// is what keeps it in the network CATEGORY and therefore retryable; the
|
|
608
|
+
// type is what lets a surface say "timed out" instead of sending
|
|
609
|
+
// someone to check their Wi-Fi. The runtime's own sentence is dropped
|
|
610
|
+
// rather than relayed: "The operation was aborted due to timeout" is
|
|
611
|
+
// the mechanism, not the news.
|
|
612
|
+
return ShipError.timeout(`${op} timed out`, { cause });
|
|
613
|
+
}
|
|
514
614
|
if (cause instanceof Error) {
|
|
515
|
-
if (cause.name === 'AbortError') {
|
|
516
|
-
return ShipError.cancelled(`${op} was cancelled`);
|
|
517
|
-
}
|
|
518
615
|
if (isTransportFailure(cause)) {
|
|
519
616
|
return ShipError.network(`${op} failed: ${cause.message}`, { cause });
|
|
520
617
|
}
|
|
@@ -561,6 +658,16 @@ export class ShipError extends Error {
|
|
|
561
658
|
static network(message, details) {
|
|
562
659
|
return new ShipError(ErrorType.Network, message, undefined, details);
|
|
563
660
|
}
|
|
661
|
+
/**
|
|
662
|
+
* A deadline expired before the exchange completed.
|
|
663
|
+
*
|
|
664
|
+
* Statusless like its four client-only siblings: no exchange completed, so
|
|
665
|
+
* there is no HTTP status to report. `isNetworkError()` is true — see
|
|
666
|
+
* `ErrorType.Timeout` for why the category is shared and the type is not.
|
|
667
|
+
*/
|
|
668
|
+
static timeout(message, details) {
|
|
669
|
+
return new ShipError(ErrorType.Timeout, message, undefined, details);
|
|
670
|
+
}
|
|
564
671
|
static cancelled(message, details) {
|
|
565
672
|
return new ShipError(ErrorType.Cancelled, message, undefined, details);
|
|
566
673
|
}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -815,6 +815,23 @@ export const ErrorType = {
|
|
|
815
815
|
Maintenance: 'maintenance',
|
|
816
816
|
/** Network/connection error. Client-side only — set by HTTP clients on fetch failure; never produced server-side. */
|
|
817
817
|
Network: 'network_error',
|
|
818
|
+
/**
|
|
819
|
+
* A deadline expired before the exchange completed. Client-side only — set
|
|
820
|
+
* by HTTP clients when a timeout signal fires; never produced server-side.
|
|
821
|
+
*
|
|
822
|
+
* A member of the NETWORK category rather than a sibling of it:
|
|
823
|
+
* `isNetworkError()` answers "nothing was exchanged", which is true of a
|
|
824
|
+
* deadline exactly as it is of a refused connection, so every consumer that
|
|
825
|
+
* retries, declines to report, or declines to relay a wire message on that
|
|
826
|
+
* category is already right about a timeout. The distinct TYPE exists for
|
|
827
|
+
* the one decision the category cannot make — what to SAY. "Check your
|
|
828
|
+
* internet connection" is the wrong sentence for a five-minute deploy
|
|
829
|
+
* ceiling, and a surface can only tell the two apart by type.
|
|
830
|
+
*
|
|
831
|
+
* The same relationship every comparable SDK ships:
|
|
832
|
+
* `APIConnectionTimeoutError extends APIConnectionError`.
|
|
833
|
+
*/
|
|
834
|
+
Timeout: 'timeout_error',
|
|
818
835
|
/** Operation was cancelled. Client-side only — set on `AbortSignal` abort; never produced server-side. */
|
|
819
836
|
Cancelled: 'operation_cancelled',
|
|
820
837
|
/** File operation error. Client-side only — set by SDK during local file processing; never produced server-side. */
|
|
@@ -833,6 +850,7 @@ export type ErrorType = (typeof ErrorType)[keyof typeof ErrorType];
|
|
|
833
850
|
*/
|
|
834
851
|
const CLIENT_ONLY_ERROR_TYPES = new Set<string>([
|
|
835
852
|
ErrorType.Network,
|
|
853
|
+
ErrorType.Timeout,
|
|
836
854
|
ErrorType.Cancelled,
|
|
837
855
|
ErrorType.File,
|
|
838
856
|
ErrorType.Config,
|
|
@@ -849,12 +867,20 @@ const ERROR_CATEGORIES = {
|
|
|
849
867
|
* over the statusless ones too — those are raised locally and have no
|
|
850
868
|
* status for `isClientError`'s second arm to read, so omitting one makes it
|
|
851
869
|
* read as a server fault. The rule is the membership test: every type in
|
|
852
|
-
* `CLIENT_ONLY_ERROR_TYPES` except
|
|
853
|
-
*
|
|
870
|
+
* `CLIENT_ONLY_ERROR_TYPES` except the two `isNetworkError` owns belongs
|
|
871
|
+
* here.
|
|
854
872
|
*
|
|
855
873
|
* `Cancelled` was missing until 2026-07-29, which is exactly that failure:
|
|
856
874
|
* a caller who aborted their own deploy was told "server error: please try
|
|
857
875
|
* again" — the CLI's fallback for everything this set does not claim.
|
|
876
|
+
*
|
|
877
|
+
* `Timeout` is deliberately NOT here, and it is the sharper case, because
|
|
878
|
+
* it is the one client-only type that is not the client's fault: the
|
|
879
|
+
* caller set a ceiling, but what exhausted it was the network or the
|
|
880
|
+
* server. Reading it as client-attributable would say the caller erred,
|
|
881
|
+
* and it would silently disarm every consumer whose retry predicate
|
|
882
|
+
* declines `isClientError()` — a deadline is precisely the failure worth
|
|
883
|
+
* a second attempt.
|
|
858
884
|
*/
|
|
859
885
|
client: new Set<ErrorType>([
|
|
860
886
|
ErrorType.Business,
|
|
@@ -866,7 +892,14 @@ const ERROR_CATEGORIES = {
|
|
|
866
892
|
ErrorType.RateLimit,
|
|
867
893
|
ErrorType.Validation,
|
|
868
894
|
]),
|
|
869
|
-
|
|
895
|
+
/**
|
|
896
|
+
* The exchange never happened. Two types, one category: a refused
|
|
897
|
+
* connection and an expired deadline differ in what a surface should SAY
|
|
898
|
+
* and in nothing else a consumer decides on — both are retryable, neither
|
|
899
|
+
* carries a wire message to relay, neither is worth reporting as an
|
|
900
|
+
* incident. See `ErrorType.Timeout` for why the type is distinct anyway.
|
|
901
|
+
*/
|
|
902
|
+
network: new Set<ErrorType>([ErrorType.Network, ErrorType.Timeout]),
|
|
870
903
|
auth: new Set<ErrorType>([ErrorType.Authentication]),
|
|
871
904
|
} as const;
|
|
872
905
|
|
|
@@ -893,37 +926,72 @@ const MAX_FOREIGN_MESSAGE_LENGTH = 200;
|
|
|
893
926
|
/**
|
|
894
927
|
* Did the runtime say the exchange never completed?
|
|
895
928
|
*
|
|
896
|
-
*
|
|
897
|
-
*
|
|
898
|
-
*
|
|
899
|
-
*
|
|
900
|
-
*
|
|
901
|
-
*
|
|
902
|
-
*
|
|
903
|
-
*
|
|
904
|
-
*
|
|
905
|
-
*
|
|
906
|
-
* |
|
|
907
|
-
*
|
|
908
|
-
*
|
|
909
|
-
*
|
|
910
|
-
*
|
|
911
|
-
*
|
|
912
|
-
*
|
|
913
|
-
*
|
|
914
|
-
*
|
|
915
|
-
*
|
|
916
|
-
* The
|
|
917
|
-
*
|
|
918
|
-
*
|
|
919
|
-
*
|
|
929
|
+
* Clients branch on the TYPE, never on message strings, so a misclassified
|
|
930
|
+
* transport failure is a lie every consumer inherits — and the one that costs
|
|
931
|
+
* most: `Api` claims a server answered when nothing was exchanged, and a
|
|
932
|
+
* retrying caller will not retry it.
|
|
933
|
+
*
|
|
934
|
+
* **Every row below is a transcript, not a belief.** Captured 2026-08-12
|
|
935
|
+
* against real runtimes — Node and Bun by direct run, the three engines by a
|
|
936
|
+
* one-off playwright probe, workerd through miniflare. The capture scripts are
|
|
937
|
+
* in `tests/errors.test.ts`, "runtime failure shapes".
|
|
938
|
+
*
|
|
939
|
+
* | runtime | connection refused / DNS failure | malformed URL |
|
|
940
|
+
* |------------------|------------------------------------------------------|--------------------------------------------------|
|
|
941
|
+
* | Node 22 / undici | `TypeError: fetch failed` | `TypeError: Failed to parse URL from …` |
|
|
942
|
+
* | Bun 1.3.14 | `Error` `code:'ConnectionRefused'` | `TypeError` `code:'ERR_INVALID_URL'` |
|
|
943
|
+
* | Chromium 151 | `TypeError: Failed to fetch` | `TypeError: …Failed to parse URL from …` |
|
|
944
|
+
* | Firefox 153 | `TypeError: NetworkError when attempting to fetch …` | `TypeError: … is not a valid URL.` |
|
|
945
|
+
* | WebKit 26.5 | `TypeError: Load failed` | `TypeError: URL is not valid or contains user …` |
|
|
946
|
+
* | workerd | `Error: Network connection lost.` (DNS: `internal error; reference = …`) | `TypeError: Invalid URL: …` |
|
|
947
|
+
*
|
|
948
|
+
* Reading that table gives the rule, and it is the INVERSE of the obvious one.
|
|
949
|
+
* The transport class is unbounded — every OS, TLS and DNS failure any engine
|
|
950
|
+
* will ever name — while the class fetch raises for its own ARGUMENTS is
|
|
951
|
+
* small, and every runtime names the URL when it complains about one. So the
|
|
952
|
+
* bounded side is the one worth testing, and the residual risk points the safe
|
|
953
|
+
* way: an unrecognised sentence lands on `Network`, which says only that
|
|
954
|
+
* nothing was exchanged.
|
|
955
|
+
*
|
|
956
|
+
* That inversion is what fixes **WebKit**, whose `Load failed` carries no code
|
|
957
|
+
* and no "fetch", and which every browser-SDK and `@shipstatic/drop` user on
|
|
958
|
+
* Safari was hitting as `Api`. It also makes the six runtimes AGREE about a
|
|
959
|
+
* malformed URL, which they did not before: the previous rule tested the
|
|
960
|
+
* message for "fetch", and Chromium's and Firefox's URL complaints both
|
|
961
|
+
* contain it, so the same mistake was `Network` on three engines and `Api` on
|
|
962
|
+
* three.
|
|
963
|
+
*
|
|
964
|
+
* **workerd is the recorded gap.** It rejects with a plain `Error`, no code
|
|
965
|
+
* and no shared sentence — and its two failure modes produce two unrelated
|
|
966
|
+
* ones — so nothing here can classify it and it lands on `Api`. Left alone
|
|
967
|
+
* rather than patched with a dialect string: the one consumer running ship in
|
|
968
|
+
* that runtime (`cloudflare/mcp`) reaches the API through a service BINDING,
|
|
969
|
+
* which is in-process and does not produce transport rejections at all.
|
|
970
|
+
*
|
|
971
|
+
* The accepted trade is unchanged: a caller's `TokenProvider` that throws a
|
|
972
|
+
* coded error (`ENOENT` from a keychain read) is typed `Network` rather than
|
|
973
|
+
* `Api`. Both are wrong for it; `Network` is the cheaper wrong.
|
|
920
974
|
*/
|
|
921
975
|
function isTransportFailure(cause: Error): boolean {
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
|
|
976
|
+
const code = (cause as { code?: unknown }).code;
|
|
977
|
+
|
|
978
|
+
// Bun is the one runtime that puts a CODE on an argument error, so it is
|
|
979
|
+
// excluded before the code arm can claim it.
|
|
980
|
+
if (code === 'ERR_INVALID_URL') return false;
|
|
981
|
+
|
|
982
|
+
// A string `code` is a runtime naming a transport-level failure. An
|
|
983
|
+
// allowlist of codes was written first and rejected — the TLS row alone
|
|
984
|
+
// would mean enumerating BoringSSL's certificate table, and a code nobody
|
|
985
|
+
// guessed is precisely the bug this closes. A `DOMException`'s code is a
|
|
986
|
+
// NUMBER, so aborts and timeouts never reach here.
|
|
987
|
+
if (typeof code === 'string') return true;
|
|
988
|
+
|
|
989
|
+
// WHATWG has fetch reject with a TypeError for BOTH halves — network error
|
|
990
|
+
// and argument error — so among TypeErrors the URL is the discriminator.
|
|
991
|
+
if (cause instanceof TypeError) return !/\burl\b/i.test(cause.message);
|
|
992
|
+
|
|
993
|
+
// Anything else — an ordinary JS fault, or workerd — is not evidence.
|
|
994
|
+
return false;
|
|
927
995
|
}
|
|
928
996
|
|
|
929
997
|
/**
|
|
@@ -984,7 +1052,7 @@ export class ShipError extends Error {
|
|
|
984
1052
|
* on the client). Falls back to status-derived (401 → Authentication,
|
|
985
1053
|
* 403 → Forbidden, 429 → RateLimit, else → Api) for non-API responses
|
|
986
1054
|
* (CDN errors, intermediaries) or malformed bodies. Client-only types
|
|
987
|
-
* (`Network`, `Cancelled`, `File`, `Config`) are filtered out of the
|
|
1055
|
+
* (`Network`, `Timeout`, `Cancelled`, `File`, `Config`) are filtered out of the
|
|
988
1056
|
* trusted set — a misbehaving server claiming one of those is ignored.
|
|
989
1057
|
*
|
|
990
1058
|
* `operationName` (e.g. `"Get account"`) is used to compose the fallback
|
|
@@ -1071,12 +1139,31 @@ export class ShipError extends Error {
|
|
|
1071
1139
|
*
|
|
1072
1140
|
* Routing:
|
|
1073
1141
|
* - Already a `ShipError` → returned as-is (caller's intent preserved)
|
|
1074
|
-
* - `AbortError` → `ShipError.cancelled(...)`
|
|
1142
|
+
* - `AbortError` → `ShipError.cancelled(...)` — someone stopped it on purpose
|
|
1143
|
+
* - `TimeoutError` → `ShipError.timeout(...)` — a deadline expired; the
|
|
1144
|
+
* message names the timeout, and the type is in the network CATEGORY
|
|
1145
|
+
* because nothing was exchanged
|
|
1075
1146
|
* - A transport failure → `ShipError.network(...)` — see `isTransportFailure`
|
|
1076
1147
|
* for what each runtime offers as evidence
|
|
1077
1148
|
* - Any other `Error` → `ShipError(Api, ...)` (no HTTP status — fetch never reached the server)
|
|
1078
1149
|
* - Anything else (string, undefined, etc.) → `ShipError(Api, ...)`
|
|
1079
1150
|
*
|
|
1151
|
+
* **Abort and timeout are read from `name` BEFORE any `instanceof Error`
|
|
1152
|
+
* gate.** A `DOMException` satisfies that gate in every runtime measured
|
|
1153
|
+
* (Node, Bun, Chromium, Firefox, WebKit, workerd — all six), but the
|
|
1154
|
+
* inheritance is a comparatively recent spec change and this classification
|
|
1155
|
+
* has no reason to depend on it: `name` is where the meaning lives, and
|
|
1156
|
+
* reading it first costs nothing. The suite plants a non-`Error`
|
|
1157
|
+
* `DOMException` shape to hold the arm, since no runtime on the table
|
|
1158
|
+
* produces one.
|
|
1159
|
+
*
|
|
1160
|
+
* A caller's own `AbortSignal.timeout()` is the reachable source of
|
|
1161
|
+
* `TimeoutError` — and the two are NOT interchangeable per runtime: WebKit
|
|
1162
|
+
* reports a fired `AbortSignal.timeout()` as `AbortError`, so on Safari a
|
|
1163
|
+
* deadline is indistinguishable from a cancellation and lands on
|
|
1164
|
+
* `Cancelled`. Recorded rather than worked around; `Cancelled` is honest
|
|
1165
|
+
* there, since the caller's signal is what stopped it.
|
|
1166
|
+
*
|
|
1080
1167
|
* The optional `operationName` is composed into the message for context:
|
|
1081
1168
|
* `"Get account was cancelled"`, `"Get account failed: ..."`. Defaults to
|
|
1082
1169
|
* `"Request"` when omitted.
|
|
@@ -1086,10 +1173,23 @@ export class ShipError extends Error {
|
|
|
1086
1173
|
|
|
1087
1174
|
const op = operationName || 'Request';
|
|
1088
1175
|
|
|
1176
|
+
// Read by NAME, ahead of the Error gate — see the note above.
|
|
1177
|
+
const name = (cause as { name?: unknown } | null | undefined)?.name;
|
|
1178
|
+
if (name === 'AbortError') {
|
|
1179
|
+
return ShipError.cancelled(`${op} was cancelled`);
|
|
1180
|
+
}
|
|
1181
|
+
if (name === 'TimeoutError') {
|
|
1182
|
+
// A deadline: not a fault, not a cancellation, and — since it has its
|
|
1183
|
+
// own type — no longer merely "network". Nothing was exchanged, which
|
|
1184
|
+
// is what keeps it in the network CATEGORY and therefore retryable; the
|
|
1185
|
+
// type is what lets a surface say "timed out" instead of sending
|
|
1186
|
+
// someone to check their Wi-Fi. The runtime's own sentence is dropped
|
|
1187
|
+
// rather than relayed: "The operation was aborted due to timeout" is
|
|
1188
|
+
// the mechanism, not the news.
|
|
1189
|
+
return ShipError.timeout(`${op} timed out`, { cause });
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1089
1192
|
if (cause instanceof Error) {
|
|
1090
|
-
if (cause.name === 'AbortError') {
|
|
1091
|
-
return ShipError.cancelled(`${op} was cancelled`);
|
|
1092
|
-
}
|
|
1093
1193
|
if (isTransportFailure(cause)) {
|
|
1094
1194
|
return ShipError.network(`${op} failed: ${cause.message}`, { cause });
|
|
1095
1195
|
}
|
|
@@ -1146,6 +1246,17 @@ export class ShipError extends Error {
|
|
|
1146
1246
|
return new ShipError(ErrorType.Network, message, undefined, details);
|
|
1147
1247
|
}
|
|
1148
1248
|
|
|
1249
|
+
/**
|
|
1250
|
+
* A deadline expired before the exchange completed.
|
|
1251
|
+
*
|
|
1252
|
+
* Statusless like its four client-only siblings: no exchange completed, so
|
|
1253
|
+
* there is no HTTP status to report. `isNetworkError()` is true — see
|
|
1254
|
+
* `ErrorType.Timeout` for why the category is shared and the type is not.
|
|
1255
|
+
*/
|
|
1256
|
+
static timeout(message: string, details?: unknown): ShipError {
|
|
1257
|
+
return new ShipError(ErrorType.Timeout, message, undefined, details);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1149
1260
|
static cancelled(message: string, details?: unknown): ShipError {
|
|
1150
1261
|
return new ShipError(ErrorType.Cancelled, message, undefined, details);
|
|
1151
1262
|
}
|