<!-- Source: https://docs.geopera.com/api-reference/sdks/typescript/errors · Markdown for LLMs -->

# 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 `GeoperaError` is 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 `fetch` rejects 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:

```typescript
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](/api-reference/errors) 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:

```typescript
import { GeoperaClient, GeoperaError } from '@geopera/sdk';
```

The `problem` body follows the platform-wide error contract. See [Errors](/api-reference/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:

```typescript
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:

```typescript
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](/api-reference/errors).

## Reacting to specific statuses

Because `status` is plain HTTP, you can route recovery logic off it. A few common cases:

```typescript
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](/api-reference/authentication) and [Scopes](/api-reference/scopes). For `429` and retry guidance, see [Rate limits](/api-reference/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:

```typescript
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:

```typescript
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:

```typescript
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`:

```typescript
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](/api-reference/errors) — the platform-wide problem+json contract and `type` catalogue.
- [Authentication](/api-reference/authentication) — resolving `401`/`403` failures.
- [Scopes](/api-reference/scopes) — why an operation may be rejected with `403`.
- [Rate limits](/api-reference/rate-limits) — handling `429` responses.
