Handling errors
Every method on the client (getContent, getContentList, etc.) throws an ApiClientError when the underlying request fails. The error is a typed Error subclass that mirrors the REST API error contract and adds two synthesised codes for client-side failures.
ApiClientError
import { ApiClientError, isApiClientError } from '@content-island/api-client';
class ApiClientError extends Error { readonly status: number; // HTTP status (0 when the request never reached the server) readonly code: ApiClientErrorCode; readonly requestId?: string; // populated when the server returned a contract error body readonly details?: Record<string, unknown>; // .message is the human-readable text from the server (or the synthesised fallback) // .cause is set when wrapping a network failure (TypeError from fetch)}
const isApiClientError: (error: unknown) => error is ApiClientError;ApiClientErrorCode
type ApiClientErrorCode = | 'VALIDATION_ERROR' // 400 | 'UNAUTHORIZED' // 401 | 'FORBIDDEN' // 403 | 'NOT_FOUND' // 404 | 'CONFLICT' // 409 | 'INTERNAL_ERROR' // 5xx | 'NETWORK_ERROR' // request never reached the server (status: 0) | 'UNKNOWN'; // non-contract response on a status the client cannot classifyThe first six come straight from the REST API contract. NETWORK_ERROR and UNKNOWN are synthesised by the client when the server response can’t be parsed (see Graceful fallback).
Recommended pattern
import { isApiClientError } from '@content-island/api-client';
try { const post = await client.getContent<Post>({ id: 'abc123' });} catch (error) { if (!isApiClientError(error)) { throw error; } switch (error.code) { case 'NOT_FOUND': // render an empty state break; case 'UNAUTHORIZED': // refresh / rotate the access token break; case 'VALIDATION_ERROR': { const fields = error.details?.fields as | Array<{ field: string; message: string }> | undefined; // surface fields to the user break; } case 'NETWORK_ERROR': // retry / show offline UI break; default: // log and report — include error.requestId so support can trace the call console.error(`[${error.code}] ${error.message}`, error.requestId); }}Graceful fallback
The client guarantees that any failure surfaces as an ApiClientError, even when the server response does not follow the contract.
| Source of the failure | .status | .code | .requestId | .cause |
|---|---|---|---|---|
| Contract error body (the normal case) | from HTTP | from error.code | from body | — |
Non-contract body on a known HTTP status (e.g. nginx 502 HTML, empty body) | from HTTP | derived from HTTP — 400→VALIDATION_ERROR, 401→UNAUTHORIZED, 403→FORBIDDEN, 404→NOT_FOUND, 409→CONFLICT, 422→VALIDATION_ERROR, 5xx→INTERNAL_ERROR | — | — |
| Non-contract body on an unmapped HTTP status | from HTTP | UNKNOWN | — | — |
Network failure (fetch rejected — DNS, TLS, offline) | 0 | NETWORK_ERROR | — | original Error |
In the synthesised cases .message falls back to the HTTP statusText (or Network request failed for fetch rejections), so it’s still safe to log.
Migration from v0.20.x
@content-island/api-client@0.21.0 introduces the typed error. This is a breaking change — methods used to throw a plain Error whose message was a JSON-stringified payload, and now throw a typed ApiClientError.
Before (v0.20.x)
try { await client.getContent({ id: 'abc' });} catch (error) { // error was a plain Error with a JSON-stringified message const parsed = JSON.parse((error as Error).message); parsed.status; // 404 parsed.statusText; // "Not Found" parsed.body; // raw response body}After (v0.21.0)
import { isApiClientError } from '@content-island/api-client';
try { await client.getContent({ id: 'abc' });} catch (error) { if (isApiClientError(error)) { error.status; // 404 error.code; // 'NOT_FOUND' error.message; // readable, server-provided error.requestId; // matches the server log error.details; // { fields: [...] } on VALIDATION_ERROR }}What changes in practice:
- Stop
JSON.parse-ingerror.message. The message is now plain readable text. - Use
isApiClientErrorrather thaninstanceof Error. It narrows the type and is safe across realms. - Branch on
.code, not on string contents of the message. - Use
.requestIdwhen reporting an issue — it correlates with our server logs. - Handle
NETWORK_ERRORexplicitly if you care about offline / DNS / TLS failures. Previously these threw a rawTypeErrorfromfetch; now they surface asApiClientError(status: 0, code: 'NETWORK_ERROR')with the original error preserved on.cause.