@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 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 `Network` (which `isNetworkError` owns)
305
- * belongs here.
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
- network: new Set([ErrorType.Network]),
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
- * WHATWG has `fetch` reject with a **TypeError** on network error, and undici,
344
- * Chromium and Firefox comply. Bun does not: it rejects with a plain `Error`
345
- * carrying a system `code` string. Captured 2026-08-05 (the capture script is
346
- * in `tests/errors.test.ts`, "runtime failure shapes"):
347
- *
348
- * | failure | Node 22 / undici | Bun 1.3.14 |
349
- * |---------------|---------------------------|----------------------------------------------|
350
- * | refused | `TypeError: fetch failed` | `Error` `code: 'ConnectionRefused'` |
351
- * | DNS failure | `TypeError: fetch failed` | `Error` `code: 'ConnectionRefused'` |
352
- * | reset | `TypeError: fetch failed` | `Error` `code: 'ECONNRESET'` |
353
- * | TLS rejected | `TypeError: fetch failed` | `Error` `code: 'UNKNOWN_CERTIFICATE_…ERROR'` |
354
- *
355
- * So the test is the **evidence, not a list of dialect strings**: a string
356
- * `code` is a runtime naming a transport-level failure. An allowlist of codes
357
- * was written first and rejected the TLS row alone would mean enumerating
358
- * BoringSSL's certificate table, and a code nobody guessed is precisely the bug
359
- * this closes. Two kinds of error are deliberately NOT caught: ordinary JS
360
- * faults carry no `code` at all, and a `DOMException`'s is a **number**, so
361
- * aborts and timeouts fall through to their own arms.
362
- *
363
- * The accepted trade: a caller's `TokenProvider` that throws a coded error
364
- * (`ENOENT` from a keychain read) is typed `Network` rather than `Api`. Both
365
- * are wrong for it, `Network` is the cheaper wrong it says "nothing was
366
- * exchanged", which is true, where `Api` claims a server answered.
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
- if (typeof cause.code === 'string')
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
- // Spec runtimes put no code on the rejection itself. The message test is what
372
- // keeps fetch's ARGUMENT errors out `Failed to parse URL from …` is a
373
- // caller's config mistake, not a transport failure.
374
- return cause instanceof TypeError && cause.message.includes('fetch');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipstatic/types",
3
- "version": "2.7.0-beta.1",
3
+ "version": "2.7.0-beta.3",
4
4
  "description": "Shared types for ShipStatic platform",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
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 `Network` (which `isNetworkError` owns)
853
- * belongs here.
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
- network: new Set<ErrorType>([ErrorType.Network]),
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
- * WHATWG has `fetch` reject with a **TypeError** on network error, and undici,
897
- * Chromium and Firefox comply. Bun does not: it rejects with a plain `Error`
898
- * carrying a system `code` string. Captured 2026-08-05 (the capture script is
899
- * in `tests/errors.test.ts`, "runtime failure shapes"):
900
- *
901
- * | failure | Node 22 / undici | Bun 1.3.14 |
902
- * |---------------|---------------------------|----------------------------------------------|
903
- * | refused | `TypeError: fetch failed` | `Error` `code: 'ConnectionRefused'` |
904
- * | DNS failure | `TypeError: fetch failed` | `Error` `code: 'ConnectionRefused'` |
905
- * | reset | `TypeError: fetch failed` | `Error` `code: 'ECONNRESET'` |
906
- * | TLS rejected | `TypeError: fetch failed` | `Error` `code: 'UNKNOWN_CERTIFICATE_…ERROR'` |
907
- *
908
- * So the test is the **evidence, not a list of dialect strings**: a string
909
- * `code` is a runtime naming a transport-level failure. An allowlist of codes
910
- * was written first and rejected the TLS row alone would mean enumerating
911
- * BoringSSL's certificate table, and a code nobody guessed is precisely the bug
912
- * this closes. Two kinds of error are deliberately NOT caught: ordinary JS
913
- * faults carry no `code` at all, and a `DOMException`'s is a **number**, so
914
- * aborts and timeouts fall through to their own arms.
915
- *
916
- * The accepted trade: a caller's `TokenProvider` that throws a coded error
917
- * (`ENOENT` from a keychain read) is typed `Network` rather than `Api`. Both
918
- * are wrong for it, `Network` is the cheaper wrong it says "nothing was
919
- * exchanged", which is true, where `Api` claims a server answered.
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
- if (typeof (cause as { code?: unknown }).code === 'string') return true;
923
- // Spec runtimes put no code on the rejection itself. The message test is what
924
- // keeps fetch's ARGUMENT errors out `Failed to parse URL from …` is a
925
- // caller's config mistake, not a transport failure.
926
- return cause instanceof TypeError && cause.message.includes('fetch');
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
  }