Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/callspec-ui/ui/routeErrorsCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const HANDLER_SUMMARIES: Record<string, string> = {
[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 {
Expand Down Expand Up @@ -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',
Expand All @@ -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'},
},
},
{
Expand Down
41 changes: 21 additions & 20 deletions src/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

}

Expand Down
75 changes: 50 additions & 25 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {deserializeWithPred} from './serializer';
import {BUILTIN_ERROR} from './builtinErrors';

export type {
BuiltinErrorCode,
Expand Down Expand Up @@ -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<T, E>(result: CallspecResult<T, E>): result is CallspecOk<T> {

Expand Down Expand Up @@ -99,6 +79,8 @@ export type CallspecClientConfig = {
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>)
fetch?: typeof globalThis.fetch
fetchOptions?: Omit<RequestInit, 'method' | 'body' | 'headers'>
/** Defaults to `navigator.onLine !== false` when `navigator` exists. */
isOnline?: () => boolean
};

async function resolveHeaders(
Expand Down Expand Up @@ -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;

}

Expand Down Expand Up @@ -240,8 +266,7 @@ export class CallspecClient {

return {
ok: false as const,
status: 0,
...networkClientError(err),
...classifyFetchFailure(err, this.isOnline),
};

}
Expand Down
2 changes: 1 addition & 1 deletion src/clientTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
4 changes: 2 additions & 2 deletions src/content/docs/builtin-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Use `import {err} from 'callspec'` (or your `defineErrors` handle &mdash; 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 &mdash; declare them with `defineErrors` and your own HTTP status (often 409).

Expand Down Expand Up @@ -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? }` &mdash; **debug only; do not show to end users** |

Typical client pattern (handle what you care about + shared default): [Client usage](./client-usage.md).
Expand Down
Loading