Skip to content

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 classify

The 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).

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 HTTPfrom error.codefrom body
Non-contract body on a known HTTP status (e.g. nginx 502 HTML, empty body)from HTTPderived 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 statusfrom HTTPUNKNOWN
Network failure (fetch rejected — DNS, TLS, offline)0NETWORK_ERRORoriginal 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-ing error.message. The message is now plain readable text.
  • Use isApiClientError rather than instanceof Error. It narrows the type and is safe across realms.
  • Branch on .code, not on string contents of the message.
  • Use .requestId when reporting an issue — it correlates with our server logs.
  • Handle NETWORK_ERROR explicitly if you care about offline / DNS / TLS failures. Previously these threw a raw TypeError from fetch; now they surface as ApiClientError(status: 0, code: 'NETWORK_ERROR') with the original error preserved on .cause.