diff --git a/src/callspec-ui/ui/routeErrorsCatalog.ts b/src/callspec-ui/ui/routeErrorsCatalog.ts index 8e6f33ff..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]: 'Dependency down — try again later', + [BUILTIN_ERROR.SERVICE_UNAVAILABLE]: 'Service is unreachable', }; function wireErrorSchema(code: string, dataSchema?: unknown, dataRequired?: boolean): unknown { @@ -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..f6220b0e 100644 --- a/src/client.spec.ts +++ b/src/client.spec.ts @@ -420,38 +420,39 @@ 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 classifies fetch throws before any HTTP response', async (assert) => { - const originalFetch = globalThis.fetch; - - globalThis.fetch = (async () => { + const throwingFetch = (async () => { throw new TypeError('Failed to fetch'); }) as typeof fetch; - try { + const offline = await new CallspecClient({ + baseUrl: 'https://api.test/v1', + fetch: throwingFetch, + isOnline: (): boolean => false, + }).callResult('healthcheck', {}); - const runtime = new CallspecClient({baseUrl: 'https://api.test/v1'}); - const result = await runtime.callResult('healthcheck', {}); + assert.equal(offline.ok, false); - assert.equal(result.ok, false); - - if (!result.ok) { + if (!offline.ok) { - assert.equal(result.status, 0); - assert.equal(result.code, CLIENT_ERROR.NETWORK_ERROR); + assert.equal(offline.status, 0); + assert.equal(offline.code, CLIENT_ERROR.NETWORK_ERROR); - if (result.code === CLIENT_ERROR.NETWORK_ERROR) { - - assert.equal(result.data.message, 'Failed to fetch'); - assert.equal(result.data.name, 'TypeError'); + } - } + const online = await new CallspecClient({ + baseUrl: 'https://api.test/v1', + fetch: throwingFetch, + isOnline: (): boolean => true, + }).callResult('healthcheck', {}); - } + assert.equal(online.ok, false); - } finally { + if (!online.ok) { - globalThis.fetch = originalFetch; + assert.equal(online.status, 503); + assert.equal(online.code, BUILTIN_ERROR.SERVICE_UNAVAILABLE); } diff --git a/src/client.ts b/src/client.ts index 78481bc8..e2eb2832 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, @@ -27,35 +28,14 @@ export { } from './clientErrorNormalization'; export type {ResolveRouteClientErrorInput} from './clientErrorNormalization'; +import {CLIENT_ERROR, resolveRouteClientError} 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)}, - }; - -} export function isCallspecOk(result: CallspecResult): result is CallspecOk { @@ -99,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( @@ -204,13 +186,57 @@ 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, isOnline: () => boolean): { + 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 = !isOnline(); + 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; + private readonly isOnline: () => boolean; constructor(private readonly config: CallspecClientConfig) { this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis); + this.isOnline = config.isOnline ?? defaultIsOnline; } @@ -240,8 +266,7 @@ export class CallspecClient { return { ok: false as const, - status: 0, - ...networkClientError(err), + ...classifyFetchFailure(err, this.isOnline), }; } diff --git a/src/clientTypes.ts b/src/clientTypes.ts index c54fc3af..cddfa642 100644 --- a/src/clientTypes.ts +++ b/src/clientTypes.ts @@ -46,7 +46,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..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 | Dependency down, try later | `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). @@ -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).