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 extra 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
| 'SNAPSHOT_MODE' // snapshot-mode misuse: a write/management method, or a refresh that cannot proceed or fails
| 'RATE_LIMITED'; // export endpoint per-project rate limit exceeded (429)

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). SNAPSHOT_MODE covers misuse of a snapshot-mode client. It is raised when:

  • a write or management method is called on a snapshot-mode client — writes are not available in snapshot mode, so the call rejects with no network round-trip;
  • refreshSnapshot() cannot proceed — it is called on an 'api'-mode client, or in snapshot mode without a configured snapshotLoader;
  • a refresh fails — the loader rejects (the original error is attached as .cause), the loaded snapshot is invalid JSON or has an invalid shape, its schema version does not match, or its projectId / view differs from the active snapshot’s. In every failure path the active snapshot is kept.

RATE_LIMITED is raised when the export endpoint’s per-project rate limit is exceeded (HTTP 429); it surfaces from exportSnapshot / the content-island export CLI.

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.