From 677529377c77c2d4fbe8151cf1e9c4b55a408833 Mon Sep 17 00:00:00 2001 From: skyy Date: Wed, 26 Aug 2026 21:19:28 -0400 Subject: [PATCH 1/6] fix: split fetch network errors from unreachable server failures When fetch throws before any HTTP response, classify offline/abort as NETWORK_ERROR and unreachable API hosts while online as INTERNAL_ERROR (status 500) with diagnostic data for logging. Co-authored-by: Cursor --- src/callspec-ui/ui/routeErrorsCatalog.ts | 6 +- src/client.spec.ts | 19 ++- src/client.ts | 31 +--- .../classifyFetchFailure.spec.ts | 75 +++++++++ .../classifyFetchFailure.ts | 147 ++++++++++++++++++ src/clientErrorNormalization/index.ts | 3 + src/clientTypes.ts | 12 +- src/content/docs/builtin-errors.md | 4 +- src/content/docs/client-usage.md | 4 + 9 files changed, 264 insertions(+), 37 deletions(-) create mode 100644 src/clientErrorNormalization/classifyFetchFailure.spec.ts create mode 100644 src/clientErrorNormalization/classifyFetchFailure.ts diff --git a/src/callspec-ui/ui/routeErrorsCatalog.ts b/src/callspec-ui/ui/routeErrorsCatalog.ts index 8e6f33ff..308edc05 100644 --- a/src/callspec-ui/ui/routeErrorsCatalog.ts +++ b/src/callspec-ui/ui/routeErrorsCatalog.ts @@ -132,7 +132,7 @@ function frameworkErrors(auth: RouteAuth, routeName: string): CatalogRouteError[ code: BUILTIN_ERROR.INTERNAL_ERROR, status: 500, kind: 'framework', - summary: 'Unhandled throw or rejected promise in the handler', + summary: 'Unhandled throw in the handler, or client could not reach the API host while the device appears online', schema: wireErrorSchema(BUILTIN_ERROR.INTERNAL_ERROR), example: {error: BUILTIN_ERROR.INTERNAL_ERROR}, }, @@ -182,7 +182,7 @@ function clientOnlyErrors(): CatalogRouteError[] { code: CLIENT_ERROR.NETWORK_ERROR, status: 0, kind: 'client', - summary: 'fetch failed before any HTTP response (DNS, offline, abort, …)', + summary: 'Device offline or request aborted before any HTTP response', clientOnly: true, schema: { type: 'object', @@ -205,7 +205,7 @@ function clientOnlyErrors(): CatalogRouteError[] { ok: false, status: 0, code: CLIENT_ERROR.NETWORK_ERROR, - data: {message: 'Failed to fetch', name: 'TypeError'}, + data: {message: 'Network unavailable', name: 'TypeError'}, }, }, { diff --git a/src/client.spec.ts b/src/client.spec.ts index 6e55c594..982e88ee 100644 --- a/src/client.spec.ts +++ b/src/client.spec.ts @@ -420,9 +420,15 @@ test('CallspecClient.callResult maps 502 HTML to SERVICE_UNAVAILABLE', async (as }); -test('CallspecClient.callResult maps fetch network failures to NETWORK_ERROR', async (assert) => { +test('CallspecClient.callResult maps unreachable server fetch failures to INTERNAL_ERROR', async (assert) => { const originalFetch = globalThis.fetch; + const originalNavigator = globalThis.navigator; + + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: {onLine: true}, + }); globalThis.fetch = (async () => { throw new TypeError('Failed to fetch'); @@ -437,10 +443,10 @@ test('CallspecClient.callResult maps fetch network failures to NETWORK_ERROR', a if (!result.ok) { - assert.equal(result.status, 0); - assert.equal(result.code, CLIENT_ERROR.NETWORK_ERROR); + assert.equal(result.status, 500); + assert.equal(result.code, BUILTIN_ERROR.INTERNAL_ERROR); - if (result.code === CLIENT_ERROR.NETWORK_ERROR) { + if (result.code === BUILTIN_ERROR.INTERNAL_ERROR && result.data) { assert.equal(result.data.message, 'Failed to fetch'); assert.equal(result.data.name, 'TypeError'); @@ -453,6 +459,11 @@ test('CallspecClient.callResult maps fetch network failures to NETWORK_ERROR', a globalThis.fetch = originalFetch; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: originalNavigator, + }); + } }); diff --git a/src/client.ts b/src/client.ts index 78481bc8..683de2df 100644 --- a/src/client.ts +++ b/src/client.ts @@ -22,40 +22,20 @@ export type { export { CLIENT_ERROR, + classifyFetchFailure, normalizeClientErrorBody, resolveRouteClientError, } from './clientErrorNormalization'; -export type {ResolveRouteClientErrorInput} from './clientErrorNormalization'; +export type {ClassifiedFetchFailure, ResolveRouteClientErrorInput} from './clientErrorNormalization'; import type { CallspecOk, - CallspecNetworkClientError, CallspecResult, CallspecRouteResult, CallResultOptions, } from './clientTypes'; -import {CLIENT_ERROR, resolveRouteClientError} from './clientErrorNormalization'; - -function networkClientError(err: unknown): CallspecNetworkClientError { - - if (err instanceof Error) { - - return { - code: CLIENT_ERROR.NETWORK_ERROR, - data: { - message: err.message, - ...(err.name ? {name: err.name} : {}), - }, - }; - - } - - return { - code: CLIENT_ERROR.NETWORK_ERROR, - data: {message: String(err)}, - }; - -} +import {classifyFetchFailure} from './clientErrorNormalization/classifyFetchFailure'; +import {resolveRouteClientError} from './clientErrorNormalization'; export function isCallspecOk(result: CallspecResult): result is CallspecOk { @@ -240,8 +220,7 @@ export class CallspecClient { return { ok: false as const, - status: 0, - ...networkClientError(err), + ...classifyFetchFailure(err), }; } diff --git a/src/clientErrorNormalization/classifyFetchFailure.spec.ts b/src/clientErrorNormalization/classifyFetchFailure.spec.ts new file mode 100644 index 00000000..ddef5fc6 --- /dev/null +++ b/src/clientErrorNormalization/classifyFetchFailure.spec.ts @@ -0,0 +1,75 @@ +import {test} from 'kizu'; +import {BUILTIN_ERROR, CLIENT_ERROR} from '../client'; +import {classifyFetchFailure} from './classifyFetchFailure'; + +test('classifyFetchFailure: offline and online Failed to fetch', (assert) => { + + const originalNavigator = globalThis.navigator; + + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: {onLine: false}, + }); + + try { + + const offline = classifyFetchFailure(new TypeError('Failed to fetch')); + + assert.equal(offline.status, 0); + assert.equal(offline.code, CLIENT_ERROR.NETWORK_ERROR); + + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: {onLine: true}, + }); + + const online = classifyFetchFailure(new TypeError('Failed to fetch')); + + assert.equal(online.status, 500); + assert.equal(online.code, BUILTIN_ERROR.INTERNAL_ERROR); + + } finally { + + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: originalNavigator, + }); + + } + +}); + +test('classifyFetchFailure: ECONNREFUSED maps to INTERNAL_ERROR', (assert) => { + + const err = new Error('fetch failed') as Error & {cause: {code: string}}; + + err.cause = {code: 'ECONNREFUSED'}; + + const result = classifyFetchFailure(err); + + assert.equal(result.status, 500); + assert.equal(result.code, BUILTIN_ERROR.INTERNAL_ERROR); + +}); + +test('classifyFetchFailure: connection refused message maps to INTERNAL_ERROR', (assert) => { + + const result = classifyFetchFailure(new Error('connect ECONNREFUSED 127.0.0.1:3000')); + + assert.equal(result.status, 500); + assert.equal(result.code, BUILTIN_ERROR.INTERNAL_ERROR); + +}); + +test('classifyFetchFailure: AbortError maps to NETWORK_ERROR', (assert) => { + + const err = new Error('The user aborted a request.'); + + err.name = 'AbortError'; + + const result = classifyFetchFailure(err); + + assert.equal(result.status, 0); + assert.equal(result.code, CLIENT_ERROR.NETWORK_ERROR); + +}); diff --git a/src/clientErrorNormalization/classifyFetchFailure.ts b/src/clientErrorNormalization/classifyFetchFailure.ts new file mode 100644 index 00000000..ffa2d12f --- /dev/null +++ b/src/clientErrorNormalization/classifyFetchFailure.ts @@ -0,0 +1,147 @@ +import {BUILTIN_ERROR} from '../builtinErrors'; +import type {CallspecNetworkClientError} from '../clientTypes'; +import {CLIENT_ERROR} from './types'; + +const SERVER_UNREACHABLE_CODES = new Set([ + 'ECONNREFUSED', + 'ECONNRESET', + 'EPIPE', + 'ETIMEDOUT', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'ENOTFOUND', +]); + +const SERVER_UNREACHABLE_MESSAGE = /econnrefused|connection refused|connect econnrefused|socket hang up|econnreset|ehostunreach|enetunreach|getaddrinfo enotfound|network unreachable/i; + +const FETCH_TRANSPORT_MESSAGE = /failed to fetch|load failed|network error when attempting to fetch/i; + +export type ClassifiedFetchFailure = + | ({status: 0} & CallspecNetworkClientError) + | { + status: 500 + code: typeof BUILTIN_ERROR.INTERNAL_ERROR + data: { + message: string + name?: string + } + }; + +function fetchErrorMessage(err: unknown): string { + + if (err instanceof Error) return err.message; + + return String(err); + +} + +function fetchErrorName(err: unknown): string | undefined { + + return err instanceof Error ? err.name : undefined; + +} + +function systemErrorCode(err: unknown): string | undefined { + + if (typeof err !== 'object' || err === null) return undefined; + + const direct = (err as {code?: unknown}).code; + + if (typeof direct === 'string') return direct; + + const cause = (err as {cause?: unknown}).cause; + + if (typeof cause === 'object' && cause !== null) { + + const nested = (cause as {code?: unknown}).code; + + if (typeof nested === 'string') return nested; + + } + + return undefined; + +} + +function isBrowserOffline(): boolean { + + return typeof navigator !== 'undefined' && navigator.onLine === false; + +} + +function networkClientError(err: unknown): CallspecNetworkClientError { + + const message = fetchErrorMessage(err); + const name = fetchErrorName(err); + + return { + code: CLIENT_ERROR.NETWORK_ERROR, + data: { + message, + ...(name ? {name} : {}), + }, + }; + +} + +function serverUnreachableClientError(err: unknown): Extract { + + const message = fetchErrorMessage(err); + const name = fetchErrorName(err); + + return { + status: 500, + code: BUILTIN_ERROR.INTERNAL_ERROR, + data: { + message, + ...(name ? {name} : {}), + }, + }; + +} + +function looksLikeServerUnreachable(err: unknown): boolean { + + const sysCode = systemErrorCode(err); + + if (sysCode && SERVER_UNREACHABLE_CODES.has(sysCode)) return true; + + return SERVER_UNREACHABLE_MESSAGE.test(fetchErrorMessage(err)); + +} + +function looksLikeTransportFailureWhileOnline(err: unknown): boolean { + + const name = fetchErrorName(err); + const message = fetchErrorMessage(err); + + if (name === 'TypeError' && FETCH_TRANSPORT_MESSAGE.test(message)) return true; + + return false; + +} + +/** Classify a thrown `fetch` error before any HTTP response is received. */ +export function classifyFetchFailure(err: unknown): ClassifiedFetchFailure { + + if (isBrowserOffline()) { + + return { + status: 0, + ...networkClientError(err), + }; + + } + + if (looksLikeServerUnreachable(err) || looksLikeTransportFailureWhileOnline(err)) { + + return serverUnreachableClientError(err); + + } + + return { + status: 0, + ...networkClientError(err), + }; + +} diff --git a/src/clientErrorNormalization/index.ts b/src/clientErrorNormalization/index.ts index a7d684c6..8be68441 100644 --- a/src/clientErrorNormalization/index.ts +++ b/src/clientErrorNormalization/index.ts @@ -1,4 +1,7 @@ export {CLIENT_ERROR} from './types'; export type {ResolveRouteClientErrorInput} from './types'; +export {classifyFetchFailure} from './classifyFetchFailure'; +export type {ClassifiedFetchFailure} from './classifyFetchFailure'; + export {normalizeClientErrorBody, resolveRouteClientError} from './resolveRouteClientError'; diff --git a/src/clientTypes.ts b/src/clientTypes.ts index c54fc3af..ecf49605 100644 --- a/src/clientTypes.ts +++ b/src/clientTypes.ts @@ -12,6 +12,14 @@ type CallspecValidationClientError = { data: Record }; +type CallspecInternalClientError = { + code: typeof BUILTIN_ERROR.INTERNAL_ERROR + data?: { + message: string + name?: string + } +}; + export type TooManyRequestsContext = { title?: string message?: string @@ -30,7 +38,7 @@ type CallspecRouteNotFoundClientError = { export type CallspecBuiltinClientError = | CallspecValidationClientError | {code: typeof BUILTIN_ERROR.UNAUTHORIZED} - | {code: typeof BUILTIN_ERROR.INTERNAL_ERROR} + | CallspecInternalClientError | CallspecRouteNotFoundClientError | {code: typeof BUILTIN_ERROR.NOT_FOUND, data?: OptionalBuiltinContext} | {code: typeof BUILTIN_ERROR.FORBIDDEN, data?: OptionalBuiltinContext} @@ -46,7 +54,7 @@ export type CallspecUnknownClientError = { } }; -/** Client-only — fetch never got an HTTP response (DNS, offline, abort, etc.). `status` is `0`. */ +/** Client-only — browser/device has no network path (offline, aborted request). `status` is `0`. */ export type CallspecNetworkClientError = { code: 'NETWORK_ERROR' data: { diff --git a/src/content/docs/builtin-errors.md b/src/content/docs/builtin-errors.md index 349e1eb4..ea008074 100644 --- a/src/content/docs/builtin-errors.md +++ b/src/content/docs/builtin-errors.md @@ -34,7 +34,7 @@ You usually do **not** return these from handlers. The framework puts them on th | `VALIDATION_ERROR` | 400 | Input fails the route `input` pred — `data` is field → message map | | `UNAUTHORIZED` | 401 | Bearer/auth required and missing or invalid | | `ROUTE_NOT_FOUND` | 404 | RPC method path not in the mounted spec — `data.route` | -| `INTERNAL_ERROR` | 500 | Unhandled throw / rejected promise in the handler (or anything not mapped by `handleUnhandledError`) | +| `INTERNAL_ERROR` | 500 | Unhandled throw / rejected promise in the handler (or anything not mapped by `handleUnhandledError`). The client may also synthesize this when `fetch` fails while the device appears online and the API host cannot be reached. | Bare `throw new Error(…)` → `INTERNAL_ERROR`. Expected failures should **`return err.*`**. @@ -44,7 +44,7 @@ Always in every generated `*Result` union. You cannot `return` these from a serv | Code | `status` | When | `data` | |------|----------|------|--------| -| `NETWORK_ERROR` | `0` | `fetch` failed before any HTTP response (DNS, offline, abort, …) | `{ message, name? }` from the thrown `Error` when available | +| `NETWORK_ERROR` | `0` | Device appears offline, or the request was aborted before a response | `{ message, name? }` from the thrown `Error` when available | | `UNKNOWN_ERROR` | HTTP status of the response | Response outside the route contract (proxy HTML, undeclared `{ error }`, invalid domain payload, …) | `{ body, headers? }` — **debug only; do not show to end users** | Typical client pattern (handle what you care about + shared default): [Client usage](./client-usage.md). diff --git a/src/content/docs/client-usage.md b/src/content/docs/client-usage.md index 99d442f8..25008345 100644 --- a/src/content/docs/client-usage.md +++ b/src/content/docs/client-usage.md @@ -68,6 +68,10 @@ export function handleFailure(result: Failed): void { case 'NETWORK_ERROR': toast.error('Check your connection and try again'); return; + case 'INTERNAL_ERROR': + console.error(result.data); + toast.error('Something went wrong on our end'); + return; case 'UNKNOWN_ERROR': console.error(result.data); // operators / devtools — do not show to users toast.error('Something went wrong'); From d38fb220f2081bb936895ad59bfd54cd7e2b17b5 Mon Sep 17 00:00:00 2001 From: skyy Date: Wed, 26 Aug 2026 21:21:40 -0400 Subject: [PATCH 2/6] fix: map unreachable API host to SERVICE_UNAVAILABLE not INTERNAL_ERROR Connection refused and online Failed to fetch are service reachability failures (503), not unhandled handler bugs (500). Co-authored-by: Cursor --- src/callspec-ui/ui/routeErrorsCatalog.ts | 4 ++-- src/client.spec.ts | 10 +++++----- .../classifyFetchFailure.spec.ts | 16 ++++++++-------- .../classifyFetchFailure.ts | 14 +++++++------- src/clientTypes.ts | 10 +--------- src/content/docs/builtin-errors.md | 4 ++-- src/content/docs/client-usage.md | 4 ---- 7 files changed, 25 insertions(+), 37 deletions(-) diff --git a/src/callspec-ui/ui/routeErrorsCatalog.ts b/src/callspec-ui/ui/routeErrorsCatalog.ts index 308edc05..db5b31c8 100644 --- a/src/callspec-ui/ui/routeErrorsCatalog.ts +++ b/src/callspec-ui/ui/routeErrorsCatalog.ts @@ -23,7 +23,7 @@ const HANDLER_SUMMARIES: Record = { [BUILTIN_ERROR.NOT_FOUND]: 'Resource missing — return from handler', [BUILTIN_ERROR.FORBIDDEN]: 'Authenticated but not allowed', [BUILTIN_ERROR.TOO_MANY_REQUESTS]: 'Rate limit or quota exceeded', - [BUILTIN_ERROR.SERVICE_UNAVAILABLE]: 'Dependency down — try again later', + [BUILTIN_ERROR.SERVICE_UNAVAILABLE]: 'Dependency down — try again later; client may synthesize when the API host is unreachable', }; function wireErrorSchema(code: string, dataSchema?: unknown, dataRequired?: boolean): unknown { @@ -132,7 +132,7 @@ function frameworkErrors(auth: RouteAuth, routeName: string): CatalogRouteError[ code: BUILTIN_ERROR.INTERNAL_ERROR, status: 500, kind: 'framework', - summary: 'Unhandled throw in the handler, or client could not reach the API host while the device appears online', + summary: 'Unhandled throw or rejected promise in the handler', schema: wireErrorSchema(BUILTIN_ERROR.INTERNAL_ERROR), example: {error: BUILTIN_ERROR.INTERNAL_ERROR}, }, diff --git a/src/client.spec.ts b/src/client.spec.ts index 982e88ee..35830bc5 100644 --- a/src/client.spec.ts +++ b/src/client.spec.ts @@ -420,7 +420,7 @@ test('CallspecClient.callResult maps 502 HTML to SERVICE_UNAVAILABLE', async (as }); -test('CallspecClient.callResult maps unreachable server fetch failures to INTERNAL_ERROR', async (assert) => { +test('CallspecClient.callResult maps unreachable server fetch failures to SERVICE_UNAVAILABLE', async (assert) => { const originalFetch = globalThis.fetch; const originalNavigator = globalThis.navigator; @@ -443,13 +443,13 @@ test('CallspecClient.callResult maps unreachable server fetch failures to INTERN if (!result.ok) { - assert.equal(result.status, 500); - assert.equal(result.code, BUILTIN_ERROR.INTERNAL_ERROR); + assert.equal(result.status, 503); + assert.equal(result.code, BUILTIN_ERROR.SERVICE_UNAVAILABLE); - if (result.code === BUILTIN_ERROR.INTERNAL_ERROR && result.data) { + if (result.code === BUILTIN_ERROR.SERVICE_UNAVAILABLE && result.data) { assert.equal(result.data.message, 'Failed to fetch'); - assert.equal(result.data.name, 'TypeError'); + assert.equal(result.data.description, 'TypeError'); } diff --git a/src/clientErrorNormalization/classifyFetchFailure.spec.ts b/src/clientErrorNormalization/classifyFetchFailure.spec.ts index ddef5fc6..34a8537d 100644 --- a/src/clientErrorNormalization/classifyFetchFailure.spec.ts +++ b/src/clientErrorNormalization/classifyFetchFailure.spec.ts @@ -25,8 +25,8 @@ test('classifyFetchFailure: offline and online Failed to fetch', (assert) => { const online = classifyFetchFailure(new TypeError('Failed to fetch')); - assert.equal(online.status, 500); - assert.equal(online.code, BUILTIN_ERROR.INTERNAL_ERROR); + assert.equal(online.status, 503); + assert.equal(online.code, BUILTIN_ERROR.SERVICE_UNAVAILABLE); } finally { @@ -39,7 +39,7 @@ test('classifyFetchFailure: offline and online Failed to fetch', (assert) => { }); -test('classifyFetchFailure: ECONNREFUSED maps to INTERNAL_ERROR', (assert) => { +test('classifyFetchFailure: ECONNREFUSED maps to SERVICE_UNAVAILABLE', (assert) => { const err = new Error('fetch failed') as Error & {cause: {code: string}}; @@ -47,17 +47,17 @@ test('classifyFetchFailure: ECONNREFUSED maps to INTERNAL_ERROR', (assert) => { const result = classifyFetchFailure(err); - assert.equal(result.status, 500); - assert.equal(result.code, BUILTIN_ERROR.INTERNAL_ERROR); + assert.equal(result.status, 503); + assert.equal(result.code, BUILTIN_ERROR.SERVICE_UNAVAILABLE); }); -test('classifyFetchFailure: connection refused message maps to INTERNAL_ERROR', (assert) => { +test('classifyFetchFailure: connection refused message maps to SERVICE_UNAVAILABLE', (assert) => { const result = classifyFetchFailure(new Error('connect ECONNREFUSED 127.0.0.1:3000')); - assert.equal(result.status, 500); - assert.equal(result.code, BUILTIN_ERROR.INTERNAL_ERROR); + assert.equal(result.status, 503); + assert.equal(result.code, BUILTIN_ERROR.SERVICE_UNAVAILABLE); }); diff --git a/src/clientErrorNormalization/classifyFetchFailure.ts b/src/clientErrorNormalization/classifyFetchFailure.ts index ffa2d12f..9fd28a5d 100644 --- a/src/clientErrorNormalization/classifyFetchFailure.ts +++ b/src/clientErrorNormalization/classifyFetchFailure.ts @@ -19,11 +19,11 @@ const FETCH_TRANSPORT_MESSAGE = /failed to fetch|load failed|network error when export type ClassifiedFetchFailure = | ({status: 0} & CallspecNetworkClientError) | { - status: 500 - code: typeof BUILTIN_ERROR.INTERNAL_ERROR + status: 503 + code: typeof BUILTIN_ERROR.SERVICE_UNAVAILABLE data: { message: string - name?: string + description?: string } }; @@ -84,17 +84,17 @@ function networkClientError(err: unknown): CallspecNetworkClientError { } -function serverUnreachableClientError(err: unknown): Extract { +function serverUnreachableClientError(err: unknown): Extract { const message = fetchErrorMessage(err); const name = fetchErrorName(err); return { - status: 500, - code: BUILTIN_ERROR.INTERNAL_ERROR, + status: 503, + code: BUILTIN_ERROR.SERVICE_UNAVAILABLE, data: { message, - ...(name ? {name} : {}), + ...(name ? {description: name} : {}), }, }; diff --git a/src/clientTypes.ts b/src/clientTypes.ts index ecf49605..cddfa642 100644 --- a/src/clientTypes.ts +++ b/src/clientTypes.ts @@ -12,14 +12,6 @@ type CallspecValidationClientError = { data: Record }; -type CallspecInternalClientError = { - code: typeof BUILTIN_ERROR.INTERNAL_ERROR - data?: { - message: string - name?: string - } -}; - export type TooManyRequestsContext = { title?: string message?: string @@ -38,7 +30,7 @@ type CallspecRouteNotFoundClientError = { export type CallspecBuiltinClientError = | CallspecValidationClientError | {code: typeof BUILTIN_ERROR.UNAUTHORIZED} - | CallspecInternalClientError + | {code: typeof BUILTIN_ERROR.INTERNAL_ERROR} | CallspecRouteNotFoundClientError | {code: typeof BUILTIN_ERROR.NOT_FOUND, data?: OptionalBuiltinContext} | {code: typeof BUILTIN_ERROR.FORBIDDEN, data?: OptionalBuiltinContext} diff --git a/src/content/docs/builtin-errors.md b/src/content/docs/builtin-errors.md index ea008074..8d49c027 100644 --- a/src/content/docs/builtin-errors.md +++ b/src/content/docs/builtin-errors.md @@ -13,7 +13,7 @@ Use `import {err} from 'callspec'` (or your `defineErrors` handle — builti | `NOT_FOUND` | 404 | Resource missing | `message?`, `description?` | | `FORBIDDEN` | 403 | Authenticated but not allowed | `message?`, `description?` | | `TOO_MANY_REQUESTS` | 429 | Rate limit / quota | `title?`, `message?` | -| `SERVICE_UNAVAILABLE` | 503 | Dependency down, try later | `message?`, `description?` | +| `SERVICE_UNAVAILABLE` | 503 | Dependency down, try later; the client may also synthesize this when `fetch` fails while the device appears online and the API host cannot be reached | `message?`, `description?` | State conflicts (duplicate key, version mismatch) are **domain** errors — declare them with `defineErrors` and your own HTTP status (often 409). @@ -34,7 +34,7 @@ You usually do **not** return these from handlers. The framework puts them on th | `VALIDATION_ERROR` | 400 | Input fails the route `input` pred — `data` is field → message map | | `UNAUTHORIZED` | 401 | Bearer/auth required and missing or invalid | | `ROUTE_NOT_FOUND` | 404 | RPC method path not in the mounted spec — `data.route` | -| `INTERNAL_ERROR` | 500 | Unhandled throw / rejected promise in the handler (or anything not mapped by `handleUnhandledError`). The client may also synthesize this when `fetch` fails while the device appears online and the API host cannot be reached. | +| `INTERNAL_ERROR` | 500 | Unhandled throw / rejected promise in the handler (or anything not mapped by `handleUnhandledError`) | Bare `throw new Error(…)` → `INTERNAL_ERROR`. Expected failures should **`return err.*`**. diff --git a/src/content/docs/client-usage.md b/src/content/docs/client-usage.md index 25008345..99d442f8 100644 --- a/src/content/docs/client-usage.md +++ b/src/content/docs/client-usage.md @@ -68,10 +68,6 @@ export function handleFailure(result: Failed): void { case 'NETWORK_ERROR': toast.error('Check your connection and try again'); return; - case 'INTERNAL_ERROR': - console.error(result.data); - toast.error('Something went wrong on our end'); - return; case 'UNKNOWN_ERROR': console.error(result.data); // operators / devtools — do not show to users toast.error('Something went wrong'); From 8a18b2a1bcf396ca42cf8aa6a7eea790f2c0b733 Mon Sep 17 00:00:00 2001 From: skyy Date: Wed, 26 Aug 2026 21:29:25 -0400 Subject: [PATCH 3/6] docs: replace synthesize jargon in SERVICE_UNAVAILABLE copy Co-authored-by: Cursor --- src/callspec-ui/ui/routeErrorsCatalog.ts | 2 +- src/content/docs/builtin-errors.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/callspec-ui/ui/routeErrorsCatalog.ts b/src/callspec-ui/ui/routeErrorsCatalog.ts index db5b31c8..b38a1a31 100644 --- a/src/callspec-ui/ui/routeErrorsCatalog.ts +++ b/src/callspec-ui/ui/routeErrorsCatalog.ts @@ -23,7 +23,7 @@ const HANDLER_SUMMARIES: Record = { [BUILTIN_ERROR.NOT_FOUND]: 'Resource missing — return from handler', [BUILTIN_ERROR.FORBIDDEN]: 'Authenticated but not allowed', [BUILTIN_ERROR.TOO_MANY_REQUESTS]: 'Rate limit or quota exceeded', - [BUILTIN_ERROR.SERVICE_UNAVAILABLE]: 'Dependency down — try again later; client may synthesize when the API host is unreachable', + [BUILTIN_ERROR.SERVICE_UNAVAILABLE]: 'Service down or unreachable — from your handler, or from the browser client when it cannot connect to the API', }; function wireErrorSchema(code: string, dataSchema?: unknown, dataRequired?: boolean): unknown { diff --git a/src/content/docs/builtin-errors.md b/src/content/docs/builtin-errors.md index 8d49c027..7c4269c0 100644 --- a/src/content/docs/builtin-errors.md +++ b/src/content/docs/builtin-errors.md @@ -13,7 +13,7 @@ Use `import {err} from 'callspec'` (or your `defineErrors` handle — builti | `NOT_FOUND` | 404 | Resource missing | `message?`, `description?` | | `FORBIDDEN` | 403 | Authenticated but not allowed | `message?`, `description?` | | `TOO_MANY_REQUESTS` | 429 | Rate limit / quota | `title?`, `message?` | -| `SERVICE_UNAVAILABLE` | 503 | Dependency down, try later; the client may also synthesize this when `fetch` fails while the device appears online and the API host cannot be reached | `message?`, `description?` | +| `SERVICE_UNAVAILABLE` | 503 | Service down or unreachable — return from a handler, or produced by the browser client when the request never connects (API stopped, connection refused, etc.) while the device is still online | `message?`, `description?` | State conflicts (duplicate key, version mismatch) are **domain** errors — declare them with `defineErrors` and your own HTTP status (often 409). From cc48462efd9ed1e7d4c13799eb978b8c230501c5 Mon Sep 17 00:00:00 2001 From: skyy Date: Wed, 26 Aug 2026 21:31:44 -0400 Subject: [PATCH 4/6] fix: simplify fetch failure classification to two rules Inline offline vs online handling in CallspecClient instead of a separate classifyFetchFailure module with regex heuristics. Co-authored-by: Cursor --- src/callspec-ui/ui/routeErrorsCatalog.ts | 2 +- src/client.spec.ts | 48 +++--- src/client.ts | 44 +++++- .../classifyFetchFailure.spec.ts | 75 --------- .../classifyFetchFailure.ts | 147 ------------------ src/clientErrorNormalization/index.ts | 3 - src/content/docs/builtin-errors.md | 2 +- 7 files changed, 71 insertions(+), 250 deletions(-) delete mode 100644 src/clientErrorNormalization/classifyFetchFailure.spec.ts delete mode 100644 src/clientErrorNormalization/classifyFetchFailure.ts diff --git a/src/callspec-ui/ui/routeErrorsCatalog.ts b/src/callspec-ui/ui/routeErrorsCatalog.ts index b38a1a31..e213a58e 100644 --- a/src/callspec-ui/ui/routeErrorsCatalog.ts +++ b/src/callspec-ui/ui/routeErrorsCatalog.ts @@ -23,7 +23,7 @@ const HANDLER_SUMMARIES: Record = { [BUILTIN_ERROR.NOT_FOUND]: 'Resource missing — return from handler', [BUILTIN_ERROR.FORBIDDEN]: 'Authenticated but not allowed', [BUILTIN_ERROR.TOO_MANY_REQUESTS]: 'Rate limit or quota exceeded', - [BUILTIN_ERROR.SERVICE_UNAVAILABLE]: 'Service down or unreachable — from your handler, or from the browser client when it cannot connect to the API', + [BUILTIN_ERROR.SERVICE_UNAVAILABLE]: 'Service down or could not connect to the API', }; function wireErrorSchema(code: string, dataSchema?: unknown, dataRequired?: boolean): unknown { diff --git a/src/client.spec.ts b/src/client.spec.ts index 35830bc5..4094be87 100644 --- a/src/client.spec.ts +++ b/src/client.spec.ts @@ -420,45 +420,55 @@ test('CallspecClient.callResult maps 502 HTML to SERVICE_UNAVAILABLE', async (as }); -test('CallspecClient.callResult maps unreachable server fetch failures to SERVICE_UNAVAILABLE', async (assert) => { +test('CallspecClient.callResult classifies fetch throws before any HTTP response', async (assert) => { - const originalFetch = globalThis.fetch; const originalNavigator = globalThis.navigator; + const throwingFetch = (async () => { + throw new TypeError('Failed to fetch'); + }) as typeof fetch; Object.defineProperty(globalThis, 'navigator', { configurable: true, - value: {onLine: true}, + value: {onLine: false}, }); - globalThis.fetch = (async () => { - throw new TypeError('Failed to fetch'); - }) as typeof fetch; - try { - const runtime = new CallspecClient({baseUrl: 'https://api.test/v1'}); - const result = await runtime.callResult('healthcheck', {}); + const offline = await new CallspecClient({ + baseUrl: 'https://api.test/v1', + fetch: throwingFetch, + }).callResult('healthcheck', {}); - assert.equal(result.ok, false); + assert.equal(offline.ok, false); - if (!result.ok) { + if (!offline.ok) { - assert.equal(result.status, 503); - assert.equal(result.code, BUILTIN_ERROR.SERVICE_UNAVAILABLE); + assert.equal(offline.status, 0); + assert.equal(offline.code, CLIENT_ERROR.NETWORK_ERROR); - if (result.code === BUILTIN_ERROR.SERVICE_UNAVAILABLE && result.data) { + } - assert.equal(result.data.message, 'Failed to fetch'); - assert.equal(result.data.description, 'TypeError'); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: {onLine: true}, + }); - } + const online = await new CallspecClient({ + baseUrl: 'https://api.test/v1', + fetch: throwingFetch, + }).callResult('healthcheck', {}); + + assert.equal(online.ok, false); + + if (!online.ok) { + + assert.equal(online.status, 503); + assert.equal(online.code, BUILTIN_ERROR.SERVICE_UNAVAILABLE); } } finally { - globalThis.fetch = originalFetch; - Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator, diff --git a/src/client.ts b/src/client.ts index 683de2df..62167c9a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,4 +1,5 @@ import {deserializeWithPred} from './serializer'; +import {BUILTIN_ERROR} from './builtinErrors'; export type { BuiltinErrorCode, @@ -22,11 +23,12 @@ export type { export { CLIENT_ERROR, - classifyFetchFailure, normalizeClientErrorBody, resolveRouteClientError, } from './clientErrorNormalization'; -export type {ClassifiedFetchFailure, ResolveRouteClientErrorInput} from './clientErrorNormalization'; +export type {ResolveRouteClientErrorInput} from './clientErrorNormalization'; + +import {CLIENT_ERROR, resolveRouteClientError} from './clientErrorNormalization'; import type { CallspecOk, @@ -34,8 +36,6 @@ import type { CallspecRouteResult, CallResultOptions, } from './clientTypes'; -import {classifyFetchFailure} from './clientErrorNormalization/classifyFetchFailure'; -import {resolveRouteClientError} from './clientErrorNormalization'; export function isCallspecOk(result: CallspecResult): result is CallspecOk { @@ -184,6 +184,42 @@ async function parseResponseBody( } +/** When `fetch` throws before any HTTP response: offline → NETWORK_ERROR; otherwise → SERVICE_UNAVAILABLE. */ +function classifyFetchFailure(err: unknown): { + status: number + code: typeof CLIENT_ERROR.NETWORK_ERROR | typeof BUILTIN_ERROR.SERVICE_UNAVAILABLE + data: {message: string, name?: string} | {message: string, description?: string} +} { + + const message = err instanceof Error ? err.message : String(err); + const name = err instanceof Error ? err.name : undefined; + const offline = typeof navigator !== 'undefined' && navigator.onLine === false; + const aborted = name === 'AbortError'; + + if (offline || aborted) { + + return { + status: 0, + code: CLIENT_ERROR.NETWORK_ERROR, + data: { + message, + ...(name ? {name} : {}), + }, + }; + + } + + return { + status: 503, + code: BUILTIN_ERROR.SERVICE_UNAVAILABLE, + data: { + message, + ...(name ? {description: name} : {}), + }, + }; + +} + export class CallspecClient { private readonly fetchImpl: typeof globalThis.fetch; diff --git a/src/clientErrorNormalization/classifyFetchFailure.spec.ts b/src/clientErrorNormalization/classifyFetchFailure.spec.ts deleted file mode 100644 index 34a8537d..00000000 --- a/src/clientErrorNormalization/classifyFetchFailure.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import {test} from 'kizu'; -import {BUILTIN_ERROR, CLIENT_ERROR} from '../client'; -import {classifyFetchFailure} from './classifyFetchFailure'; - -test('classifyFetchFailure: offline and online Failed to fetch', (assert) => { - - const originalNavigator = globalThis.navigator; - - Object.defineProperty(globalThis, 'navigator', { - configurable: true, - value: {onLine: false}, - }); - - try { - - const offline = classifyFetchFailure(new TypeError('Failed to fetch')); - - assert.equal(offline.status, 0); - assert.equal(offline.code, CLIENT_ERROR.NETWORK_ERROR); - - Object.defineProperty(globalThis, 'navigator', { - configurable: true, - value: {onLine: true}, - }); - - const online = classifyFetchFailure(new TypeError('Failed to fetch')); - - assert.equal(online.status, 503); - assert.equal(online.code, BUILTIN_ERROR.SERVICE_UNAVAILABLE); - - } finally { - - Object.defineProperty(globalThis, 'navigator', { - configurable: true, - value: originalNavigator, - }); - - } - -}); - -test('classifyFetchFailure: ECONNREFUSED maps to SERVICE_UNAVAILABLE', (assert) => { - - const err = new Error('fetch failed') as Error & {cause: {code: string}}; - - err.cause = {code: 'ECONNREFUSED'}; - - const result = classifyFetchFailure(err); - - assert.equal(result.status, 503); - assert.equal(result.code, BUILTIN_ERROR.SERVICE_UNAVAILABLE); - -}); - -test('classifyFetchFailure: connection refused message maps to SERVICE_UNAVAILABLE', (assert) => { - - const result = classifyFetchFailure(new Error('connect ECONNREFUSED 127.0.0.1:3000')); - - assert.equal(result.status, 503); - assert.equal(result.code, BUILTIN_ERROR.SERVICE_UNAVAILABLE); - -}); - -test('classifyFetchFailure: AbortError maps to NETWORK_ERROR', (assert) => { - - const err = new Error('The user aborted a request.'); - - err.name = 'AbortError'; - - const result = classifyFetchFailure(err); - - assert.equal(result.status, 0); - assert.equal(result.code, CLIENT_ERROR.NETWORK_ERROR); - -}); diff --git a/src/clientErrorNormalization/classifyFetchFailure.ts b/src/clientErrorNormalization/classifyFetchFailure.ts deleted file mode 100644 index 9fd28a5d..00000000 --- a/src/clientErrorNormalization/classifyFetchFailure.ts +++ /dev/null @@ -1,147 +0,0 @@ -import {BUILTIN_ERROR} from '../builtinErrors'; -import type {CallspecNetworkClientError} from '../clientTypes'; -import {CLIENT_ERROR} from './types'; - -const SERVER_UNREACHABLE_CODES = new Set([ - 'ECONNREFUSED', - 'ECONNRESET', - 'EPIPE', - 'ETIMEDOUT', - 'EHOSTUNREACH', - 'ENETUNREACH', - 'ENOTFOUND', -]); - -const SERVER_UNREACHABLE_MESSAGE = /econnrefused|connection refused|connect econnrefused|socket hang up|econnreset|ehostunreach|enetunreach|getaddrinfo enotfound|network unreachable/i; - -const FETCH_TRANSPORT_MESSAGE = /failed to fetch|load failed|network error when attempting to fetch/i; - -export type ClassifiedFetchFailure = - | ({status: 0} & CallspecNetworkClientError) - | { - status: 503 - code: typeof BUILTIN_ERROR.SERVICE_UNAVAILABLE - data: { - message: string - description?: string - } - }; - -function fetchErrorMessage(err: unknown): string { - - if (err instanceof Error) return err.message; - - return String(err); - -} - -function fetchErrorName(err: unknown): string | undefined { - - return err instanceof Error ? err.name : undefined; - -} - -function systemErrorCode(err: unknown): string | undefined { - - if (typeof err !== 'object' || err === null) return undefined; - - const direct = (err as {code?: unknown}).code; - - if (typeof direct === 'string') return direct; - - const cause = (err as {cause?: unknown}).cause; - - if (typeof cause === 'object' && cause !== null) { - - const nested = (cause as {code?: unknown}).code; - - if (typeof nested === 'string') return nested; - - } - - return undefined; - -} - -function isBrowserOffline(): boolean { - - return typeof navigator !== 'undefined' && navigator.onLine === false; - -} - -function networkClientError(err: unknown): CallspecNetworkClientError { - - const message = fetchErrorMessage(err); - const name = fetchErrorName(err); - - return { - code: CLIENT_ERROR.NETWORK_ERROR, - data: { - message, - ...(name ? {name} : {}), - }, - }; - -} - -function serverUnreachableClientError(err: unknown): Extract { - - const message = fetchErrorMessage(err); - const name = fetchErrorName(err); - - return { - status: 503, - code: BUILTIN_ERROR.SERVICE_UNAVAILABLE, - data: { - message, - ...(name ? {description: name} : {}), - }, - }; - -} - -function looksLikeServerUnreachable(err: unknown): boolean { - - const sysCode = systemErrorCode(err); - - if (sysCode && SERVER_UNREACHABLE_CODES.has(sysCode)) return true; - - return SERVER_UNREACHABLE_MESSAGE.test(fetchErrorMessage(err)); - -} - -function looksLikeTransportFailureWhileOnline(err: unknown): boolean { - - const name = fetchErrorName(err); - const message = fetchErrorMessage(err); - - if (name === 'TypeError' && FETCH_TRANSPORT_MESSAGE.test(message)) return true; - - return false; - -} - -/** Classify a thrown `fetch` error before any HTTP response is received. */ -export function classifyFetchFailure(err: unknown): ClassifiedFetchFailure { - - if (isBrowserOffline()) { - - return { - status: 0, - ...networkClientError(err), - }; - - } - - if (looksLikeServerUnreachable(err) || looksLikeTransportFailureWhileOnline(err)) { - - return serverUnreachableClientError(err); - - } - - return { - status: 0, - ...networkClientError(err), - }; - -} diff --git a/src/clientErrorNormalization/index.ts b/src/clientErrorNormalization/index.ts index 8be68441..a7d684c6 100644 --- a/src/clientErrorNormalization/index.ts +++ b/src/clientErrorNormalization/index.ts @@ -1,7 +1,4 @@ export {CLIENT_ERROR} from './types'; export type {ResolveRouteClientErrorInput} from './types'; -export {classifyFetchFailure} from './classifyFetchFailure'; -export type {ClassifiedFetchFailure} from './classifyFetchFailure'; - export {normalizeClientErrorBody, resolveRouteClientError} from './resolveRouteClientError'; diff --git a/src/content/docs/builtin-errors.md b/src/content/docs/builtin-errors.md index 7c4269c0..ba43396b 100644 --- a/src/content/docs/builtin-errors.md +++ b/src/content/docs/builtin-errors.md @@ -13,7 +13,7 @@ Use `import {err} from 'callspec'` (or your `defineErrors` handle — builti | `NOT_FOUND` | 404 | Resource missing | `message?`, `description?` | | `FORBIDDEN` | 403 | Authenticated but not allowed | `message?`, `description?` | | `TOO_MANY_REQUESTS` | 429 | Rate limit / quota | `title?`, `message?` | -| `SERVICE_UNAVAILABLE` | 503 | Service down or unreachable — return from a handler, or produced by the browser client when the request never connects (API stopped, connection refused, etc.) while the device is still online | `message?`, `description?` | +| `SERVICE_UNAVAILABLE` | 503 | Service down or could not connect (handler return, or browser client when online and the request never connected) | `message?`, `description?` | State conflicts (duplicate key, version mismatch) are **domain** errors — declare them with `defineErrors` and your own HTTP status (often 409). From 33d1814d0c4ca693a615c2c2370e7a4e798b7712 Mon Sep 17 00:00:00 2001 From: skyy Date: Wed, 26 Aug 2026 21:32:05 -0400 Subject: [PATCH 5/6] docs: shorten SERVICE_UNAVAILABLE copy to match other builtins Co-authored-by: Cursor --- src/callspec-ui/ui/routeErrorsCatalog.ts | 2 +- src/content/docs/builtin-errors.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/callspec-ui/ui/routeErrorsCatalog.ts b/src/callspec-ui/ui/routeErrorsCatalog.ts index e213a58e..07897067 100644 --- a/src/callspec-ui/ui/routeErrorsCatalog.ts +++ b/src/callspec-ui/ui/routeErrorsCatalog.ts @@ -23,7 +23,7 @@ const HANDLER_SUMMARIES: Record = { [BUILTIN_ERROR.NOT_FOUND]: 'Resource missing — return from handler', [BUILTIN_ERROR.FORBIDDEN]: 'Authenticated but not allowed', [BUILTIN_ERROR.TOO_MANY_REQUESTS]: 'Rate limit or quota exceeded', - [BUILTIN_ERROR.SERVICE_UNAVAILABLE]: 'Service down or could not connect to the API', + [BUILTIN_ERROR.SERVICE_UNAVAILABLE]: 'Service is unreachable', }; function wireErrorSchema(code: string, dataSchema?: unknown, dataRequired?: boolean): unknown { diff --git a/src/content/docs/builtin-errors.md b/src/content/docs/builtin-errors.md index ba43396b..3674825c 100644 --- a/src/content/docs/builtin-errors.md +++ b/src/content/docs/builtin-errors.md @@ -13,7 +13,7 @@ Use `import {err} from 'callspec'` (or your `defineErrors` handle — builti | `NOT_FOUND` | 404 | Resource missing | `message?`, `description?` | | `FORBIDDEN` | 403 | Authenticated but not allowed | `message?`, `description?` | | `TOO_MANY_REQUESTS` | 429 | Rate limit / quota | `title?`, `message?` | -| `SERVICE_UNAVAILABLE` | 503 | Service down or could not connect (handler return, or browser client when online and the request never connected) | `message?`, `description?` | +| `SERVICE_UNAVAILABLE` | 503 | Service is unreachable | `message?`, `description?` | State conflicts (duplicate key, version mismatch) are **domain** errors — declare them with `defineErrors` and your own HTTP status (often 409). From 9c3db2ad7e318b20d516fe2845f5ce7f77f8d783 Mon Sep 17 00:00:00 2001 From: skyy Date: Wed, 26 Aug 2026 21:34:33 -0400 Subject: [PATCH 6/6] fix: inject isOnline on CallspecClient instead of mocking navigator Tests pass isOnline like fetch; production default still reads navigator.onLine. Co-authored-by: Cursor --- src/client.spec.ts | 58 +++++++++++++++------------------------------- src/client.ts | 16 ++++++++++--- 2 files changed, 32 insertions(+), 42 deletions(-) diff --git a/src/client.spec.ts b/src/client.spec.ts index 4094be87..f6220b0e 100644 --- a/src/client.spec.ts +++ b/src/client.spec.ts @@ -422,57 +422,37 @@ test('CallspecClient.callResult maps 502 HTML to SERVICE_UNAVAILABLE', async (as test('CallspecClient.callResult classifies fetch throws before any HTTP response', async (assert) => { - const originalNavigator = globalThis.navigator; const throwingFetch = (async () => { throw new TypeError('Failed to fetch'); }) as typeof fetch; - Object.defineProperty(globalThis, 'navigator', { - configurable: true, - value: {onLine: false}, - }); + const offline = await new CallspecClient({ + baseUrl: 'https://api.test/v1', + fetch: throwingFetch, + isOnline: (): boolean => false, + }).callResult('healthcheck', {}); - try { - - const offline = await new CallspecClient({ - baseUrl: 'https://api.test/v1', - fetch: throwingFetch, - }).callResult('healthcheck', {}); - - assert.equal(offline.ok, false); + assert.equal(offline.ok, false); - if (!offline.ok) { + if (!offline.ok) { - assert.equal(offline.status, 0); - assert.equal(offline.code, CLIENT_ERROR.NETWORK_ERROR); - - } - - Object.defineProperty(globalThis, 'navigator', { - configurable: true, - value: {onLine: true}, - }); + assert.equal(offline.status, 0); + assert.equal(offline.code, CLIENT_ERROR.NETWORK_ERROR); - const online = await new CallspecClient({ - baseUrl: 'https://api.test/v1', - fetch: throwingFetch, - }).callResult('healthcheck', {}); - - assert.equal(online.ok, false); + } - if (!online.ok) { + const online = await new CallspecClient({ + baseUrl: 'https://api.test/v1', + fetch: throwingFetch, + isOnline: (): boolean => true, + }).callResult('healthcheck', {}); - assert.equal(online.status, 503); - assert.equal(online.code, BUILTIN_ERROR.SERVICE_UNAVAILABLE); + assert.equal(online.ok, false); - } + if (!online.ok) { - } finally { - - Object.defineProperty(globalThis, 'navigator', { - configurable: true, - value: originalNavigator, - }); + assert.equal(online.status, 503); + assert.equal(online.code, BUILTIN_ERROR.SERVICE_UNAVAILABLE); } diff --git a/src/client.ts b/src/client.ts index 62167c9a..e2eb2832 100644 --- a/src/client.ts +++ b/src/client.ts @@ -79,6 +79,8 @@ export type CallspecClientConfig = { headers?: HeadersInit | (() => HeadersInit | Promise) fetch?: typeof globalThis.fetch fetchOptions?: Omit + /** Defaults to `navigator.onLine !== false` when `navigator` exists. */ + isOnline?: () => boolean }; async function resolveHeaders( @@ -184,8 +186,14 @@ async function parseResponseBody( } +function defaultIsOnline(): boolean { + + return typeof navigator === 'undefined' || navigator.onLine !== false; + +} + /** When `fetch` throws before any HTTP response: offline → NETWORK_ERROR; otherwise → SERVICE_UNAVAILABLE. */ -function classifyFetchFailure(err: unknown): { +function classifyFetchFailure(err: unknown, isOnline: () => boolean): { status: number code: typeof CLIENT_ERROR.NETWORK_ERROR | typeof BUILTIN_ERROR.SERVICE_UNAVAILABLE data: {message: string, name?: string} | {message: string, description?: string} @@ -193,7 +201,7 @@ function classifyFetchFailure(err: unknown): { const message = err instanceof Error ? err.message : String(err); const name = err instanceof Error ? err.name : undefined; - const offline = typeof navigator !== 'undefined' && navigator.onLine === false; + const offline = !isOnline(); const aborted = name === 'AbortError'; if (offline || aborted) { @@ -223,10 +231,12 @@ function classifyFetchFailure(err: unknown): { export class CallspecClient { private readonly fetchImpl: typeof globalThis.fetch; + private readonly isOnline: () => boolean; constructor(private readonly config: CallspecClientConfig) { this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis); + this.isOnline = config.isOnline ?? defaultIsOnline; } @@ -256,7 +266,7 @@ export class CallspecClient { return { ok: false as const, - ...classifyFetchFailure(err), + ...classifyFetchFailure(err, this.isOnline), }; }