Idempotente APIs: Robuste Schnittstellen für verteilte Systeme
Zurück zum Blog

Idempotente APIs: Robuste Schnittstellen für verteilte Systeme

6 Min. Lesezeit
Lesen auf English

Warum Idempotenz kein Nice-to-have ist

Wer Microservices betreibt, kennt das Szenario: Ein Client schickt einen POST-Request, die Verarbeitung auf dem Server läuft durch, aber die Response geht auf dem Rückweg verloren -- Netzwerk-Timeout, Load-Balancer-Reset, was auch immer. Der Client weiss nicht, ob der Request angekommen ist. Also schickt er ihn erneut. Und plötzlich existiert die Bestellung doppelt, die Zahlung wurde zweimal ausgelöst, oder der Datensatz ist inkonsistent.

In einem Monolithen mit einer einzigen Datenbank lässt sich das noch über Transaktionen abfangen. In einer Microservice-Architektur, wo Requests durch mehrere Services laufen und jeder seine eigene Datenhaltung hat, wird aus diesem Problem ein architekturelles. Die Lösung: Idempotente APIs. Eine API ist idempotent, wenn derselbe Request -- egal wie oft er gesendet wird -- immer dasselbe Ergebnis erzeugt und keine zusätzlichen Seiteneffekte hat.

Das klingt trivial. In der Umsetzung steckt aber mehr, als man zunächst denkt.

Was HTTP von Haus aus mitbringt

Die HTTP-Spezifikation definiert bereits, welche Methoden idempotent sind und welche nicht. GET, PUT und DELETE sind per Definition idempotent. Ein GET liefert immer dieselben Daten zurück (bei gleichem Zustand). Ein PUT überschreibt eine Ressource -- egal ob einmal oder zehnmal, das Ergebnis ist dasselbe. Ein DELETE entfernt eine Ressource; der zweite Aufruf findet sie nicht mehr und gibt 404 zurück, ändert aber nichts am Systemzustand.

POST ist die Ausnahme. Ein POST erzeugt eine neue Ressource, und jeder Aufruf erzeugt potenziell eine weitere. Genau hier entsteht das Problem mit Retries. Wenn ein Client einen POST /api/orders wiederholt, weil die Response ausblieb, werden zwei Bestellungen angelegt. Bei einem POST /api/payments wird doppelt belastet.

PATCH ist ebenfalls nicht automatisch idempotent, je nach Implementierung. Ein PATCH, der ein Feld auf einen absoluten Wert setzt ({"status": "confirmed"}), ist de facto idempotent. Ein PATCH, der einen relativen Wert anwendet ({"balance": "+100"}), ist es nicht.

Für GET, PUT und DELETE müssen wir in der Regel nichts tun. Für POST -- und das ist die Methode, die in APIs am häufigsten Seiteneffekte erzeugt -- brauchen wir ein explizites Muster.

Das Idempotency-Key-Pattern

Die Idee ist bestechend einfach: Der Client generiert einen eindeutigen Schlüssel pro logische Operation und schickt ihn als HTTP-Header mit. Der Server prüft vor der Verarbeitung, ob dieser Schlüssel bereits verwendet wurde. Wenn ja, gibt er das gespeicherte Ergebnis zurück, ohne die Operation erneut auszuführen.

Stripe hat dieses Muster populär gemacht, und es hat sich als de-facto-Standard für Payment-APIs etabliert. Der Header heisst typischerweise Idempotency-Key:

POST /api/payments HTTP/1.1
Content-Type: application/json
Idempotency-Key: 7a3f8b2e-4d1c-4e5f-9a6b-1c2d3e4f5a6b

{"amount": 4999, "currency": "EUR", "customer_id": "cust_42"}

Der Schlüssel ist eine UUID, die der Client erzeugt. Derselbe Schlüssel bedeutet: dieselbe logische Operation. Ein neuer Schlüssel bedeutet: eine neue Operation. Der Server entscheidet anhand des Schlüssels, ob er verarbeiten oder das cached Ergebnis zurückgeben soll.

Das Elegante an diesem Ansatz: Die Geschäftslogik muss nicht verändert werden. Die Idempotenz-Prüfung lässt sich als Middleware oder Filter implementieren, der vor der eigentlichen Verarbeitung greift.

Implementierung mit Spring Boot und Redis

Für die Speicherung der Idempotency Keys setzen wir auf Redis mit TTL. Redis ist schnell genug, um bei jedem Request eine Prüfung durchzuführen, und der TTL sorgt dafür, dass alte Schlüssel automatisch aufgeräumt werden -- kein Cronjob, kein manuelles Cleanup, kein unbegrenztes Wachstum.

Zunächst der Filter, der jeden Request mit Idempotency-Key abfängt:

@Component
public class IdempotencyFilter extends OncePerRequestFilter {

    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;

    private static final Duration KEY_TTL = Duration.ofHours(24);
    private static final String KEY_PREFIX = "idempotency:";

    public IdempotencyFilter(StringRedisTemplate redisTemplate,
                             ObjectMapper objectMapper) {
        this.redisTemplate = redisTemplate;
        this.objectMapper = objectMapper;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {

        String idempotencyKey = request.getHeader("Idempotency-Key");

        if (idempotencyKey == null || !"POST".equals(request.getMethod())) {
            filterChain.doFilter(request, response);
            return;
        }

        String redisKey = KEY_PREFIX + idempotencyKey;
        String cachedResponse = redisTemplate.opsForValue().get(redisKey);

        if (cachedResponse != null) {
            // Schlüssel existiert bereits -- cached Response zurückgeben
            response.setStatus(HttpServletResponse.SC_CONFLICT);
            response.setContentType("application/json");
            response.getWriter().write(cachedResponse);
            return;
        }

        // Request verarbeiten und Response cachen
        ContentCachingResponseWrapper wrappedResponse =
            new ContentCachingResponseWrapper(response);
        filterChain.doFilter(request, wrappedResponse);

        if (wrappedResponse.getStatus() >= 200
                && wrappedResponse.getStatus() < 300) {
            String body = new String(wrappedResponse.getContentAsByteArray());
            redisTemplate.opsForValue()
                .set(redisKey, body, KEY_TTL);
        }

        wrappedResponse.copyBodyToResponse();
    }
}

Die Logik ist geradlinig: Header prüfen, Redis fragen, bei Treffer 409 Conflict zurückgeben, bei Neuzugang verarbeiten und das Ergebnis cachen. Der TTL von 24 Stunden ist ein pragmatischer Wert -- lang genug, um Retry-Stürme abzufangen, kurz genug, um Redis nicht vollzulaufen.

Der Controller bleibt sauber

Das ist der entscheidende Punkt: Der Controller weiss nichts von Idempotenz. Er implementiert seine Geschäftslogik wie gewohnt. Die hexagonale Trennung von Infrastruktur-Concerns und Fachlogik bleibt erhalten.

@RestController
@RequestMapping("/api/payments")
public class PaymentController {

    private final PaymentService paymentService;

    public PaymentController(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    @PostMapping
    public ResponseEntity<PaymentResponse> createPayment(
            @Valid @RequestBody PaymentRequest request) {
        Payment payment = paymentService.processPayment(request);
        return ResponseEntity
            .status(HttpStatus.CREATED)
            .body(PaymentResponse.from(payment));
    }
}

Kein Idempotency-Check im Controller, kein Redis-Zugriff im Service. Der Filter fängt Duplikate ab, bevor sie den Controller erreichen. Für den Client ist der Vertrag klar: 201 Created bei der ersten Verarbeitung, 409 Conflict bei einem Duplikat.

Alternative: Datenbank-Constraints

Nicht jedes Projekt braucht Redis. Wenn bereits eine relationale Datenbank im Einsatz ist, lässt sich die Idempotenz auch über einen Unique Constraint absichern:

CREATE TABLE idempotency_keys (
    key         VARCHAR(255) PRIMARY KEY,
    response    JSONB NOT NULL,
    created_at  TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_idempotency_keys_created
    ON idempotency_keys (created_at);

Der Unique Constraint auf key verhindert doppelte Einträge auf Datenbankebene. Ein regelmässiger Cleanup-Job entfernt abgelaufene Einträge -- das ist der Nachteil gegenüber Redis, wo der TTL das automatisch erledigt.

Wir nutzen den Datenbank-Ansatz in Services, die ohnehin eine PostgreSQL-Anbindung haben und kein Redis betreiben. Redis ist die bessere Wahl, wenn man viele Requests pro Sekunde verarbeitet und die Latenz der Prüfung minimieren will.

Fehlerbehandlung und Edge Cases

Ein paar Situationen, die in der Praxis auftreten und die man durchdenken sollte.

Request schlägt fehl. Wenn die Verarbeitung mit einem Fehler endet -- etwa einer Validierungsverletzung oder einem internen Fehler -- sollte der Idempotency Key nicht gespeichert werden. Nur erfolgreiche Verarbeitungen werden gecacht. Andernfalls könnte ein Client nach einem transienten Fehler denselben Key nicht erneut verwenden, obwohl die Operation nie durchgeführt wurde. Deshalb prüft unser Filter den Status-Code und speichert nur bei 2xx.

Verschiedene Payloads, gleicher Key. Was passiert, wenn ein Client denselben Idempotency Key mit unterschiedlichem Request-Body sendet? Stripe gibt in diesem Fall einen 422 Unprocessable Entity zurück. Wir handhaben das pragmatischer: Der erste Request gewinnt, der zweite bekommt 409. In der Praxis ist dieses Szenario fast immer ein Client-Bug, kein legitimer Anwendungsfall.

Concurrent Requests. Zwei identische Requests kommen gleichzeitig an. Hier hilft Redis mit SETNX (SET if Not eXists) als atomare Operation:

Boolean wasSet = redisTemplate.opsForValue()
    .setIfAbsent(redisKey, "processing", KEY_TTL);

if (Boolean.FALSE.equals(wasSet)) {
    // Anderer Request wird gerade verarbeitet
    response.setStatus(HttpServletResponse.SC_CONFLICT);
    return;
}

Das setIfAbsent ist atomar -- nur ein Request gewinnt das Rennen. Der andere erhält sofort einen 409 Conflict.

Wann Idempotenz wirklich nötig ist

Nicht jeder Endpoint braucht einen Idempotency Key. Wir setzen das Muster gezielt ein:

  • Payment-Endpoints: Immer. Doppelte Zahlungen sind der schlimmste Fall.
  • Order-Creation: Immer. Doppelte Bestellungen erzeugen realen Schaden.
  • Datenmutationen mit externen Seiteneffekten: Wenn ein POST einen E-Mail-Versand, eine Webhook-Benachrichtigung oder einen Drittanbieter-Aufruf auslöst, sollte er idempotent sein.
  • Interne Service-to-Service-Calls: Wenn Service A Service B über HTTP aufruft und ein Retry-Mechanismus eingebaut ist, braucht Service B Idempotenz-Schutz.

Reine CRUD-Endpoints ohne kritische Seiteneffekte -- etwa das Anlegen eines Kommentars oder das Speichern eines Entwurfs -- können oft ohne Idempotency Key auskommen. Der doppelte Kommentar ist ärgerlich, aber kein finanzieller Schaden.

Fazit

Idempotenz ist kein exotisches Pattern. Es ist eine Grundvoraussetzung für robuste APIs in verteilten Systemen. Das Idempotency-Key-Pattern -- ein Header, ein Redis-Lookup, ein TTL -- ist einfach genug, um es als Middleware in jeden Service einzubauen, ohne die Geschäftslogik zu berühren.

Der Aufwand ist gering: ein Servlet-Filter, eine Redis-Anbindung, ein paar Zeilen Konfiguration. Der Gewinn ist erheblich: Clients können bedenkenlos Retries durchführen, Netzwerkprobleme führen nicht zu inkonsistenten Daten, und das System verhält sich vorhersagbar -- auch wenn die Infrastruktur es gerade nicht tut.

Wer Microservices baut, sollte Idempotenz von Anfang an mitdenken. Nicht als Optimierung, sondern als Teil des API-Designs.

Teilen

Patrick Hütter

Geschrieben von

Patrick Hütter

Gründer & Software-Architekt

Softwarearchitekt, Ingenieur und Unternehmer. Patrick baut seit über einem Jahrzehnt Produkte und Plattformen — von Enterprise-Backends über Cloud-Native-Infrastruktur bis hin zu KI-gestützten Anwendungen. Als Gründer von encircle360 verbindet er technische Tiefe mit unternehmerischem Denken und treibt Open-Source-Projekte voran, die echten Impact haben.