# Handle errors and retries

> Decide which answers to retry and which to fix, back off with jitter, honour Retry-After and log the request_id.

Every error of the API is an RFC 9457 Problem (`application/problem+json`) with a stable `code` and a
`request_id`. Some errors go away when you wait and try again; most need a change on your side. This guide
sorts them and shows a small helper that retries the right ones. The codes are listed in
[Errors](https://passport.kabin.sh/docs/en/errors).

```json title="429"
{
  "type": "about:blank",
  "title": "Too Many Requests",
  "status": 429,
  "code": "rate_limited",
  "detail": "Too many attempts. Try again later.",
  "request_id": "8b1e6f03c2d94a7e5f0a1b2c"
}
```

The `request_id` is also in the `X-Request-Id` header of every response, including successful ones.

## Retry or fix?

| Answer                                    | Retry?                                                                                    | What to do                                                                                                                                                |
| ----------------------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `429 rate_limited`                        | Yes, always                                                                               | Wait the number of seconds in `Retry-After`. The request was refused before it did anything.                                                              |
| `500 internal_error`, `502`, `503`, `504` | `GET` and `updateCondition`: yes, with backoff. `createLifecycleEvent`: not automatically | Back off and try again; if it persists, contact support with the `request_id`.                                                                            |
| Network error or timeout                  | Same as `5xx`                                                                             | You do not know whether the request arrived.                                                                                                              |
| `400 validation_error`                    | No                                                                                        | Fix the request; `errors[]` names each field (`field`, `code`, `message`).                                                                                |
| `401 unauthenticated`                     | No                                                                                        | The key is missing, unknown or revoked. Alert a person instead of looping.                                                                                |
| `403 forbidden`                           | No                                                                                        | The key lacks the operation's scope (`read`, `condition` or `import`).                                                                                    |
| `404 not_found`                           | No                                                                                        | Wrong id, or the resource belongs to another organisation.                                                                                                |
| `409 conflict`                            | No                                                                                        | The resource is in a state that does not allow this, e.g. a QR code before the identifiers are locked, or condition data for a unit of an archived model. |

Why the difference between methods: `GET` changes nothing, and `updateCondition` skips a measurement it already
has, so sending the same body again is harmless. `createLifecycleEvent` records a new event on every successful
call; after a timeout, check [`listLifecycleEvents`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/list-lifecycle-events)
before you send it again. Details in [Retries and idempotency](https://passport.kabin.sh/docs/en/idempotency).

> [!WARNING]
> Do not retry `401` in a loop. Unknown and revoked keys are limited to 30 attempts per 15 minutes per client
> address, so a misconfigured job soon gets `429` instead of `401`, which hides the real cause.

## Back off with jitter

When an answer is worth retrying:

1. If `Retry-After` is present, wait that many seconds.
2. Otherwise wait a random time between 0 and `min(30 s, 0.5 s × 2^attempt)` ("full jitter"), so many clients do
   not retry in lockstep.
3. Give up after a few attempts (five is plenty) and report the error with its `request_id`.
4. Set a client timeout (for example 30 seconds) so a hanging connection counts as a network error.

In batch jobs, a `429` means the whole organisation is over its limit of 600 requests per minute (all keys
together). Slow the job down instead of only retrying the single request; see [Rate limits](https://passport.kabin.sh/docs/en/rate-limits).

## A helper for every call

The helper sends a request, returns the parsed JSON, retries what is worth retrying and raises an error that
carries the Problem. Each version runs on its own; the last lines show its use.

```ts title="TypeScript"
// batterypass.mts: npx tsx batterypass.mts
const BASE = `${process.env.BATTERYPASS_URL}/api/v1`;
const KEY = process.env.BATTERYPASS_API_KEY;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export class ApiError extends Error {
  status: number;
  problem: { code?: string; detail?: string; request_id?: string; errors?: unknown[] };
  constructor(status: number, problem: ApiError['problem']) {
    super(`${status} ${problem.code}: ${problem.detail} (request_id ${problem.request_id})`);
    this.status = status;
    this.problem = problem;
  }
}

/** Sends a request; retries 429 always, and 5xx or network errors unless the method is POST. */
export async function call<T>(method: string, path: string, body?: unknown, maxAttempts = 5): Promise<T> {
  for (let attempt = 1; ; attempt++) {
    let res: Response | undefined;
    try {
      res = await fetch(BASE + path, {
        method,
        headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
        body: body === undefined ? undefined : JSON.stringify(body),
        signal: AbortSignal.timeout(30_000),
      });
    } catch (err) {
      if (method === 'POST' || attempt === maxAttempts) throw err; // network error or timeout
    }
    if (res?.ok) return (await res.json()) as T;
    const retryable = !res || res.status === 429 || (res.status >= 500 && method !== 'POST');
    if (res && (!retryable || attempt === maxAttempts)) {
      const requestId = res.headers.get('X-Request-Id') ?? undefined;
      const problem = await res.json().catch(() => ({ request_id: requestId }));
      throw new ApiError(res.status, problem);
    }
    // Retry-After (seconds) wins; otherwise exponential backoff with full jitter, at most 30 s.
    const retryAfter = Number(res?.headers.get('Retry-After'));
    await sleep(retryAfter > 0 ? retryAfter * 1000 : Math.random() * Math.min(30_000, 500 * 2 ** attempt));
  }
}

// Usage
try {
  const page = await call<{ items: { serial_number: string }[] }>('GET', '/batteries?limit=10');
  console.log(page.items.map((u) => u.serial_number));
} catch (err) {
  if (err instanceof ApiError) console.error(err.message); // log the request_id, never the key
  else throw err;
}
```

```python title="Python"
# batterypass.py
import os
import random
import time

import requests

BASE = os.environ["BATTERYPASS_URL"] + "/api/v1"
session = requests.Session()
session.headers["Authorization"] = "Bearer " + os.environ["BATTERYPASS_API_KEY"]


class ApiError(Exception):
    def __init__(self, status, problem):
        super().__init__(f"{status} {problem.get('code')}: {problem.get('detail')} (request_id {problem.get('request_id')})")
        self.status, self.problem = status, problem


def call(method, path, body=None, max_attempts=5):
    """Sends a request; retries 429 always, and 5xx or network errors unless the method is POST."""
    for attempt in range(1, max_attempts + 1):
        try:
            res = session.request(method, BASE + path, json=body, timeout=30)
        except (requests.ConnectionError, requests.Timeout):  # network error or timeout
            if method == "POST" or attempt == max_attempts:
                raise
            res = None
        if res is not None and res.ok:
            return res.json()
        retryable = res is None or res.status_code == 429 or (res.status_code >= 500 and method != "POST")
        if res is not None and (not retryable or attempt == max_attempts):
            try:
                problem = res.json()
            except ValueError:
                problem = {"request_id": res.headers.get("X-Request-Id")}
            raise ApiError(res.status_code, problem)
        # Retry-After (seconds) wins; otherwise exponential backoff with full jitter, at most 30 s.
        retry_after = res.headers.get("Retry-After") if res is not None else None
        time.sleep(int(retry_after) if retry_after else random.uniform(0, min(30, 0.5 * 2**attempt)))


# Usage
try:
    page = call("GET", "/batteries?limit=10")
    print([unit["serial_number"] for unit in page["items"]])
except ApiError as err:
    print(err)  # log the request_id, never the key
```

```go title="Go"
// batterypass: go run .
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"math/rand/v2"
	"net/http"
	"os"
	"strconv"
	"time"
)

var (
	base   = os.Getenv("BATTERYPASS_URL") + "/api/v1"
	key    = os.Getenv("BATTERYPASS_API_KEY")
	client = &http.Client{Timeout: 30 * time.Second}
)

// Problem is an RFC 9457 error response.
type Problem struct {
	Status    int    `json:"status"`
	Code      string `json:"code"`
	Detail    string `json:"detail"`
	RequestID string `json:"request_id"`
}

func (p *Problem) Error() string {
	return fmt.Sprintf("%d %s: %s (request_id %s)", p.Status, p.Code, p.Detail, p.RequestID)
}

// call sends a request and decodes the response into out; it retries 429 always, and 5xx or
// network errors unless the method is POST.
func call(method, path string, body, out any) error {
	var payload []byte
	if body != nil {
		var err error
		if payload, err = json.Marshal(body); err != nil {
			return err
		}
	}
	const maxAttempts = 5
	for attempt := 1; ; attempt++ {
		req, err := http.NewRequest(method, base+path, bytes.NewReader(payload))
		if err != nil {
			return err
		}
		req.Header.Set("Authorization", "Bearer "+key)
		req.Header.Set("Content-Type", "application/json")
		res, err := client.Do(req)
		if err != nil {
			if method == http.MethodPost || attempt == maxAttempts {
				return err // network error or timeout
			}
		} else {
			data, _ := io.ReadAll(res.Body)
			res.Body.Close()
			if res.StatusCode < 300 {
				return json.Unmarshal(data, out)
			}
			retryable := res.StatusCode == 429 || (res.StatusCode >= 500 && method != http.MethodPost)
			if !retryable || attempt == maxAttempts {
				p := &Problem{Status: res.StatusCode, RequestID: res.Header.Get("X-Request-Id")}
				_ = json.Unmarshal(data, p)
				return p
			}
			// Retry-After (seconds) wins.
			if s, _ := strconv.Atoi(res.Header.Get("Retry-After")); s > 0 {
				time.Sleep(time.Duration(s) * time.Second)
				continue
			}
		}
		// Exponential backoff with full jitter, at most 30 s.
		backoff := min(30*time.Second, 500*time.Millisecond<<attempt)
		time.Sleep(rand.N(backoff))
	}
}

func main() {
	var page struct {
		Items []struct {
			SerialNumber string `json:"serial_number"`
		} `json:"items"`
	}
	if err := call(http.MethodGet, "/batteries?limit=10", nil, &page); err != nil {
		log.Fatal(err) // log the request_id, never the key
	}
	for _, u := range page.Items {
		fmt.Println(u.SerialNumber)
	}
}
```

## What to log

| Log                                | Never log                                                          |
| ---------------------------------- | ------------------------------------------------------------------ |
| Operation, method and path         | The `Authorization` header or the key                              |
| HTTP status and `code`             | The contents of JSON or PDF exports (they include restricted data) |
| `request_id` (or `X-Request-Id`)   |                                                                    |
| `errors[]` of a `validation_error` |                                                                    |

When you contact [Support](https://passport.kabin.sh/docs/en/general/support), quote the `request_id`: it finds the request in the
server logs without anyone needing your key.
