iOS-Offline-Schreibaufträge auf einem Cloud-Mac idempotent wiederholen

iOS-Offline-Schreibaufträge auf einem Cloud-Mac idempotent wiederholen

Ein Nutzer schickt in der U-Bahn ein Ticket ab, doch die App meldet einen Netzwerkfehler. Sobald wieder eine Verbindung besteht, sendet die iOS-App die Anfrage erneut – und auf dem Server liegen plötzlich zwei Tickets. Die Ursache muss nicht in der Warteschlange liegen: Möglicherweise wurde die erste Anfrage bereits gespeichert, nur die Erfolgsmeldung erreichte das Smartphone nicht. Wer Offline-Schreibvorgänge abnehmen will, muss zwischen „Anfrage fehlgeschlagen“ und „Schreibvorgang fehlgeschlagen“ unterscheiden.

Zuerst die Identität einer Operation festlegen

Jede Übermittlung durch den Nutzer wird als dauerhafter Auftrag gespeichert: mit Operations-ID, Anfrageinhalt, Idempotenzschlüssel, Erstellungszeitpunkt und Status. Der Inhalt steht beim Einreihen fest. Ein erneuter Versuch darf nur die Anzahl der Sendeversuche und den Status ändern, nicht aber einen neuen Idempotenzschlüssel erzeugen. Erst wenn der Nutzer ausdrücklich noch einmal auf „Neu“ tippt, handelt es sich um eine andere Operation.

Der Server muss das Verarbeitungsergebnis unter dem Idempotenzschlüssel speichern. Bei gleichem Schlüssel und gleichem Inhalt gibt er das ursprüngliche Ergebnis zurück; bei gleichem Schlüssel und anderem Inhalt muss er die Anfrage ablehnen, statt den Datensatz stillschweigend zu überschreiben. Der Client darf den Auftrag erst entfernen, wenn er ein eindeutiges Erfolgsergebnis erhalten hat. Bei Zeitüberschreitung, Verbindungsabbruch oder Serverfehler ist das Ergebnis unbekannt – kein Grund, den Auftrag zu löschen.

Das Abnahmeziel lautet nicht „Die Anfrage wird nur einmal gesendet“, sondern „Dieselbe Nutzeraktion erzeugt auch nach mehrmaligem Senden nur einen Geschäftsdatensatz“.

Mit einem lokalen Testserver einen Antwortverlust simulieren

Die folgende Testschnittstelle speichert beim ersten Eingang eines Schlüssels zunächst den Datensatz und antwortet dann mit 503. Geht derselbe Schlüssel erneut ein, liefert sie das gespeicherte Ergebnis zurück. So wird gezielt der Fall simuliert, in dem der Client aus der Antwort nicht erkennen kann, ob der Schreibvorgang erfolgreich war. Speichere den Code auf dem Cloud-Mac als fault_server.py:

import json
from http.server import BaseHTTPRequestHandler, HTTPServer

records = {}
attempts = {}

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/items":
            self.send_error(404)
            return
        key = self.headers.get("Idempotency-Key")
        length = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(length)
        if not key or not body:
            self.send_error(400)
            return
        try:
            payload = json.loads(body)
        except json.JSONDecodeError:
            self.send_error(400)
            return
        attempts[key] = attempts.get(key, 0) + 1
        if key in records and records[key]["body"] != payload:
            self.send_error(409)
            return
        if key not in records:
            records[key] = {"id": len(records) + 1, "body": payload}
            self.send_error(503)
            return
        result = json.dumps({
            "id": records[key]["id"],
            "attempts": attempts[key],
            "record_count": len(records)
        }).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(result)))
        self.end_headers()
        self.wfile.write(result)

HTTPServer(("127.0.0.1", 8081), Handler).serve_forever()

Starte den Server mit python3 fault_server.py. Die App im iOS-Simulator kann ihren Testendpunkt auf http://127.0.0.1:8081/items setzen. Der Server lauscht hier nur auf der lokalen Loopback-Adresse; gib den Port nicht direkt für externe Netzwerke frei. Die Testschnittstelle hält ihre Daten im Prozessspeicher. Ein Neustart des Skripts löscht daher die Datensätze: Starte das Skript beim Test eines App-Neustarts nicht neu. Ein produktiver Dienst muss auch die Idempotenzdaten dauerhaft speichern und bei parallelen Anfragen durch eine eindeutige Datenbankbeschränkung atomare Verarbeitung gewährleisten.

Vier Abnahmeszenarien auf unterschiedlichen Ebenen prüfen

Sieh zunächst in der Sendeschicht einen austauschbaren Einstiegspunkt vor. Lass in den Unit-Tests der Warteschlange eine Testimplementierung der Sendeschicht einen Verbindungsfehler zurückgeben. Prüfe, dass der Auftrag weiterhin im dauerhaften Speicher liegt und Inhalt sowie Schlüssel unverändert sind. Dafür muss nicht die Netzwerkverbindung des gesamten Cloud-Macs unterbrochen werden; andere Aufgaben auf demselben Rechner bleiben unbeeinträchtigt.

Verbinde die App anschließend mit der lokalen Schnittstelle und prüfe die folgenden Fälle einzeln. Verwende für jeden Fall einen neuen Schlüssel. Erfasse während des Tests Auftrags-ID, Schlüssel, Anzahl der Sendeversuche und Serverantwort, aber keine sensiblen Felder aus der Anfrage im Log.

Szenario Vorgehen Erfolgskriterium
Erste Übermittlung Auftrag senden, 503 erhalten Auftrag bleibt erhalten, Schlüssel unverändert
Erneuter Versuch nach Antwortverlust Ursprünglichen Auftrag erneut senden Antwort 200, record_count ist 1
App-Neustart Nach der ersten 503 die App beenden und neu starten, dann die Warteschlange wiederherstellen Ursprünglichen Schlüssel weiterverwenden; es gibt weiterhin nur einen Datensatz
Anfrageinhalt versehentlich geändert Mit demselben Schlüssel einen anderen Inhalt senden Antwort 409; Auftrag erhält einen Status zur weiteren Untersuchung

Prüfpunkte im Simulator

Vergewissere dich zuerst, dass die App tatsächlich die Testschnittstelle verwendet. Lösche dann für einen sauberen Ausgangszustand nur die Daten der Test-App selbst – nicht nebenbei die Daten anderer Projekte im gemeinsam genutzten Simulator. Prüfe nach der ersten Übermittlung, ob der lokale Auftrag noch vorhanden ist. Beende den App-Prozess, starte ihn neu und löse die Wiederherstellung der Warteschlange aus. Beobachte, ob die zweite Anfrage denselben Schlüssel enthält. Speichert die App den Schlüssel nur im Arbeitsspeicher, wird das Problem meist an dieser Stelle sichtbar.

Das Skript zählt lediglich Anfragen innerhalb eines Prozesses und bietet keine Abfrageschnittstelle. Prüfe das Ergebnis dieses Durchlaufs daher anhand von attempts und record_count in der zweiten Antwort. Wenn du Werte über mehrere Testläufe hinweg erfassen musst, ergänze das Testsystem um einen dauerhaften Speicher, statt Zahlen aus dem Prozessspeicher als Produktionskennzahlen zu behandeln.

Klare Abbruchbedingungen für die Warteschlange festlegen

Nicht jeder Statuscode rechtfertigt denselben Umgang. Bei Verbindungsabbruch, Zeitüberschreitung und der 503 aus dem Beispiel kann der ursprüngliche Auftrag erhalten bleiben und später erneut gesendet werden. Eine 409 zeigt dagegen an, dass unter demselben Schlüssel ein anderer Inhalt eingegangen ist: Stoppe das automatische Senden und halte Diagnoseinformationen fest. Erst eine eindeutige Erfolgsmeldung erlaubt es, den Auftrag als abgeschlossen zu markieren. Bei einer echten Schnittstelle müssen auch andere Clientfehler einzeln anhand des API-Vertrags bewertet werden; pauschales Wiederholen ist keine Lösung.

Begrenze außerdem die Parallelität: Für denselben Auftrag darf es jeweils nur einen Sender geben. Wenn sowohl die Rückkehr der App in den Vordergrund als auch die Hintergrundsynchronisierung die Warteschlange anstoßen können, muss das Senderecht zunächst in der dauerhaften Speicherschicht gesichert werden. Der Server muss Idempotenz trotzdem unabhängig gewährleisten, denn eine Sperre im Client verhindert keinen Antwortverlust. Für erneute Versuche eignet sich ein begrenztes Backoff-Intervall, damit nach der Wiederherstellung der Verbindung nicht alle Aufträge gleichzeitig eintreffen. Das Backoff darf Operations-ID, Schlüssel und Inhalt jedoch nicht verändern.

Abschließende Prüfung

Führe vor dem Zusammenführen noch eine manuelle Abnahme durch: Beende die App nach der Fehlermeldung, öffne sie erneut und warte auf das erneute Senden. Prüfe, dass in der Oberfläche nur ein Datensatz erscheint, der lokale Auftrag schließlich als abgeschlossen markiert ist und record_count im Serverergebnis weiterhin 1 beträgt. Tippe anschließend tatsächlich noch einmal auf „Neu“ und prüfe, dass ein neuer Schlüssel und ein neuer Datensatz entstehen. Der erste Schritt belegt, dass sich die alte Operation sicher wiederholen lässt; der zweite, dass die Idempotenzregel keine neue Nutzeraktion fälschlich unterbindet.

Häufig gestellte Fragen

Sollte die App nach einer 503-Antwort einen neuen Auftrag anlegen?

Nein. Der ursprüngliche Auftrag könnte bereits gespeichert sein. Ein Wiederholungsversuch muss denselben Auftrag und Idempotenzschlüssel verwenden.

Muss für den Test die Netzwerkverbindung des Mac getrennt werden?

Nein. Eine austauschbare Transportschicht testet das Einreihen ohne Netz; der lokale Server prüft anschließend den echten HTTP-Wiederholungsversuch.

Mieten Sie einen eigenen Mac für Ihr nächstes Experiment

Wählen Sie einen physischen Mac mini, einen Standort und einen Abrechnungszeitraum. Modell und Verfügbarkeit sehen Sie in Echtzeit in der Konsole.

Cloud-Mac-Mietoptionen ansehen