---
title: "Error Handling"
description: "Catch and inspect API errors, configure retries, and use idempotency keys in the PgBeam Go SDK."
canonical: "https://pgbeam.com/docs/go-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 Go SDK.

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

All methods return an `error` on failure. An error from the API is a `*pgbeam.APIError`. Use `pgbeam.HasCode` to branch on the condition, and `errors.As` to read the rest of the problem document.

```go
import pgbeam "go.pgbeam.com/sdk"

project, err := client.Projects.GetProject(ctx, "prj_missing")
if err != nil {
    if pgbeam.HasCode(err, "NOT_FOUND") {
        fmt.Println("Project not found")
        return
    }

    var apiErr *pgbeam.APIError
    if errors.As(err, &apiErr) {
        fmt.Printf("API error %d [%s]: %s (request %s)\n",
            apiErr.StatusCode, apiErr.Code, apiErr.Detail, apiErr.RequestID)
        return
    }

    log.Fatal(err)
}
```

## APIError properties

Every error the API returns is an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) problem document, and `APIError` parses its fields out so you do not have to unmarshal `Body`. A body that is not a problem document leaves those fields empty; `StatusCode`, `Status` and `Body` are always set.

| Property     | Type                  | Description                                                                                                                  |
| ------------ | --------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `StatusCode` | `int`                 | HTTP status code                                                                                                             |
| `Status`     | `string`              | HTTP status text                                                                                                             |
| `Body`       | `string`              | Raw response body                                                                                                            |
| `Code`       | `string`              | Stable identity of the condition, e.g. `PLAN_LIMIT_REACHED`                                                                  |
| `Type`       | `string`              | The same identity as a URI, resolving to the error catalog                                                                   |
| `Title`      | `string`              | Short summary of the condition                                                                                               |
| `Detail`     | `string`              | Explanation of this occurrence                                                                                               |
| `Instance`   | `string`              | Path of the request that produced the error                                                                                  |
| `RequestID`  | `string`              | Correlation id, also in the `X-Request-Id` header. Quote it when reporting a problem                                         |
| `Errors`     | `[]pgbeam.FieldError` | Field-level detail when the request failed validation. Each entry has `Field` (dotted path in the request body) and `Detail` |

`apiErr.Error()` returns a one-line summary with the status, the code and the detail.

## Branch on `Code`, not on the 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 different ways. `pgbeam.HasCode(err, code)` reports whether `err` is an `APIError` with that code. It uses `errors.As`, so it works on a wrapped error too.

```go
_, err := client.Projects.CreateProject(ctx, pgbeam.CreateProjectRequest{ /* ... */ })
switch {
case err == nil:
    // Created
case pgbeam.HasCode(err, "PLAN_LIMIT_REACHED"):
    promptUpgrade(err)
case pgbeam.HasCode(err, "FORBIDDEN"):
    askAnAdminForAccess()
case pgbeam.HasCode(err, "INVALID_INPUT"):
    var apiErr *pgbeam.APIError
    if errors.As(err, &apiErr) {
        for _, f := range apiErr.Errors {
            markField(f.Field, f.Detail)
        }
    }
default:
    log.Fatal(err)
}
```

## IsNotFound helper

`pgbeam.IsNotFound(err)` returns `true` if the underlying error is a 404 response. Like `HasCode`, it uses `errors.As`, so it works even when the error is wrapped with `fmt.Errorf("...: %w", err)`.

```go
_, err := client.Projects.GetProject(ctx, "prj_missing")
if pgbeam.IsNotFound(err) {
    // Resource does not exist
}
```

See the [error catalog](/docs/api/errors) for every code the API can return, and [Response Conventions](/docs/api/response-conventions) for the error envelope format.

## Retries

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

**Retried:** 408, 429, 502, 503, 504, and network errors (connection refused, DNS failure, timeout). 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.

Every call is bounded twice. Each attempt times out after `ClientOptions.Timeout` (default 30s), and a timed-out attempt is retried like a network error. `RetryConfig.TotalBudget` (default 120s) caps the whole call, measured from the first attempt: a retry whose wait would land past it is not made, and the last error is returned. A `Retry-After` longer than the remaining budget therefore returns the error at once.

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

### Retry configuration

```go
import pgbeam "go.pgbeam.com/sdk"

client := pgbeam.NewClient(&pgbeam.ClientOptions{
    APIKey: "pbu_your_api_key",
    Retry: &pgbeam.RetryConfig{
        MaxRetries:   5,                       // default: 5 (0 to disable)
        InitialDelay: 500 * time.Millisecond,  // default: 500ms
        MaxDelay:     30 * time.Second,         // default: 30s
        TotalBudget:  120 * time.Second,        // default: 120s (0 to disable)
    },
    Timeout: 30 * time.Second, // per attempt, default: 30s (negative to disable)
})
```

| Field          | Type            | Default | Description                                                                    |
| -------------- | --------------- | ------- | ------------------------------------------------------------------------------ |
| `MaxRetries`   | `int`           | `5`     | Max retry attempts after the initial request. `0` disables retries.            |
| `InitialDelay` | `time.Duration` | `500ms` | Initial backoff delay.                                                         |
| `MaxDelay`     | `time.Duration` | `30s`   | Maximum backoff delay.                                                         |
| `TotalBudget`  | `time.Duration` | `120s`  | Ceiling for the whole call, including time spent in requests. `0` disables it. |

A `RetryConfig` you pass is used as given: fields you leave out are zero, not the defaults above, so set `TotalBudget` explicitly if you want a budget alongside custom retry settings.

`ClientOptions.Timeout` bounds each attempt, including reading the response body. `0` means the 30s default and a negative value disables it. It is applied through the request context, so it also holds when you supply your own `HTTPClient`.

### Disabling retries

```go
client := pgbeam.NewClient(&pgbeam.ClientOptions{
    APIKey: "pbu_your_api_key",
    Retry:  &pgbeam.RetryConfig{MaxRetries: 0},
})
```

### Context cancellation

Retry loops respect `context.Context`. If the context is cancelled or its deadline expires, the SDK stops retrying and returns the context error.

```go
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

project, err := client.Projects.GetProject(ctx, "prj_abc123")
```

## Idempotency

POST and PATCH requests automatically include an `Idempotency-Key` header when retries are enabled, 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.