# Fehler behandeln und Anfragen wiederholen

> Entscheiden Sie, welche Antworten Sie wiederholen und welche Sie beheben, warten Sie mit Backoff und Jitter, beachten Sie Retry-After und protokollieren Sie die request_id.

Jeder Fehler der API ist ein Problem nach RFC 9457 (`application/problem+json`) mit einem stabilen `code` und einer
`request_id`. Manche Fehler verschwinden, wenn Sie warten und es erneut versuchen; die meisten verlangen eine
Änderung auf Ihrer Seite. Diese Anleitung ordnet sie und zeigt eine kleine Hilfsfunktion, die die richtigen
wiederholt. Die Codes stehen unter [Fehler](https://passport.kabin.sh/docs/de/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"
}
```

Die `request_id` steht auch im Header `X-Request-Id` jeder Antwort, auch der erfolgreichen.

## Wiederholen oder beheben?

| Antwort                                   | Wiederholen?                                                                            | Was zu tun ist                                                                                                                                                                   |
| ----------------------------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `429 rate_limited`                        | Ja, immer                                                                               | So viele Sekunden warten, wie `Retry-After` angibt. Die Anfrage wurde abgelehnt, bevor sie etwas bewirkt hat.                                                                    |
| `500 internal_error`, `502`, `503`, `504` | `GET` und `updateCondition`: ja, mit Backoff. `createLifecycleEvent`: nicht automatisch | Warten und erneut versuchen; hält der Fehler an, den Support mit der `request_id` kontaktieren.                                                                                  |
| Netzwerkfehler oder Timeout               | Wie bei `5xx`                                                                           | Sie wissen nicht, ob die Anfrage angekommen ist.                                                                                                                                 |
| `400 validation_error`                    | Nein                                                                                    | Anfrage korrigieren; `errors[]` nennt jedes Feld (`field`, `code`, `message`).                                                                                                   |
| `401 unauthenticated`                     | Nein                                                                                    | Der Schlüssel fehlt, ist unbekannt oder widerrufen. Einen Menschen alarmieren, statt in einer Schleife zu wiederholen.                                                           |
| `403 forbidden`                           | Nein                                                                                    | Dem Schlüssel fehlt der Scope der Operation (`read`, `condition` oder `import`).                                                                                                 |
| `404 not_found`                           | Nein                                                                                    | Falsche ID, oder die Ressource gehört zu einer anderen Organisation.                                                                                                             |
| `409 conflict`                            | Nein                                                                                    | Die Ressource ist in einem Zustand, der das nicht erlaubt, z. B. ein QR-Code, bevor die Kennungen gesperrt sind, oder Zustandsdaten für eine Einheit eines archivierten Modells. |

Warum die Methoden sich unterscheiden: `GET` ändert nichts, und `updateCondition` überspringt eine Messung, die
schon gespeichert ist; denselben Body erneut zu senden schadet also nicht. `createLifecycleEvent` erfasst bei
jedem erfolgreichen Aufruf ein neues Ereignis; prüfen Sie nach einem Timeout
[`listLifecycleEvents`](https://passport.kabin.sh/docs/de/api-reference/lifecycle/list-lifecycle-events), bevor Sie erneut senden.
Einzelheiten unter [Wiederholungen und Idempotenz](https://passport.kabin.sh/docs/de/idempotency).

> [!WARNING]
> Wiederholen Sie `401` nicht in einer Schleife. Unbekannte und widerrufene Schlüssel sind auf 30 Versuche pro
> 15 Minuten und Client-Adresse begrenzt; ein falsch konfigurierter Job bekommt also bald `429` statt `401`, und die
> eigentliche Ursache gerät aus dem Blick.

## Backoff mit Jitter

Wenn sich eine Wiederholung lohnt:

1. Ist `Retry-After` gesetzt, warten Sie so viele Sekunden.
2. Andernfalls warten Sie eine zufällige Zeit zwischen 0 und `min(30 s, 0.5 s × 2^attempt)` („Full Jitter“), damit
   viele Clients nicht im Gleichtakt wiederholen.
3. Geben Sie nach wenigen Versuchen auf (fünf reichen) und melden Sie den Fehler mit seiner `request_id`.
4. Setzen Sie ein Client-Timeout (zum Beispiel 30 Sekunden), damit eine hängende Verbindung als Netzwerkfehler
   zählt.

In Batch-Jobs bedeutet ein `429`, dass die ganze Organisation über ihrem Limit von 600 Anfragen pro Minute liegt
(alle Schlüssel zusammen). Bremsen Sie den Job, statt nur die einzelne Anfrage zu wiederholen; siehe
[Ratenlimits](https://passport.kabin.sh/docs/de/rate-limits).

## Eine Hilfsfunktion für jeden Aufruf

Die Hilfsfunktion sendet eine Anfrage, gibt das geparste JSON zurück, wiederholt, was sich zu wiederholen lohnt,
und wirft einen Fehler, der das Problem enthält. Jede Fassung läuft für sich; die letzten Zeilen zeigen die
Verwendung.

```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)
	}
}
```

## Was ins Log gehört

| Protokollieren                      | Nie protokollieren                                                          |
| ----------------------------------- | --------------------------------------------------------------------------- |
| Operation, Methode und Pfad         | Den Header `Authorization` oder den Schlüssel                               |
| HTTP-Status und `code`              | Den Inhalt von JSON- oder PDF-Exporten (sie enthalten eingeschränkte Daten) |
| `request_id` (oder `X-Request-Id`)  |                                                                             |
| `errors[]` eines `validation_error` |                                                                             |

Wenn Sie den [Support](https://passport.kabin.sh/docs/de/general/support) kontaktieren, nennen Sie die `request_id`: Damit findet sich die
Anfrage in den Serverprotokollen, ohne dass jemand Ihren Schlüssel braucht.
