---
title: Errors & retries | Boltz API Docs
description: Read API error responses, tell a failed run from a stopped one, handle rate limits, rely on automatic retries, and submit jobs idempotently.
---

There are two places an error can show up: the **HTTP response** to a request you make, and the **`error` field** on a run that didn't succeed. They use the same `{ code, message }` shape.

## HTTP errors

Failed requests return a non-2xx status with a JSON body:

```
{
  "code": "unauthorized",
  "message": "Invalid or missing API key"
}
```

`code` is a stable, machine-readable string (e.g. `unauthorized`, `forbidden`, `not_found`, `bad_request`, `conflict`, `internal_error`); `message` is human-readable; some errors also include a `details` object with field-level information. The Python SDK raises a typed exception per status code, exposing `status_code`, `message`, and the parsed body (`e.body`):

| Status | SDK exception              | Meaning                                              |
| ------ | -------------------------- | ---------------------------------------------------- |
| 400    | `BadRequestError`          | Malformed request.                                   |
| 401    | `AuthenticationError`      | Missing or invalid API key.                          |
| 403    | `PermissionDeniedError`    | Key lacks permission for this action/workspace.      |
| 404    | `NotFoundError`            | Resource does not exist.                             |
| 409    | `ConflictError`            | Conflicting concurrent request.                      |
| 422    | `UnprocessableEntityError` | Validation failed (see `details`).                   |
| 429    | `RateLimitError`           | Too many requests — see [Rate limits](#rate-limits). |
| ≥500   | `InternalServerError`      | Server-side error; safe to retry.                    |
| —      | `APIConnectionError`       | Network failure or timeout (no HTTP response).       |

```
import boltz_api
from boltz_api import Boltz


client = Boltz()


try:
    client.predictions.structure_and_binding.start(model="boltz-2.1", input={...})
except boltz_api.UnprocessableEntityError as e:
    print("Invalid input:", e.message)
except boltz_api.APIStatusError as e:
    print(e.status_code, e.message)  # the machine-readable `code` is in e.body
except boltz_api.APIConnectionError:
    print("Could not reach the API")
```

A `404` for a resource that doesn't exist uses the `{ code, message }` shape above. A `404` from a mistyped path or wrong HTTP method instead returns the generic `{ message, error, statusCode }` shape — check your URL and verb if you see that.

## Run failures

Prediction and pipeline runs are asynchronous, so a request that returns `200` only means the run was *accepted*. Poll the run and check its `status`:

| Status      | Applies to           | Meaning                                                         |
| ----------- | -------------------- | --------------------------------------------------------------- |
| `pending`   | all                  | Queued, not started.                                            |
| `running`   | all                  | In progress; partial results may already be available.          |
| `succeeded` | all                  | Completed.                                                      |
| `failed`    | all                  | The run errored — see the `error` field.                        |
| `stopped`   | design & screen only | You stopped it; partial results are kept and it can be resumed. |

`stopped` only applies to pipeline runs (protein/small-molecule design and library screens). Structure-and-binding and ADME predictions never return `stopped`.

When a run `failed`, its `error` field is populated (it is `null` otherwise), using the same shape as an HTTP error:

```
run = client.protein.design.retrieve(run_id)
if run.status == "failed":
    print(run.error.code, run.error.message)
```

A run's `error.code` is a free-form string (for example `MODEL_ERROR` or `timeout`), not one of the fixed HTTP error codes above — treat it as an opaque string for logging, not something to branch on exhaustively.

## Rate limits

The API enforces two kinds of limits. Both reject excess submissions with `429` (`RateLimitError`).

**Request rate** — 100 requests per 10-second sliding window, scoped per organization (per user for not-yet-org-bound JWTs). Responses include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers; `429` responses also include `Retry-After` (seconds). Health and docs endpoints are exempt.

**Queue caps** — caps on concurrent pending (queued, not yet started) submissions per scope. New submissions are rejected once any cap is reached; queued work continues to run, and test-mode submissions are exempt. Defaults:

| Scope            | Predictions | Pipeline runs |
| ---------------- | ----------- | ------------- |
| Per organization | 5,000       | 500           |
| Per workspace    | 1,000       | 100           |

These are the platform defaults. To request higher limits for your organization, contact <support@boltz.bio>.

The SDK retries `429`s automatically (see [Automatic retries](#automatic-retries)) and honors `Retry-After`, so you usually only surface a `RateLimitError` after retries are exhausted. If you drive the API yourself, back off when you see a `429` rather than retrying immediately.

## Automatic retries

The SDK retries transient failures **2 times by default** with short exponential backoff (≈0.5s, doubling, capped at 8s, with jitter). It retries connection errors, request timeouts (`408`), `409`, `429`, and `≥500` — and honors the server's `Retry-After` and `x-should-retry` hints. The other 4xx errors (`400`/`401`/`403`/`404`/`422`) are **not** retried, since retrying won't change the outcome.

```
# Disable, or raise, the retry count — globally or per request.
client = Boltz(max_retries=0)
client.with_options(max_retries=5).protein.design.start(...)
```

Requests time out after **60 seconds** by default (raising `APITimeoutError`, which is itself retried). Configure it with `Boltz(timeout=...)` or `with_options(timeout=...)`.

## Idempotency

Retrying a job submission shouldn't create a duplicate run. Pass an `idempotency_key` (a string you choose) when you start a job: if a run with that key already exists in your workspace, the API returns the **existing** run instead of creating a new one — and doesn't bill you twice.

```
client.protein.design.start(
    target={...},
    binder_specification={...},
    num_proteins=100,
    idempotency_key="my-run-2026-05-31",
)
```

With the CLI, use the top-level `--idempotency-key` flag. The key is scoped to your organization, workspace, job type, and mode (test vs live), so the same key can be reused across test and live or across different job types. Reusing an idempotency key with a differing request body returns a `ConflictError`.

Idempotency is a request **body** field (`idempotency_key`), not an HTTP header — you choose and pass the value; the SDK does not generate it for you. A key is retired once a run's data is deleted, so it can't resurrect a deleted run.
