# Wiederholungen und Idempotenz

> Welche Anfragen Sie nach einem Timeout gefahrlos wiederholen können und wie Sie doppelte Ereignisse vermeiden.

Wenn eine Anfrage in einen Timeout läuft oder die Verbindung abbricht, wissen Sie nicht, ob BatteryPass sie
ausgeführt hat. Ob Sie sie einfach erneut senden können, hängt von der Operation ab.

## Überblick

| Operationen                                                                                                  | Methode | Gefahrlos wiederholbar          | Warum                                                                                               |
| ------------------------------------------------------------------------------------------------------------ | ------- | ------------------------------- | --------------------------------------------------------------------------------------------------- |
| Alle Lesezugriffe, auch öffentliche Abrufe von Pässen                                                        | `GET`   | Ja                              | Sie ändern nichts.                                                                                  |
| [`updateCondition`](https://passport.kabin.sh/docs/de/api-reference/lifecycle/update-condition)            | `PATCH` | Ja, mit demselben `measured_at` | Eine Messung, die gleich alt oder älter als die gespeicherte ist, wird übersprungen und aufgeführt. |
| [`createLifecycleEvent`](https://passport.kabin.sh/docs/de/api-reference/lifecycle/create-lifecycle-event) | `POST`  | Nein                            | Jeder erfolgreiche Aufruf erfasst ein neues Ereignis.                                               |

Eine mit `429` beantwortete Anfrage wurde nicht ausgeführt und lässt sich nach `Retry-After` immer wiederholen
(siehe [Ratenlimits](https://passport.kabin.sh/docs/de/rate-limits)). Eine Antwort `4xx` heißt, dass die Anfrage als Ganzes abgelehnt
wurde: Korrigieren Sie sie, statt sie zu wiederholen.

## Lesezugriffe

Jedes `GET` der API können Sie beliebig oft wiederholen. Wiederholen Sie Netzwerkfehler und Antworten `5xx`
mit exponentiellem Backoff.

## Zustandsdaten

`updateCondition` führt Messwerte mit den Zustandsdaten der Einheit zusammen. Für jeden Wert vergleicht
BatteryPass sein `measured_at` mit dem des gespeicherten Werts:

- **neuer** als die gespeicherte Messung oder gleicher Zeitpunkt mit anderem Wert: gespeichert, aufgeführt in
  `accepted`;
- **gleicher Zeitpunkt und gleicher Wert**: übersprungen mit dem Grund `unchanged`;
- **älter** als die gespeicherte Messung: übersprungen mit dem Grund `older_measurement`.

Eine wiederholte Anfrage mit demselben Body ändert also nichts. Senden Sie zum Beispiel:

```json title="Request"
{
  "measured_at": "2026-10-24T02:00:00Z",
  "values": [
    { "field_key": "condition.state_of_charge", "value": 87.5 },
    { "field_key": "condition.number_of_full_cycles", "value": 412 }
  ]
}
```

Wurde der erste Versuch gespeichert, antwortet die Wiederholung:

```json title="200"
{
  "accepted": [],
  "skipped": [
    { "field_key": "condition.state_of_charge", "reason": "unchanged" },
    { "field_key": "condition.number_of_full_cycles", "reason": "unchanged" }
  ],
  "publication": { "outcome": "not_published" }
}
```

Diese Anfrage erfasst kein neues Lebenszyklusereignis und veröffentlicht nichts: `not_published` heißt hier,
dass diese Anfrage nichts veröffentlicht hat; der Pass behält seine aktuelle Version.

> [!WARNING]
> Setzen Sie `measured_at` auf den Zeitpunkt der Messung, zum Beispiel den Zeitpunkt, zu dem Ihr
> Batteriemanagementsystem den Wert erfasst hat, und behalten Sie ihn bei einer Wiederholung bei. Ein
> `measured_at` aus der Uhrzeit beim Senden macht jede Wiederholung zu einer neuen Messung.

## Lebenszyklusereignisse

`createLifecycleEvent` erkennt keine Duplikate: Jeder erfolgreiche Aufruf erfasst ein neues Ereignis, und die
veröffentlichte API kennt keinen Idempotenzschlüssel. Prüfen Sie nach einem Timeout, einem Verbindungsabbruch
oder einer Antwort `5xx`, ob das Ereignis angekommen ist, bevor Sie es erneut senden:

1. Lesen Sie [`listLifecycleEvents`](https://passport.kabin.sh/docs/de/api-reference/lifecycle/list-lifecycle-events) für die Einheit
   (neueste zuerst).
2. Suchen Sie ein Ereignis mit `source: "api"` und Ihrem `type`, `occurred_on` und `description`.
3. Senden Sie das Ereignis nur erneut, wenn es fehlt.

Schreiben Sie eine eigene Referenz in `description`, etwa die Nummer des Serviceauftrags, damit sich das
Ereignis leicht wiederfinden lässt. BatteryPass speichert die Beschreibung ohne führende und nachfolgende
Leerzeichen.

```ts title="TypeScript"
type NewEvent = { type: string; occurred_on: string; description: string };
type LifecycleEvent = NewEvent & { id: string; source: string };

const base = `${process.env.BATTERYPASS_URL}/api/v1`;
const headers = {
  Authorization: `Bearer ${process.env.BATTERYPASS_API_KEY}`,
  'Content-Type': 'application/json',
};

// Sucht ein Ereignis, das diese Integration schon erfasst hat (gleicher Typ, Tag und Beschreibung).
async function findEvent(batteryId: string, e: NewEvent): Promise<LifecycleEvent | undefined> {
  let cursor: string | null = null;
  do {
    const query = new URLSearchParams({ limit: '200' });
    if (cursor) query.set('cursor', cursor);
    const res = await fetch(`${base}/batteries/${batteryId}/lifecycle-events?${query}`, { headers });
    if (!res.ok) throw new Error(`listLifecycleEvents: ${res.status}`);
    const page: { items: LifecycleEvent[]; next_cursor: string | null } = await res.json();
    const hit = page.items.find(
      (x) => x.source === 'api' && x.type === e.type && x.occurred_on === e.occurred_on && x.description === e.description,
    );
    if (hit) return hit;
    // Neueste zuerst: Sobald eine Seite ältere Tage erreicht, ist das Ereignis nicht da.
    if (page.items.some((x) => x.occurred_on < e.occurred_on)) return undefined;
    cursor = page.next_cursor;
  } while (cursor);
  return undefined;
}

async function recordEvent(batteryId: string, e: NewEvent): Promise<LifecycleEvent> {
  for (let attempt = 1; attempt <= 3; attempt++) {
    let res: Response | undefined;
    try {
      res = await fetch(`${base}/batteries/${batteryId}/lifecycle-events`, {
        method: 'POST',
        headers,
        body: JSON.stringify(e),
        signal: AbortSignal.timeout(30_000),
      });
    } catch {
      res = undefined; // Timeout oder Verbindungsfehler
    }
    if (res?.status === 201) return res.json();
    if (res && res.status < 500) throw new Error(`createLifecycleEvent: ${res.status} ${await res.text()}`);
    // Keine Antwort oder 5xx: Das Ereignis ist vielleicht trotzdem erfasst.
    const existing = await findEvent(batteryId, e);
    if (existing) return existing;
  }
  throw new Error('createLifecycleEvent: nach 3 Versuchen kein Erfolg');
}

const event = await recordEvent('0192f0a3-1b7c-7e2a-8f45-6a9d0c3e5b12', {
  type: 'inspection',
  occurred_on: '2026-10-24',
  description: 'Serviceauftrag SO-2026-0815: jährliche Inspektion',
});
console.log(event.id);
```

```python title="Python"
import os

import requests

base = f"{os.environ['BATTERYPASS_URL']}/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['BATTERYPASS_API_KEY']}"


def find_event(battery_id, event):
    """Sucht ein Ereignis, das diese Integration schon erfasst hat (gleicher Typ, Tag und Beschreibung)."""
    params = {"limit": 200}
    while True:
        res = session.get(f"{base}/batteries/{battery_id}/lifecycle-events", params=params, timeout=30)
        res.raise_for_status()
        page = res.json()
        for item in page["items"]:
            if (item["source"] == "api" and item["type"] == event["type"]
                    and item["occurred_on"] == event["occurred_on"]
                    and item.get("description") == event["description"]):
                return item
        # Neueste zuerst: Sobald eine Seite ältere Tage erreicht, ist das Ereignis nicht da.
        if any(item["occurred_on"] < event["occurred_on"] for item in page["items"]) or not page["next_cursor"]:
            return None
        params["cursor"] = page["next_cursor"]


def record_event(battery_id, event, attempts=3):
    for _ in range(attempts):
        try:
            res = session.post(f"{base}/batteries/{battery_id}/lifecycle-events", json=event, timeout=30)
        except (requests.Timeout, requests.ConnectionError):
            res = None  # Timeout oder Verbindungsfehler
        if res is not None and res.status_code == 201:
            return res.json()
        if res is not None and res.status_code < 500:
            raise RuntimeError(f"createLifecycleEvent: {res.status_code} {res.text}")
        # Keine Antwort oder 5xx: Das Ereignis ist vielleicht trotzdem erfasst.
        existing = find_event(battery_id, event)
        if existing:
            return existing
    raise RuntimeError(f"createLifecycleEvent: nach {attempts} Versuchen kein Erfolg")


event = record_event("0192f0a3-1b7c-7e2a-8f45-6a9d0c3e5b12", {
    "type": "inspection",
    "occurred_on": "2026-10-24",
    "description": "Serviceauftrag SO-2026-0815: jährliche Inspektion",
})
print(event["id"])
```

```go title="Go"
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"os"
	"time"
)

type newEvent struct {
	Type        string `json:"type"`
	OccurredOn  string `json:"occurred_on"`
	Description string `json:"description"`
}

type lifecycleEvent struct {
	ID          string `json:"id"`
	Type        string `json:"type"`
	OccurredOn  string `json:"occurred_on"`
	Description string `json:"description"`
	Source      string `json:"source"`
}

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

// send ruft die API auf und dekodiert eine erfolgreiche JSON-Antwort in out; es liefert den HTTP-Status.
func send(method, path string, body, out any) (int, error) {
	var buf bytes.Buffer
	if body != nil {
		if err := json.NewEncoder(&buf).Encode(body); err != nil {
			return 0, err
		}
	}
	req, err := http.NewRequest(method, base+path, &buf)
	if err != nil {
		return 0, err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("BATTERYPASS_API_KEY"))
	req.Header.Set("Content-Type", "application/json")
	res, err := client.Do(req)
	if err != nil {
		return 0, err
	}
	defer res.Body.Close()
	if res.StatusCode >= 300 {
		return res.StatusCode, nil
	}
	return res.StatusCode, json.NewDecoder(res.Body).Decode(out)
}

// findEvent sucht ein Ereignis, das diese Integration schon erfasst hat (gleicher Typ, Tag und Beschreibung).
func findEvent(batteryID string, e newEvent) (*lifecycleEvent, error) {
	query := url.Values{"limit": {"200"}}
	for {
		var page struct {
			Items      []lifecycleEvent `json:"items"`
			NextCursor *string          `json:"next_cursor"`
		}
		status, err := send(http.MethodGet, "/batteries/"+batteryID+"/lifecycle-events?"+query.Encode(), nil, &page)
		if err != nil {
			return nil, err
		}
		if status != http.StatusOK {
			return nil, fmt.Errorf("listLifecycleEvents: %d", status)
		}
		older := false
		for _, x := range page.Items {
			if x.Source == "api" && x.Type == e.Type && x.OccurredOn == e.OccurredOn && x.Description == e.Description {
				return &x, nil
			}
			older = older || x.OccurredOn < e.OccurredOn
		}
		// Neueste zuerst: Sobald eine Seite ältere Tage erreicht, ist das Ereignis nicht da.
		if older || page.NextCursor == nil {
			return nil, nil
		}
		query.Set("cursor", *page.NextCursor)
	}
}

func recordEvent(batteryID string, e newEvent) (*lifecycleEvent, error) {
	for attempt := 1; attempt <= 3; attempt++ {
		var created lifecycleEvent
		status, err := send(http.MethodPost, "/batteries/"+batteryID+"/lifecycle-events", e, &created)
		if err == nil && status == http.StatusCreated {
			return &created, nil
		}
		if err == nil && status < 500 {
			return nil, fmt.Errorf("createLifecycleEvent: %d", status)
		}
		// Keine Antwort oder 5xx: Das Ereignis ist vielleicht trotzdem erfasst.
		existing, err := findEvent(batteryID, e)
		if err != nil {
			return nil, err
		}
		if existing != nil {
			return existing, nil
		}
	}
	return nil, errors.New("createLifecycleEvent: nach 3 Versuchen kein Erfolg")
}

func main() {
	e, err := recordEvent("0192f0a3-1b7c-7e2a-8f45-6a9d0c3e5b12", newEvent{
		Type:        "inspection",
		OccurredOn:  "2026-10-24",
		Description: "Serviceauftrag SO-2026-0815: jährliche Inspektion",
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(e.ID)
}
```

Die Beispiele behandeln ein `429` wie jede andere Ablehnung; kombinieren Sie sie im Betrieb mit dem Backoff aus
[Ratenlimits](https://passport.kabin.sh/docs/de/rate-limits).

## Der Header Idempotency-Key

BatteryPass kennt einen Header `Idempotency-Key`, aber nur bei Operationen des Portals wie dem Veröffentlichen
und Massenjobs. Keine der veröffentlichten Operationen akzeptiert ihn; senden Sie ihn also nicht an die API,
dort bewirkt er nichts.
