Zum Inhalt
wannacode

API-Design: Vertrag, Versionen, wiederholte Anfragen

Kurz gesagt: Eine gute API ist ein Vertrag: Ressourcen, Methoden, Statuscodes und Felder, auf die sich der Client verlässt; ein Idempotency-Key macht eine wiederholte Anfrage sicher, und Versionen fügen hinzu, ohne etwas zu brechen.

Problem

Doppelt bezahlt

Eine Kundin bezahlt Bestellung 1001 in der Handy-App. Die App schickt eine Anfrage an die API des Shops, der Server bucht 120 € ab, aber die Verbindung bricht ab, bevor die Antwort ankommt. Die App weiß nicht, ob die Zahlung durchging. Also tut sie, was jeder vernünftige Client tut: Sie versucht es noch einmal.

So bucht der Server ab:

class PaymentApi implements Api {
final Payments payments = new Payments();
public Response handle(Request req) {
return payments.charge(req.order(), req.amount());
}
}

Und die App versucht es bis zu dreimal:

static Response pay(Api api, int order, int amount) {
String path = "/orders/" + order + "/payments";
var req = new Request("POST", path, null, amount);
for (int attempt = 1; attempt <= 3; attempt++) {
Response res = network.send(api, req);
if (res != null) {
System.out.println("Versuch " + attempt + ": " + res);
return res;
}
System.out.println("Versuch " + attempt + ": die Antwort geht verloren");
}
return null;
}

Das Netzwerk ist ein aufgezeichnetes Skript: Die Antwort auf den ersten Aufruf geht verloren. Der Server hat die Arbeit erledigt; nur die Antwort geht verloren.

var api = new PaymentApi();
pay(api, 1001, 120);
System.out.println(api.payments.report());
Ausgabe
Versuch 1: die Antwort geht verloren
Versuch 2: 201 bezahlt 1001 (pay-2)
Zahlungen: 2, gesamt 240 €

Die Kundin sieht einmal „bezahlt“, und 240 € sind von ihrer Karte abgegangen. Der Server kann nicht erkennen, dass die zweite Anfrage dieselbe Zahlung ist: Für ihn ist das eine zweite Zahlung. Eine verlorene Antwort ist nicht selten: ein Mobilfunknetz in der U-Bahn, ein Aufzug, ein Timeout im Proxy.

Die Idee

Eine API ist ein Vertrag

Eine API ist ein Versprechen an ihre Clients: Was liegt unter welcher Adresse, was nimmt sie an und was gibt sie zurück. Der Client wird gegen dieses Versprechen geschrieben, oft von einem anderen Team oder einer anderen Firma, und kann sich nicht gleichzeitig mit dem Server ändern. Dieses Versprechen ist der API-Vertrag.

In einer HTTP-API besteht der Vertrag aus ein paar Teilen:

  • Eine Ressource ist etwas mit einer Adresse: /orders/1001 ist eine Bestellung, /orders/1001/payments sind ihre Zahlungen. Die Adresse nennt eine Sache, keine Handlung: /payments, nicht /makePayment;
  • Die Methode sagt, was mit der Ressource geschieht: GET liest, POST erzeugt, PUT schreibt das Ganze, PATCH ändert einen Teil, DELETE löscht;
  • Der Status sagt, wie es gelaufen ist: 200 in Ordnung, 201 erzeugt, 400 fehlerhafte Anfrage, 404 keine solche Ressource, 409 Konflikt, 422 die Anfrage ist verstanden, kann aber nicht ausgeführt werden, 5xx der Fehler liegt im Server;
  • Die Felder der Anfrage und der Antwort, mit ihren Namen und Typen.

GET, PUT und DELETE sind nach den Regeln von HTTP idempotent: Beim zweiten Mal geben sie dasselbe Ergebnis. Deshalb wiederholen Clients und Proxys sie bedenkenlos. POST ist es nicht: Jedes POST erzeugt etwas Neues. Eine Zahlung ist ein POST, also bucht eine Wiederholung noch einmal ab.

Wie eine HTTP-Anfrage und eine Antwort aussehen, hast du in der Lektion Netzwerkprogrammierung gesehen.

Sieh es dir an

Anfrage, Antwort und Netzwerk im Speicher

Hier gibt es keinen HTTP-Server. Die API ist ein Objekt mit einer Methode handle: Sie nimmt eine Anfrage und gibt eine Antwort zurück. Die Anfrage trägt eine Methode, einen Pfad, einen Schlüssel und einen Betrag, die Antwort einen Status und einen Text:

record Request(String method, String path, String key, int amount) {
// /orders/1001/payments → 1001
int order() {
return Integer.parseInt(path.split("/")[2]);
}
}
record Response(int status, String body) {
public String toString() {
return status + " " + body;
}
}
interface Api {
Response handle(Request req);
}

Das Netzwerk reicht die Anfrage an die API weiter und verliert laut Skript die Antwort:

// ein aufgezeichnetes Szenario: bei welchen Aufrufen die Antwort verloren geht
class Network {
private final Set<Integer> lost;
private int calls = 0;
Network(Integer... lost) {
this.lost = Set.of(lost);
}
Response send(Api api, Request req) {
calls++;
Response res = api.handle(req);
// der Server hat die Arbeit erledigt; nur die Antwort geht verloren
return lost.contains(calls) ? null : res;
}
}

Die Zahlungen merken sich die Beträge:

class Payments {
private final List<Integer> amounts = new ArrayList<>();
Response charge(int order, int amount) {
amounts.add(amount);
int n = amounts.size();
return new Response(201, "bezahlt " + order + " (pay-" + n + ")");
}
String report() {
int sum = amounts.stream().mapToInt(Integer::intValue).sum();
return "Zahlungen: " + amounts.size() + ", gesamt " + sum + " €";
}
}

Die Idee

Ein Idempotency-Key

Der Server muss „dieselbe Zahlung, noch einmal versucht“ von „eine neue Zahlung“ unterscheiden. Allein kann er das nicht: Die beiden Anfragen sehen gleich aus. Also sagt es ihm der Client. Vor dem ersten Versuch erzeugt der Client einen eindeutigen Idempotency-Key (meist eine UUID) und schickt ihn bei jedem Versuch mit, im Header Idempotency-Key.

Der Server führt „Schlüssel → Antwort“. Ist der Schlüssel neu, erledigt der Server die Arbeit und merkt sich die Antwort. Wurde der Schlüssel schon gesehen, tut der Server nichts und gibt die gespeicherte Antwort zurück. Der Client bekommt dasselbe, was er beim ersten Mal bekommen hätte.

Das ist die Idee des idempotenten Consumers aus der Lektion zu Ereignissen, zum Client hin gedreht: Dort merkt sich der Consumer die Ereignis-ID, hier merkt sich der Server den Schlüssel der Anfrage.

Sieh es dir an

Die wiederholte Zahlung mit Schlüssel

Der Client erzeugt den Schlüssel einmal, vor der Schleife:

static int keys = 0;
static Response payWithKey(Api api, int order, int amount) {
// ein Schlüssel für alle Versuche; ein echter Client nimmt eine UUID
String key = "k-" + (++keys);
String path = "/orders/" + order + "/payments";
var req = new Request("POST", path, key, amount);
for (int attempt = 1; attempt <= 3; attempt++) {
Response res = network.send(api, req);
if (res != null) {
System.out.println("Versuch " + attempt + ": " + res);
return res;
}
System.out.println("Versuch " + attempt + ": die Antwort geht verloren");
}
return null;
}

Der Server prüft den Schlüssel, bevor er abbucht:

class IdempotentApi implements Api {
final Payments payments = new Payments();
// Schlüssel → die Antwort, die schon gegeben wurde
final Map<String, Response> done = new HashMap<>();
public Response handle(Request req) {
if (req.key() == null)
return new Response(400, "kein Idempotency-Key");
Response saved = done.get(req.key());
if (saved != null) return saved;
var res = payments.charge(req.order(), req.amount());
done.put(req.key(), res);
return res;
}
}

Dasselbe Skript: Die erste Antwort geht verloren.

var api = new IdempotentApi();
payWithKey(api, 1001, 120);
System.out.println(api.payments.report());
Ausgabe
Versuch 1: die Antwort geht verloren
Versuch 2: 201 bezahlt 1001 (pay-1)
Zahlungen: 1, gesamt 120 €

class IdempotentApi implements Api {
final Payments payments = new Payments();
// Schlüssel → die Antwort, die schon gegeben wurde
final Map<String, Response> done = new HashMap<>();
public Response handle(Request req) {
if (req.key() == null)
return new Response(400, "kein Idempotency-Key");
Response saved = done.get(req.key());
if (saved != null) return saved;
var res = payments.charge(req.order(), req.amount());
done.put(req.key(), res);
return res;
}
}
Schritt für Schritt, in Worten
  1. Szenario 1, ohne Schlüssel: Das Handy schickt POST /orders/1001/payments über 120 €.
  2. Der Server bucht 120 € ab (pay-1) und antwortet mit 201.
  3. Die Verbindung bricht ab: Die Antwort geht unterwegs verloren. Das Handy weiß nicht, ob die Zahlung durchging.
  4. Das Handy versucht es noch einmal mit derselben Anfrage.
  5. Der Server erkennt nicht, dass es dieselbe Zahlung ist: Er bucht noch einmal 120 € ab (pay-2). Zusammen 240 €.
  6. Szenario 2, mit Schlüssel: Der Client erzeugt den Schlüssel k-1 einmal und schickt ihn bei jedem Versuch mit.
  7. Der Schlüssel ist neu: Der Server bucht 120 € ab und merkt sich k-1 → seine Antwort.
  8. Die Antwort geht wieder verloren.
  9. Der neue Versuch trägt denselben Schlüssel, k-1.
  10. Der Server findet k-1 und gibt die gespeicherte Antwort pay-1 zurück, ohne noch einmal abzubuchen.
  11. Ohne Schlüssel: 2 Zahlungen, 240 €. Mit Schlüssel: 1 Zahlung, 120 €.

Der zweite Versuch bekommt pay-1, dieselbe Antwort wie der erste, und das Geld wird einmal abgebucht. Ohne Schlüssel lehnt der Server mit 400 ab: Eine Zahlung ohne Schlüssel lässt sich nicht sicher wiederholen, also nimmt diese API sie nicht an. Manche APIs (etwa Stripe) nehmen auch Anfragen ohne Schlüssel an, versprechen dann aber bei einer Wiederholung nichts.

Probier es aus

Finde den Fehler

Ein Kollege baut den Client um, und der Schlüssel landet in der Schleife. Die API ist dieselbe, mit Schlüssel:

Ausgabe
Versuch 1: die Antwort geht verloren
Versuch 2: 201 bezahlt 1001 (pay-2)
Zahlungen: 2, gesamt 240 €

Wieder zwei Zahlungen. In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Eine neue Version, ohne alte Clients zu brechen

Die Handy-App vom letzten Monat ist noch auf Tausenden von Handys und wird heute nicht aktualisiert. Jede Änderung der API muss auch mit ihr funktionieren. Kann die Änderung einen alten Client brechen? Prüfe den Vertrag der Antwort: Jedes alte Feld muss da sein, mit demselben Typ.

// der Vertrag der Antwort: Feldname und Typ
record Field(String name, String type) {}
static String check(List<Field> old, List<Field> next) {
List<String> problems = new ArrayList<>();
for (Field f : old) {
Field now = next.stream()
.filter(n -> n.name().equals(f.name()))
.findFirst().orElse(null);
if (now == null) problems.add("fehlt " + f.name());
else if (!now.type().equals(f.type()))
problems.add(f.name() + ": " + f.type() + " → " + now.type());
}
if (problems.isEmpty()) return "✓ bricht nichts";
return "✗ bricht: " + String.join(", ", problems);
}
var id = new Field("id", "string");
var status = new Field("status", "string");
var v1 = List.of(id, status, new Field("amount", "number"));
var added = new ArrayList<>(v1);
added.add(new Field("currency", "string"));
var renamed = List.of(id, status, new Field("total", "number"));
var retyped = List.of(id, status, new Field("amount", "string"));
System.out.println("Feld currency hinzugefügt: " + check(v1, added));
System.out.println("amount heißt jetzt total: " + check(v1, renamed));
System.out.println("amount ist jetzt Text: " + check(v1, retyped));
Ausgabe
Feld currency hinzugefügt: ✓ bricht nichts
amount heißt jetzt total: ✗ bricht: fehlt amount
amount ist jetzt Text: ✗ bricht: amount: number → string

Die Regeln, die daraus folgen:

  • hinzufügen, nicht entfernen. Ein neues Feld in der Antwort ist sicher: Der alte Client liest es nicht. Ein entferntes, umbenanntes oder im Typ geändertes Feld bricht;
  • in der Anfrage ist es umgekehrt. Ein neues Pflichtfeld in der Anfrage bricht alte Clients, weil sie es nicht schicken. Ein neues Feld muss optional sein, mit einem Standardwert;
  • der Client sei tolerant. Ein Client, der bei einem unbekannten Feld oder einem neuen Wert in einer Liste von Statuswerten scheitert, bricht sich selbst. Der Client liest nur, was er braucht, und überspringt den Rest;
  • eine brechende Änderung ist eine neue Version. /v2/orders neben /v1/orders, beide in Betrieb, bis die alten Clients umgezogen sind. Die alte Version wird mit einem Datum als veraltet angekündigt und dann abgeschaltet.

Die Idee

Blättern: Offset oder Cursor

Es gibt Tausende von Bestellungen, und die App zeigt 20 auf einmal. Das einfachste Blättern ist Offset: „überspringe die ersten 40, gib mir 20“. Der Cursor ist das Gegenstück: „gib mir 20 nach Bestellung 1007“. Der Cursor ist die letzte ID der vorherigen Seite, und der Server gibt ihn in der Antwort zurück, damit der Client die nächste Seite anfordern kann.

// die Bestellungen, die neuesten zuerst
static List<Integer> orders = new ArrayList<>(List.of(1009, 1008, 1007, 1006, 1005, 1004, 1003, 1002, 1001));
static List<Integer> byOffset(int offset, int limit) {
return orders.stream().skip(offset).limit(limit).toList();
}
// der Cursor: die letzte id der vorherigen Seite
static List<Integer> afterCursor(int cursor, int limit) {
return orders.stream().filter(id -> id < cursor).limit(limit).toList();
}

Der Client liest die erste Seite, und zwischen den beiden Seiten kommt eine neue Bestellung dazu:

var page1 = byOffset(0, 3);
System.out.println("Seite 1: " + page1);
orders.add(0, 1010);
System.out.println("eine neue Bestellung kommt 1010");
System.out.println("offset, Seite 2: " + byOffset(3, 3));
int cursor = page1.get(page1.size() - 1);
System.out.println("cursor, Seite 2: " + afterCursor(cursor, 3));
Ausgabe
Seite 1: [1009, 1008, 1007]
eine neue Bestellung kommt 1010
offset, Seite 2: [1007, 1006, 1005]
cursor, Seite 2: [1006, 1005, 1004]

Mit Offset kommt Bestellung 1007 zweimal: Die neue Bestellung hat alles nach unten geschoben. Mit Cursor beginnt die zweite Seite direkt nach 1007, egal was oben dazukam.

Der Cursor ist auch in der Datenbank schnell. OFFSET 10000 lässt die Datenbank 10.000 Zeilen lesen und wegwerfen, bevor sie 20 zurückgibt. WHERE id < 1007 ORDER BY id DESC LIMIT 20 springt über den Index direkt an die Stelle. Der Preis des Cursors: Du kannst nicht zu Seite 7 springen, und die Zahl der Seiten gibt es nicht geschenkt.

In einer echten API ist der Cursor ein undurchsichtiger String (zum Beispiel "eyJpZCI6MTAwN30", das kodierte {"id":1007}), damit sich der Client nicht darauf verlässt, was drin steckt, und der Server ihn ändern kann.

Probier es aus

Sag die Ausgabe voraus

Jetzt wird Bestellung 1008 zwischen den beiden Seiten gelöscht.

Die letzte Zeile der Ausgabe ist:

var page1 = byOffset(0, 3);
System.out.println("Seite 1: " + page1);
orders.remove(Integer.valueOf(1008));
System.out.println("Bestellung 1008 ist gelöscht");
System.out.println("offset, Seite 2: " + byOffset(3, 3));
Antwort

Die Idee

Wann das wehtut

Ein Idempotency-Key kostet eine Tabelle mit Schlüsseln, eine Prüfung bei jeder Anfrage und Aufräumen (Schlüssel werden meist etwa einen Tag aufbewahrt). Füge ihn nicht hinzu:

  • bei Anfragen, die schon idempotent sind. GET, PUT mit einem Wert, DELETE tun beim Wiederholen nichts Zweites;
  • wo doppeltes Ausführen nicht schadet. „Als gelesen markieren“ zweimal ist dasselbe wie einmal.

Ein Cursor ist unnötig, wenn die Liste klein ist und sich kaum ändert (die Kategorien des Shops) oder wenn Leute wirklich zu Seite 7 springen (eine Tabelle im Admin-Bereich). Dort ist Offset einfacher und ehrlich.

Eine API-Version ist unnötig, solange alle Clients im selben Repository liegen und zusammen mit dem Server ausgeliefert werden: Dann ändere sie zusammen. Versionen sind für Clients, die du nicht kontrollierst. Jede unterstützte Version ist Code, den du testen und pflegen musst, also mach kein v2 für eine Änderung, die kompatibel sein kann.

Und jage nicht um jeden Preis dem „reinen REST“ hinterher. POST /orders/1001/cancel ist klarer als eine künstliche Ressource „Stornierung“. Der Vertrag muss klar und stabil sein, nicht schön.

Wie ein API-Gateway vor den Services betrieben wird (TLS, Rate Limiting, Routen), ist ein Thema für den DevOps-Kurs ZeroToOps.

Genauer, für Neugierige

Die Standards. Der Header Idempotency-Key ist in einem IETF-Entwurf beschrieben („The Idempotency-Key HTTP Header Field“): Derselbe Schlüssel mit einer anderen Anfrage ist 422, und eine Wiederholung, während die erste Anfrage noch läuft, ist 409. Stripe, Adyen und PayPal nutzen Schlüssel für ihre Zahlungen. Fehler in JSON folgen meist RFC 9457 (Problem Details): type, title, status, detail.

Der Vertrag als Datei. OpenAPI beschreibt die Pfade, Felder und Statuscodes in YAML. Daraus werden Clients und Dokumentation erzeugt, und Werkzeuge wie oasdiff vergleichen zwei Versionen und sagen, welche Änderung bricht, genau wie check in dieser Lektion. Pact prüft den Vertrag von der Seite des Clients (Consumer-driven Contract Tests).

Nach Sprache. Spring Boot: @RestController, und springdoc-openapi baut OpenAPI aus dem Code. Spring Data hat Slice und Pageable für Offset und ScrollPosition für einen Cursor.

Nicht nur REST. GraphQL hat eine Adresse, und der Client sagt, welche Felder er will, deshalb ist das Hinzufügen von Feldern natürlich, und Cursor stehen im Standard (Relay Connections). gRPC beschreibt den Vertrag in .proto-Dateien mit nummerierten Feldern: Ein neues Feld mit neuer Nummer bricht nichts, eine wiederverwendete Nummer bricht.

Ein Cursor bei gleichen Werten im Schlüssel. Sortierst du nach Datum, können zwei Bestellungen dasselbe Datum haben. Der Cursor ist dann das Paar (Datum, ID), sonst gehen an der Grenze zwischen den Seiten Zeilen verloren.

Übung

Derselbe Schlüssel, eine andere Zahlung

Löse die Aufgabe in deinem Editor.

Ein fehlerhafter Client schickt den Schlüssel k-1 für eine Zahlung über 120 €, dann denselben Schlüssel für eine Zahlung über 90 €. Jetzt gibt der Server die gespeicherte Antwort für 120 € zurück, und der Client glaubt, 90 € bezahlt zu haben. Ändere IdempotentApi so, dass sie sich auch die Anfrage merkt: Derselbe Schlüssel mit derselben Anfrage gibt die gespeicherte Antwort zurück, derselbe Schlüssel mit einer anderen Anfrage ist 422. Schicke drei Anfragen (120 €, noch einmal 120 €, 90 €) und den Bericht:

Ausgabe
201 bezahlt 1001 (pay-1)
201 bezahlt 1001 (pay-1)
422 der Schlüssel k-1 gehört zu einer anderen Anfrage
Zahlungen: 1, gesamt 120 €

Weitere Aufgaben

  • Ablauf. Speichere zum Schlüssel einen Erstellungs-Tick. Ein Schlüssel, der älter als 100 Ticks ist, wird vergessen, und eine Anfrage mit ihm ist neu.
  • Antwort auf einen Fehler. Wenn die Zahlung einen Fehler wirft (die Karte wird abgelehnt), wird die Antwort dann gemerkt? Entscheide und begründe: Soll eine Wiederholung nach 402 es noch einmal versuchen?
  • Ein Cursor nach Datum. Mach aus den Bestellungen Paare (Datum, ID) mit zwei Bestellungen am selben Datum und eine Seitengröße von 1. Zeige, dass ein Cursor allein nach Datum eine Bestellung verliert und ein Cursor (Datum, ID) nicht.

Test

Teste dich

1. Die Antwort auf ein POST für eine Zahlung geht verloren. Was soll der Client tun, um sicher zu wiederholen?
2. Welche dieser Änderungen an der Antwort bricht alte Clients NICHT?
3. Zwischen zwei Offset-Seiten kommt oben eine neue Bestellung dazu. Was sieht der Client auf der zweiten Seite?
4. Wann braucht man keinen Idempotency-Key?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·