---
title: "Handle errors"
description: "Every Flume Partner API error is JSON with a stable code, a message, the field at fault and a request id. Branch on the code and retry only what is safe."
url: https://docs.tryflume.ai/guides/handle-errors
updated: 2026-11-11
---

# Handle errors

> Every Flume Partner API error is JSON with a stable code, a message, the field at fault and a request id. Branch on the code and retry only what is safe.

Every error has the same JSON shape. Branch on `error.code`, show `error.message`, and log `error.request_id`.

## Read the envelope

```json
{
  "error": {
    "code": "SKU_NOT_IN_SEARCH",
    "message": "This product was not a result of that search. Use the search_session_uuid of the search (or the page) that returned it.",
    "field": "sku_uuid",
    "request_id": "e19b8889-a63b-4000-9dbe-ffd5b9157bd5"
  }
}
```

| Field | Use |
|---|---|
| `code` | Stable. Write your logic against it |
| `message` | Written for a person. It can change. Never match on it |
| `field` | The request field at fault, as a dotted path, or `null` |
| `request_id` | Quote it to Flume support. Also in the `X-Request-Id` response header |
| `details` | Sometimes present, with more about a refusal |

## Decide what to retry

**Diagram: What to do when a server call fails.** Read the status and error.code on the left, then take the one action beside it.

```mermaid
flowchart LR
  c-key["401 PARTNER_KEY_INVALID"]
  a-key["Fix the key or the base URL / do not retry"]
  c-token["401 USER_TOKEN_INVALID"]
  a-token["Delete the stored token / start again without it"]
  c-code["401 CONNECT_CODE_INVALID"]
  a-code["Store nothing / the user verifies on the next open"]
  c-403["403 USER_NOT_CONNECTED"]
  a-403["Verify the user / then send the user access token"]
  c-4xx["400, 404, 413, 422 / and any other 403 or 409"]
  a-4xx["Fix the request / field names what is wrong"]
  c-409["409 IDEMPOTENCY_IN_PROGRESS"]
  a-409["Retry with the same key / after Retry-After"]
  c-429["429"]
  a-429["Wait for Retry-After, then retry / a daily limit resets at 00:00 UTC"]
  c-5xx["500, 502, 503, timeout"]
  a-5xx["Retry with a growing wait / same Idempotency-Key, up to 3 retries"]
  c-key --> a-key
  c-token --> a-token
  c-code --> a-code
  c-403 --> a-403
  c-4xx --> a-4xx
  c-409 --> a-409
  c-429 --> a-429
  c-5xx --> a-5xx
```

1. 401 PARTNER_KEY_INVALID: fix the key, or the base URL it belongs to. Do not retry.
2. 401 USER_TOKEN_INVALID: delete the stored token and start the session again without it. The user verifies again in the iframe.
3. 401 CONNECT_CODE_INVALID: store nothing and do not retry. The user verifies again on the next open.
4. 403 USER_NOT_CONNECTED: the call needs a verified user. Verify the user, then send the user access token.
5. 400, 404, 413, 422, and any other 403 or 409: fix the request. On a 422, field names what is wrong. Do not retry unchanged.
6. 409 IDEMPOTENCY_IN_PROGRESS: the first request with this Idempotency-Key is still running. Retry with the same key after Retry-After.
7. 429: wait for the number of seconds in Retry-After, then retry. For a daily limit that is the time until 00:00 UTC, so stop for the day.
8. 500, 502, 503 or a timeout: retry with a growing wait and the same Idempotency-Key, up to 3 retries.

| Status | Retry? | How |
|---|---|---|
| `400`, `404`, `413`, `422` | No | Fix the request |
| `401` | No | Fix the key, or verify the user again |
| `403` | No | Send the user access token, or verify the user |
| `409` `IDEMPOTENCY_IN_PROGRESS` | Yes | After `Retry-After` seconds (2 when the header is absent), same key |
| `409` other codes | No | Resolve the conflict |
| `429` | Yes | After `Retry-After` seconds |
| `500` `INTERNAL` | Yes | With backoff. Same `Idempotency-Key` |
| `502` `VERIFICATION_EMAIL_FAILED` | Yes | With backoff |
| `503` `SEARCH_UNAVAILABLE` | Yes | With backoff |

Use exponential backoff with jitter. Double the wait each time, and stop after 4 tries.

## Example: a retry wrapper

**Python**
```python
import random
import time

import requests

RETRYABLE = {"IDEMPOTENCY_IN_PROGRESS", "RATE_LIMITED", "INTERNAL", "VERIFICATION_EMAIL_FAILED", "SEARCH_UNAVAILABLE"}

def call(method, url, **kwargs):
    for attempt in range(4):
        response = requests.request(method, url, timeout=75, **kwargs)
        if response.ok:
            return response
        try:
            error = response.json()["error"]
        except (ValueError, KeyError):
            response.raise_for_status()
        if error["code"] not in RETRYABLE or attempt == 3:
            raise RuntimeError(f'{error["code"]} {error["request_id"]}: {error["message"]}')
        wait = float(response.headers.get("Retry-After", 2**attempt))
        if wait > 60:
            raise RuntimeError(f'{error["code"]}: retry in {wait:.0f} seconds')
        time.sleep(wait + random.random())
```
**Node.js**
```js
const RETRYABLE = new Set(["IDEMPOTENCY_IN_PROGRESS", "RATE_LIMITED", "INTERNAL", "VERIFICATION_EMAIL_FAILED", "SEARCH_UNAVAILABLE"]);

export async function call(url, init) {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(url, init);
    if (response.ok) return response;
    const body = await response.json().catch(() => null);
    const error = body?.error;
    if (!error) throw new Error(`HTTP ${response.status}`);
    if (!RETRYABLE.has(error.code) || attempt === 3) {
      throw new Error(`${error.code} ${error.request_id}: ${error.message}`);
    }
    const wait = Number(response.headers.get("Retry-After") ?? 2 ** attempt);
    if (wait > 60) throw new Error(`${error.code}: retry in ${wait} seconds`);
    await new Promise((resolve) => setTimeout(resolve, (wait + Math.random()) * 1000));
  }
}
```

Keep the same `Idempotency-Key` in `init.headers` across the retries of one action.

To retry an upload such as `POST /search`, build the body again for each attempt. In Python, open the file again or call `image.seek(0)`. A file that was already read uploads as empty, and the call returns `400` `IMAGE_REQUIRED`.

## Handle responses that are not the envelope

- On live, a request from an unsupported country gets a `403` with no JSON body. See [Environments](https://docs.tryflume.ai/concepts/environments.md#how-do-you-get-live-access).
- On live, a network-level limit per IP address can return a `429` with no JSON body and no `Retry-After`.
- A network timeout has no body. Retry a write with the same `Idempotency-Key`.

Always check that the body parses as JSON and has `error` before you read it.

## Show errors to users

- `error.message` is written for the user. You can show it.
- For `429 RATE_LIMITED`, tell the user when to try again.

## Log for support

Log these for every failed call:

- `X-Request-Id`
- the method and path
- `error.code`
- the key prefix, never the key
- `session_uuid`, for iframe sessions

## See also

- [Errors](https://docs.tryflume.ai/api/errors.md): every code, with its status and fix
- [Idempotency](https://docs.tryflume.ai/concepts/idempotency.md)
- [Limits](https://docs.tryflume.ai/concepts/limits.md)
