> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gpuoutlet.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Error reference

> The error envelope, the six error types, and every code we return with what to do about it.

Every error — all of them, on every endpoint — comes back in one shape:

```json theme={null}
{
  "error": {
    "type": "invalid_request_error",
    "code": "offer_unavailable",
    "message": "This offer cannot be rented right now — it has no free capacity at this size, or it was withdrawn.",
    "doc_url": "https://docs.gpuoutlet.ai/api/errors#offer_unavailable",
    "request_id": "req_a71c3e9048bd"
  }
}
```

<ResponseField name="type" type="string" required>
  The broad class. Six values; see below. Branch on this for retry policy.
</ResponseField>

<ResponseField name="code" type="string" required>
  Stable and machine-readable. Branch on this for specific handling.
</ResponseField>

<ResponseField name="message" type="string" required>
  Human-readable. Written for a developer reading a log — safe to show a user,
  but not localised.
</ResponseField>

<ResponseField name="doc_url" type="string" required>
  Deep link to this page, anchored at the code.
</ResponseField>

<ResponseField name="request_id" type="string" required>
  Quote it to support and we can find the exact request.
</ResponseField>

<ResponseField name="param" type="string">
  The offending parameter, when one is identifiable.
</ResponseField>

Some codes add fields — `required_scope`, `retry_after_seconds`, `limit_cents`,
`committed_cents`, `resets_at`. They are always **inside** the `error` object,
never beside it, so a parser that reads `body.error` gets everything.

## Branch on `type` first

| Type                    | HTTP          | Meaning                                                       | Retry?                             |
| ----------------------- | ------------- | ------------------------------------------------------------- | ---------------------------------- |
| `invalid_request_error` | 400, 404, 409 | Your request was wrong or the target is not in a usable state | Only after changing something      |
| `authentication_error`  | 401           | The key is missing, unknown, revoked or expired               | No — fix the key                   |
| `permission_error`      | 403           | Valid key, not allowed to do this                             | No                                 |
| `billing_error`         | 402           | Well-formed and allowed, but the account cannot pay           | No — top up, or wait for the reset |
| `rate_limit_error`      | 429           | Too many requests, or the daily quota is spent                | Yes, after `Retry-After`           |
| `api_error`             | 5xx           | Our fault                                                     | Yes, with backoff                  |

<Note>
  `billing_error` is the one worth special-casing. It looks transient — the request
  was valid, nothing was malformed — but retrying it unchanged will fail forever.
  A retry loop that treats 402 like 429 will hammer us until its own timeout and
  still not start a machine.
</Note>

## Every code

### Authentication — 401

#### invalid\_api\_key

**401 · `authentication_error`**

No usable API key was provided. Send it as `Authorization: Bearer gpk_…`.

Check for the usual suspects: a missing `Bearer ` prefix, a truncated paste, an
environment variable that did not survive into the container, or a trailing
newline from `$(cat key.txt)`.

#### api\_key\_revoked

**401 · `authentication_error`**

The key was revoked in the dashboard. Revocation is immediate and final —
create a new key.

If you did not revoke it, someone with dashboard access did. Check with your team
before minting a replacement.

#### api\_key\_expired

**401 · `authentication_error`**

The key passed its expiry date. Create a new one.

### Permission — 403

#### insufficient\_scope

**403 · `permission_error`** · carries `required_scope`

The key is valid but lacks the scope this endpoint needs. Scopes are fixed at
creation — mint a new key. See [Scopes](/api/scopes).

#### test\_mode\_not\_supported

**403 · `permission_error`**

A test key tried to create or stop a rental. Test keys authenticate like live
keys and read the same real catalog, but there is no sandbox behind this API —
a rental they created would be a real, billed machine. Use a live key.

#### account\_suspended

**403 · `permission_error`** · code `banned`

The account is suspended. Contact [help@gpuoutlet.ai](mailto:help@gpuoutlet.ai).

### Request — 400, 404, 409

#### invalid\_request

**400 · `invalid_request_error`** · often carries `param`

One or more parameters are invalid. `param` names the first offender when we can
identify one.

#### invalid\_cursor

**400 · `invalid_request_error`**

The cursor does not match the current filters or sort order. Cursors are valid
only for the exact query that produced them — see [Pagination](/api/pagination#cursors-are-bound-to-their-query).

#### pagination\_limit\_exceeded

**400 · `invalid_request_error`**

The result set is too deep to page through. Narrow the filters rather than paging
further.

#### not\_found

**404 · `invalid_request_error`**

No such resource, or it is not visible to you. The two are reported identically
on purpose: distinguishing them would confirm that another account's resource
exists.

#### offer\_not\_found

**404 · `invalid_request_error`**

No such offer, or it is no longer visible. Offers are transient — a supplier can
withdraw one at any time. Re-read `/offers` and pick another.

#### offer\_unavailable

**409 · `invalid_request_error`**

The offer exists but cannot be rented right now: no free capacity at the size you
asked for, or the seller withdrew it. **Specific to this offer** — pick a
different one rather than retrying this one.

Contrast with `capacity_unavailable` (503), which is not offer-specific.

#### max\_active\_rentals\_reached

**409 · `invalid_request_error`**

The account is at its concurrent-rental ceiling. Stop one first — and remember
that a rental in `stopping` still occupies a slot until it reaches `stopped`.
See [Concurrency](/api/spend-limits#concurrent-rentals).

#### invalid\_template

**400 · `invalid_request_error`**

No such template. Omit `template_id` to boot the default image.

### Idempotency — 400, 409

#### idempotency\_key\_required

**400 · `invalid_request_error`**

`POST /rentals` spends money, so `Idempotency-Key` is mandatory. Generate one per
intent — a UUID is fine. See [Idempotency](/api/idempotency).

#### idempotency\_key\_in\_flight

**409 · `invalid_request_error`** · carries `retry_after_seconds`

A request with this key is still being processed. This is the correct answer to a
client that retried before the first attempt finished — it stops you creating two
machines. Wait `retry_after_seconds` and retry **with the same key**.

#### idempotency\_key\_reused

**409 · `invalid_request_error`**

This key was already used with a *different* request body. We refuse rather than
silently serving the first rental or overwriting it, because both would be wrong.
Use a new key for a new rental.

### Billing — 402

All three are `billing_error`. **None of them is retryable as-is.**

#### insufficient\_balance

**402 · `billing_error`**

The balance does not cover the hold this rental requires. Top up, then retry.

#### account\_past\_due

**402 · `billing_error`**

There is an unpaid balance. Settle it before starting new rentals.

#### key\_spend\_limit\_exceeded

**402 · `billing_error`** · carries `limit_cents`, `committed_cents`, `resets_at`

This key's daily spend cap would be crossed. Raise the limit, use another key, or
wait until `resets_at`. See [Spend limits](/api/spend-limits).

### Rate limits — 429

#### rate\_limited

**429 · `rate_limit_error`** · `Retry-After` header

Too many requests. Back off for the stated interval. See [Rate limits](/api/rate-limits).

#### daily\_quota\_exceeded

**429 · `rate_limit_error`**

The daily request quota for this key is spent. It resets at 00:00 UTC. Slowing
down will not help before then.

### Ours — 5xx

#### capacity\_unavailable

**503 · `api_error`**

Capacity we expected to be there was not, and **no rental was created**. Temporary
and not specific to the offer you asked for. Retry with backoff; if it persists
across several offers, check [status.gpuoutlet.ai](https://status.gpuoutlet.ai).

#### api\_error

**5xx · `api_error`**

Our fault. Retry with exponential backoff and jitter. If it persists, send us the
`request_id`.

## A retry policy that works

```python theme={null}
import random, time

RETRYABLE_TYPES = {"rate_limit_error", "api_error"}

def call_with_retry(fn, attempts=5):
    for i in range(attempts):
        resp = fn()
        if resp.ok:
            return resp

        err = resp.json().get("error", {})
        if err.get("type") not in RETRYABLE_TYPES:
            # 400/401/403/404/409/402 — retrying changes nothing.
            raise ApiError(err)

        # Honour the server's own number when it gives us one.
        wait = err.get("retry_after_seconds") or int(resp.headers.get("Retry-After", 0))
        time.sleep(wait or min(2 ** i, 30) + random.random())

    raise ApiError(err)
```

Three things this gets right: it branches on `type` rather than on the status
code alone, it prefers the server's `Retry-After` over its own guess, and it adds
jitter so a fleet of workers does not retry in lockstep.
