---
title: "Error Handling"
description: "Catch and inspect API errors, configure retries, and use idempotency keys in the PgBeam TypeScript SDK."
canonical: "https://pgbeam.com/docs/ts-sdk/error-handling"
last-updated: "2026-10-29T19:01:20.000Z"
---

# Error Handling

> Catch and inspect API errors, configure retries, and use idempotency keys in the PgBeam TypeScript SDK.

URL: https://pgbeam.com/docs/ts-sdk/error-handling

All non-2xx responses throw an `ApiError`:

```typescript
import { ApiError, extractMessage } from "pgbeam";

try {
  await api.projects.deleteProject({
    pathParams: { project_id: "prj_xxx" },
  });
} catch (err) {
  if (err instanceof ApiError) {
    console.error(err.status); // 404
    console.error(err.statusText); // "Not Found"

    const msg = extractMessage(err.body);
    console.error(msg); // "Project not found"
  }
}
```

## ApiError properties

Every error the API returns is an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) problem document, and `ApiError` lifts its fields onto itself so you do not have to reach into `body`.

| Property     | Type                           | Description                                                 |
| ------------ | ------------------------------ | ----------------------------------------------------------- |
| `status`     | `number`                       | HTTP status code                                            |
| `statusText` | `string`                       | HTTP status text                                            |
| `code`       | `string \| undefined`          | Stable identity of the condition, e.g. `PLAN_LIMIT_REACHED` |
| `type`       | `string \| undefined`          | The same identity as a URI, resolving to the error catalog  |
| `title`      | `string \| undefined`          | Short summary of the condition                              |
| `detail`     | `string \| undefined`          | Explanation of this occurrence. Same value as `message`     |
| `instance`   | `string \| undefined`          | Path of the request that produced the error                 |
| `requestId`  | `string \| undefined`          | Correlation id, also in the `X-Request-Id` header           |
| `errors`     | `ApiFieldError[] \| undefined` | Field-level detail when the request failed validation       |
| `message`    | `string`                       | `detail`, falling back to the status text                   |
| `body`       | `unknown`                      | Parsed response body                                        |

## Branch on `code`, not on `message`

`code` is stable across wording changes and separates conditions that share a status. A `403` is either a permissions problem or a billing one, and those are resolved in completely different ways.

```typescript
try {
  await api.projects.createProject({ name: "analytics" });
} catch (err) {
  if (err instanceof ApiError) {
    switch (err.code) {
      case "PLAN_LIMIT_REACHED":
        return promptUpgrade(err.detail);
      case "FORBIDDEN":
        return askAnAdminForAccess();
      case "INVALID_INPUT":
        return err.errors?.forEach((e) => markField(e.field, e.detail));
    }
  }
  throw err;
}
```

## extractMessage helper

Pulls the human-readable message out of an error body: the problem document's `detail`, falling back to the older `{ error: { message } }` and `{ message }` shapes that non-control-plane endpoints still answer with.

```typescript
const msg = extractMessage(err.body);
// "Project not found"
```

See the [error catalog](/docs/api/errors) for every code the API can return.

## Retries

The SDK automatically retries transient errors with exponential backoff. Non-retryable errors are thrown immediately.

**Retried:** 408, 429, 502, 503, 504, and network errors (connection refused, DNS failure, fetch error). All other status codes are **not** retried.

When the server returns a `Retry-After` header (on 429 or 503), the SDK uses that value instead of its own computed backoff.

See [Response Conventions](/docs/api/response-conventions#retries) for the backoff formula and retry behavior details.

### Retry configuration

```typescript
import { PgBeamClient } from "pgbeam";

const client = new PgBeamClient({
  token: "your-api-key",
  baseUrl: "https://api.pgbeam.com",
  retry: {
    maxRetries: 5, // default: 5 (0 to disable)
    initialDelayMs: 500, // default: 500ms
    maxDelayMs: 30_000, // default: 30s
    idempotencyKeys: true, // default: true
  },
});
```

| Option            | Type      | Default | Description                                                         |
| ----------------- | --------- | ------- | ------------------------------------------------------------------- |
| `maxRetries`      | `number`  | `5`     | Max retry attempts after the initial request. `0` disables retries. |
| `initialDelayMs`  | `number`  | `500`   | Initial backoff delay in milliseconds.                              |
| `maxDelayMs`      | `number`  | `30000` | Maximum backoff delay in milliseconds.                              |
| `idempotencyKeys` | `boolean` | `true`  | Auto-send `Idempotency-Key` header on POST/PATCH.                   |

### Disabling retries

```typescript
const client = new PgBeamClient({
  token: "your-api-key",
  baseUrl: "https://api.pgbeam.com",
  retry: false,
});
```

## Idempotency

When `idempotencyKeys` is enabled (the default), POST and PATCH requests include an `Idempotency-Key` header so retries never double-create resources. The server caches responses for 24 hours.

See [Response Conventions](/docs/api/response-conventions#idempotency) for how idempotency keys work.

## Conditional requests

Reads that support it return an `ETag`. Send it back as `If-Match` on the write that follows, and the write is refused with `412 Precondition Failed` if someone else changed the resource in between, instead of silently overwriting their change. Operations that accept these headers take a `headers` param and an `onResponse` hook for that one call, which is how you read the `ETag`.

```typescript
import { ApiError } from "pgbeam";

const path = { project_id: "prj_xxx", database_id: "db_xxx" };

let etag: string | undefined;
const db = await api.databases.getDatabase({
  pathParams: path,
  onResponse: (res) => {
    etag = res.headers.get("ETag") ?? undefined;
  },
});

try {
  await api.databases.updateDatabase({
    pathParams: path,
    body: { name: `${db.name}-renamed` },
    headers: { "If-Match": etag },
  });
} catch (err) {
  if (!(err instanceof ApiError && err.status === 412)) throw err;
  // Changed since the read: re-read, re-apply the change, try again.
}
```

A `412` is not retried. Its problem document carries the usual `code`, and its `ETag` header is the current one, visible to the same `onResponse` hook.

`If-None-Match` turns a poll into a cheap check. When nothing changed the server answers `304 Not Modified` with no body. The SDK throws that as an `ApiError` with `status` 304, never retries it, and `isNotModified(err)` recognises it:

```typescript
import { isNotModified } from "pgbeam";

try {
  const fresh = await api.databases.getDatabase({
    pathParams: path,
    headers: { "If-None-Match": etag },
  });
  render(fresh);
} catch (err) {
  if (!isNotModified(err)) throw err;
  // Unchanged: keep what you have.
}
```

`Authorization` and `Content-Type` cannot be set this way: the client sets them from its token and the request body, and a call that passes either is rejected with a `TypeError` before anything is sent. An `Idempotency-Key` passed here replaces the one the SDK would generate.

For operations without a `headers` param, the client-level `onResponse` option sees every response, `ETag` included.