Error handling
The TypeScript SDK throws a typed GeoperaError for every non-2xx API response and lets native fetch failures propagate unchanged, so you can always tell what went wrong: the server rejected your request, or the request never reached the server at all.
How errors surface
Every call you make — through the recommended client.<resource>.<action>(body) surface, through the low-level client.invoke(...) escape hatch, or through the functional callOperation(...) helper — ends in the same request path. They all POST to /v1/op/{operation_id}, parse the JSON response, and apply one rule:
- If the response status is 2xx, the parsed body is returned.
- If the response status is non-2xx, a
GeoperaErroris thrown carrying the status and the parsed problem body. - If no response is produced at all (the connection drops, DNS fails, the request is aborted), the SDK does not intervene — whatever
fetchrejects with bubbles straight out.
This is deliberate. The SDK never swallows a network error and never repackages an aborted request as an API error, so a single try/catch lets you branch cleanly on the failure category.
| Failure | What you catch | When it happens |
|---|---|---|
| The API returned a non-2xx status | GeoperaError | The request reached the server and it responded with a 4xx/5xx problem+json body. |
| The request was cancelled | A native AbortError (a DOMException named "AbortError") | The AbortSignal you passed fired — a timeout you wired up, or a manual cancellation. |
| The request never reached the server | A native fetch error (usually a TypeError) | DNS/TLS/connection failure, an offline device, or no global fetch in the runtime. |
A GeoperaError is thrown only when a response was received and res.ok is false. Everything that prevents a response rejects with whatever the underlying fetch throws — the SDK does not wrap it.
GeoperaError
GeoperaError extends the built-in Error and adds three read-only fields:
export class GeoperaError extends Error {
constructor(
public readonly status: number,
public readonly problem: unknown,
public readonly operation: string
) {
super(`Geopera operation '${operation}' failed (HTTP ${status})`);
this.name = 'GeoperaError';
}
}| Member | Type | Description |
|---|---|---|
status | number | The HTTP status code of the response (e.g. 401, 404, 422, 429, 500). |
problem | unknown | The parsed JSON response body — an RFC 9457 problem+json document when the API returned one. Typed unknown; narrow it before use. |
operation | string | The operation_id that was invoked (e.g. "catalog.search"). This is the same id whether you reached the operation through the namespaced surface, invoke, or callOperation. |
message | string | Geopera operation '{operation}' failed (HTTP {status}) — safe to log, but prefer status + problem for programmatic decisions. |
name | string | Always "GeoperaError". |
GeoperaError is exported from the package root alongside the client, so you import both together:
import { GeoperaClient, GeoperaError } from '@geopera/sdk';The problem body follows the platform-wide error contract. See Errors for the full schema and the catalogue of type values; this page does not repeat it.
Branching on error type
catch binds err as unknown, so the instanceof checks below double as TypeScript type guards: inside the GeoperaError branch, err.status, err.problem, and err.operation are all available without a cast. Test GeoperaError first, then aborts, then everything else.
The recommended surface is the typed client.<resource>.<action>(body) form. It throws exactly the same errors as invoke, because it delegates to it:
import { GeoperaClient, GeoperaError } from '@geopera/sdk';
const client = new GeoperaClient({ token: process.env.GEOPERA_TOKEN! });
try {
const out = await client.catalog.search({
collections: ['sentinel-2-l2a'],
limit: 10
});
console.log(out);
} catch (err) {
if (err instanceof GeoperaError) {
// The API responded with a non-2xx status.
console.error(`${err.operation} -> HTTP ${err.status}`);
console.error('problem:', err.problem);
} else if (err instanceof DOMException && err.name === 'AbortError') {
// The request was aborted (timeout or manual cancellation).
console.error('request aborted');
} else {
// DNS/TLS/connection failure, or some other unexpected error.
console.error('network or unexpected error:', err);
}
}Note the order of the branches. AbortError is not a GeoperaError, so it falls through the first check; if you only catch GeoperaError you will let aborts and network failures escape unhandled — which is sometimes what you want, but should be a deliberate choice.
Inspecting the problem body
problem is typed unknown by design, because the SDK does not assume the failing endpoint returned a well-formed problem document (a proxy or load balancer can return HTML, or an empty body). Narrow it before reading fields:
interface ProblemDetails {
type: string;
title: string;
status: number;
detail?: string;
instance?: string;
}
function isProblem(value: unknown): value is ProblemDetails {
return typeof value === 'object' && value !== null && 'type' in value && 'title' in value;
}
try {
await client.catalog.get_item({ id: 'does-not-exist' });
} catch (err) {
if (err instanceof GeoperaError && isProblem(err.problem)) {
console.error(err.problem.title); // e.g. "Item not found"
console.error(err.problem.detail); // human-readable explanation
} else if (err instanceof GeoperaError) {
// Non-2xx response, but the body was not a problem document.
console.error(`HTTP ${err.status}, non-standard body:`, err.problem);
}
}Validation failures (HTTP 422) typically carry a problem body with field-level detail; treat any extra members beyond the five standard ones as operation-specific and read them only after narrowing. For the type URI catalogue (authentication, scope, validation, rate-limit, and not-found problems), see Errors.
Reacting to specific statuses
Because status is plain HTTP, you can route recovery logic off it. A few common cases:
try {
return await client.catalog.search({ collections: ['sentinel-2-l2a'] });
} catch (err) {
if (err instanceof GeoperaError) {
switch (err.status) {
case 401:
case 403:
// Token missing, expired, or lacks the required scope.
throw new Error('Check your gpra_ token and its scopes.');
case 404:
return null; // Treat "not found" as an empty result for your domain.
case 422:
// Request body failed validation — inspect err.problem for details.
throw err;
case 429:
// Rate limited — back off and retry.
throw err;
default:
throw err; // 5xx and anything else.
}
}
throw err; // Aborts and network failures are not GeoperaErrors.
}Authentication failures (401/403) usually mean a missing or expired token or an insufficient scope — see Authentication and Scopes. For 429 and retry guidance, see Rate limits.
Timeouts and cancellation
Both the namespaced methods and invoke accept a second options argument with a signal, which is passed straight through to fetch. When the signal fires, the request rejects with an AbortError — not a GeoperaError, because no response was ever received:
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5_000);
try {
const out = await client.catalog.search(
{ collections: ['sentinel-2-l2a'] },
{ signal: controller.signal }
);
return out;
} catch (err) {
if (err instanceof GeoperaError) {
// Server responded with an error before the deadline.
console.error('API error:', err.status);
} else {
// Includes the AbortError thrown when the 5s deadline elapsed.
console.error('aborted or network failure:', err);
}
throw err;
} finally {
clearTimeout(timeout);
}This is the key gotcha: a timeout is an aborted request, so it surfaces as AbortError, and your instanceof GeoperaError branch will (correctly) not catch it. If you want timeouts to retry but API errors to fail fast, branch on the abort case explicitly.
Errors from the low-level surfaces
The structured surface is recommended, but two escape hatches exist for dynamic ids and one-shot calls. Both raise the identical error model, so any handler you write works across all three.
invoke
client.invoke(operationId, body, options?) is the dynamic escape hatch — useful when the operation_id is computed at runtime. It throws GeoperaError on a non-2xx response and lets native fetch errors propagate, exactly like the namespaced methods that delegate to it:
try {
const out = await client.invoke('catalog.search', {
collections: ['sentinel-2-l2a'],
limit: 10
});
console.log(out);
} catch (err) {
if (err instanceof GeoperaError) {
console.error(err.operation, err.status, err.problem);
} else {
throw err;
}
}callOperation
The functional helper callOperation(operationId, body, token, options?) constructs a one-shot client internally, so it throws the same errors as invoke — GeoperaError on a non-2xx response, native fetch errors otherwise:
import { callOperation, GeoperaError } from '@geopera/sdk';
try {
const out = await callOperation(
'catalog.search',
{ collections: ['sentinel-2-l2a'], limit: 5 },
process.env.GEOPERA_TOKEN!
);
console.log(out);
} catch (err) {
if (err instanceof GeoperaError) {
console.error(err.status, err.problem);
} else {
throw err;
}
}Worked example: a resilient wrapper
A small wrapper that normalizes failures into a discriminated result, distinguishing API problems, aborts, and everything else. It is fully typed against the operation registry, so body and the returned value are inferred from the operation_id:
import { GeoperaClient, GeoperaError } from '@geopera/sdk';
import type { OperationId, OperationInput, OperationOutput } from '@geopera/sdk';
type Result<T> =
| { ok: true; value: T }
| { ok: false; kind: 'api'; status: number; problem: unknown }
| { ok: false; kind: 'aborted' }
| { ok: false; kind: 'network'; error: unknown };
async function safeInvoke<K extends OperationId>(
client: GeoperaClient,
op: K,
body: OperationInput<K>,
signal?: AbortSignal
): Promise<Result<OperationOutput<K>>> {
try {
const value = await client.invoke(op, body, { signal });
return { ok: true, value };
} catch (err) {
if (err instanceof GeoperaError) {
return { ok: false, kind: 'api', status: err.status, problem: err.problem };
}
if (err instanceof DOMException && err.name === 'AbortError') {
return { ok: false, kind: 'aborted' };
}
return { ok: false, kind: 'network', error: err };
}
}
const client = new GeoperaClient({ token: process.env.GEOPERA_TOKEN! });
const result = await safeInvoke(client, 'catalog.search', {
collections: ['sentinel-2-l2a'],
limit: 10
});
if (result.ok) {
console.log(result.value);
} else if (result.kind === 'api') {
console.error(`HTTP ${result.status}`, result.problem);
} else if (result.kind === 'aborted') {
console.error('request timed out or was cancelled');
} else {
console.error('network failure', result.error);
}This pattern uses invoke so the wrapper stays generic over any OperationId. For non-generic code, prefer the namespaced client.catalog.search(...) form and let these same instanceof checks classify the failure.
See also
- Errors — the platform-wide problem+json contract and
typecatalogue. - Authentication — resolving
401/403failures. - Scopes — why an operation may be rejected with
403. - Rate limits — handling
429responses.