SSE-Wiederverbindung in iOS nach einem Verbindungsabbruch testen

SSE-Wiederverbindung in iOS nach einem Verbindungsabbruch testen

In der Nachrichtenliste des iOS-Simulators auf einem Cloud-Mac sieht alles richtig aus. Unter realen Netzwerkbedingungen fehlt nach einer kurzen Verbindungsunterbrechung jedoch plötzlich ein Statusupdate – oder dieselbe Benachrichtigung wird zweimal verarbeitet. Bei SSE (Server-Sent Events, einem Strom von Textereignissen vom Server zum Client) passiert das besonders leicht: Eine erfolgreiche HTTP-Anfrage bedeutet noch nicht, dass der Client das letzte Ereignis verarbeitet hat. Mit einem Testserver, der nur auf dem Mac selbst erreichbar ist, lassen sich Unterbrechung und Fortsetzung getrennt und wiederholbar prüfen.

Zuerst die Regeln für die Fortsetzung festlegen

Ein SSE-Ereignis besteht aus mehreren Feldzeilen und endet mit einer Leerzeile. Die id ist der vom Server vergebene Ereignis-Cursor. Beim erneuten Verbinden sendet der Client die ID des zuletzt verarbeiteten Ereignisses im Header Last-Event-ID. Der Server liefert daraufhin die folgenden Ereignisse.

„ID gelesen“ ist nicht dasselbe wie „Ereignis verarbeitet“. Die Verbindung kann genau zwischen id: 2 und der folgenden data-Zeile abbrechen. Wird ID 2 bereits dann gespeichert, beginnt die Fortsetzung bei Ereignis 3 und Ereignis 2 geht dauerhaft verloren. Deshalb zuerst die abschließende Leerzeile empfangen, dann die fachliche Verarbeitung abschließen und erst danach den Cursor speichern. Schreibt die Verarbeitung in eine Datenbank, sollte sie zudem anhand der Ereignis-ID dedupliziert werden: Auch zwischen dem Speichern des Verarbeitungsergebnisses und dem Speichern des Cursors kann die Verbindung abbrechen.

Der Test soll nicht beweisen, dass die Verbindung nie abbricht. Er soll zeigen, dass die Ereignismenge vor und nach dem Abbruch vollständig bleibt und jede ID höchstens einmal eine fachliche Wirkung auslöst.

Eine steuerbare lokale Ereignisquelle starten

Erstellen Sie auf dem Cloud-Mac die Datei sse_fixture.py. Das Skript bindet sich ausschließlich an 127.0.0.1; der Testport ist von außen nicht erreichbar. Mit cut=1 endet die Antwort nach einem Ereignis. Bei der nächsten Anfrage liest der Server den Header und setzt beim folgenden Ereignis fort.

from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlsplit

EVENTS = [(1, "queued"), (2, "building"), (3, "finished")]

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        url = urlsplit(self.path)
        if url.path != "/events":
            self.send_error(404)
            return
        try:
            last = int(self.headers.get("Last-Event-ID", "0"))
        except ValueError:
            self.send_error(400)
            return
        cut = parse_qs(url.query).get("cut") == ["1"]
        self.send_response(200)
        self.send_header("Content-Type", "text/event-stream; charset=utf-8")
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        for event_id, value in EVENTS:
            if event_id <= last:
                continue
            payload = f"id: {event_id}\ndata: {value}\n\n".encode()
            try:
                self.wfile.write(payload)
                self.wfile.flush()
            except (BrokenPipeError, ConnectionResetError):
                break
            if cut:
                break

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

Starten Sie python3 sse_fixture.py und prüfen Sie in einem zweiten Terminal die beiden Antworten:

curl -Ns 'http://127.0.0.1:8765/events?cut=1'
curl -Ns -H 'Last-Event-ID: 1' 'http://127.0.0.1:8765/events'

Die erste Antwort sollte nur ID 1 enthalten, die zweite nacheinander ID 2 und 3. Das reguläre Ende der ersten Antwort dient hier dazu, eine neue Anfrage nach dem Ende des Streams zu testen. Verbindungs-Resets, Timeouts und Abbrüche mitten in einem Ereignis müssen separat simuliert werden. Ein reguläres Ende deckt nicht alle Unterbrechungsszenarien eines produktiven Netzwerks ab.

Ereignisse auf iOS erst nach vollständigem Empfang übernehmen

Der Testclient kann den Stream mit URLSession.bytes(for:) zeilenweise lesen. Das folgende Beispiel setzt voraus, dass das Deployment-Target diese Schnittstelle unterstützt. Es behandelt nur eine Verbindung; die aufrufende Stelle steuert weitere Versuche. Erst bei einer Leerzeile wird ein Ereignis zur Verarbeitung übergeben. Ein unvollständiger Rest am Stream-Ende wird verworfen.

import Foundation

struct StreamEvent {
    let id: String
    let data: String
}

func readEvents(after lastID: String?, url: URL) async throws -> [StreamEvent] {
    var request = URLRequest(url: url)
    if let lastID {
        request.setValue(lastID, forHTTPHeaderField: "Last-Event-ID")
    }
    let (bytes, response) = try await URLSession.shared.bytes(for: request)
    guard let http = response as? HTTPURLResponse,
          http.statusCode == 200 else {
        throw URLError(.badServerResponse)
    }

    var result: [StreamEvent] = []
    var eventID: String?
    var data: [String] = []
    for try await line in bytes.lines {
        if line.isEmpty {
            if let eventID, !data.isEmpty {
                result.append(StreamEvent(id: eventID, data: data.joined(separator: "\n")))
            }
            eventID = nil
            data = []
        } else if line.hasPrefix("id:") {
            eventID = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
        } else if line.hasPrefix("data:") {
            data.append(String(line.dropFirst(5)).dropFirst(
                line.dropFirst(5).first == " " ? 1 : 0
            ).description)
        }
    }
    return result
}

Dieses Minimalbeispiel erhält mehrzeilige data-Felder, ignoriert aber Heartbeat-Zeilen und andere Felder. Ein vollständiger Parser muss außerdem Ereignisse ohne ID, leere IDs und Hinweise zum Wiederholungsintervall protokollgemäß behandeln. Für den Test liefert das Beispiel ein Array zurück; für einen dauerhaft laufenden Produktionsstream ist das ungeeignet. Bei einer langlebigen Verbindung sollten Ereignisse fortlaufend verarbeitet statt unbegrenzt im Speicher gesammelt werden.

Zwei Unterbrechungstests im Simulator durchführen

Prüfen Sie zuerst mit curl auf dem Mac, ob der Dienst erreichbar ist. Rufen Sie anschließend im iOS-Simulator http://127.0.0.1:8765/events?cut=1 auf. Der Simulator kann in der Regel auf lokale Dienste seines Mac zugreifen. Auf einem physischen Gerät verweist 127.0.0.1 dagegen auf das Gerät selbst; die Adresse lässt sich dort nicht unverändert übernehmen. Falls die App unverschlüsseltes HTTP standardmäßig blockiert, konfigurieren Sie eine eng begrenzte Ausnahme für das lokale Netzwerk nur im Debug-Build. Eine pauschale Ausnahme gehört nicht in die Release-Konfiguration.

Erfassen Sie im ersten Durchlauf Ereignis 1 und setzen Sie den Cursor erst nach erfolgreicher Verarbeitung auf 1. Entfernen Sie im zweiten Durchlauf cut=1, senden Sie Last-Event-ID: 1 mit und prüfen Sie, ob genau die IDs 2 und 3 eintreffen. Lassen Sie danach die erste fachliche Verarbeitung absichtlich fehlschlagen: Der Cursor muss auf dem alten Wert bleiben. Trifft das Ereignis erneut ein, muss es anhand der ID dedupliziert oder sicher erneut verarbeitet werden. Brechen Sie zum Schluss die Leseaufgabe ab und prüfen Sie, ob auch der Timer für die Wiederverbindung abgebrochen wurde. Andernfalls können nach dem Verlassen der Ansicht Hintergrundanfragen weiterlaufen.

Prüfpunkt Erfolgskriterium
Vollständiges Ereignis Die fachliche Verarbeitung beginnt erst nach der abschließenden Leerzeile
Fortsetzung nach Abbruch Ereignisse nach der gespeicherten ID treffen in der richtigen Reihenfolge ein
Erneute Zustellung Dieselbe ID löst keine zweite fachliche Wirkung aus
Verlassen der Ansicht Leseaufgabe und geplanter Wiederholungsversuch werden gemeinsam beendet

Fehler der richtigen Ebene zuordnen

Beginnt die zweite Antwort wieder beim ersten Ereignis, prüfen Sie zunächst, ob der Anfrage-Header tatsächlich die gespeicherte ID enthält, und dann, ob der Server nach dem Cursor filtern kann. Machen Sie nicht vorschnell den Simulator verantwortlich. Geht nur das letzte Ereignis verloren, prüfen Sie vor allem, ob der Parser das Stream-Ende fälschlich als Ereignisgrenze behandelt. Führt die Wiederverbindung zu doppelten Benachrichtigungen, prüfen Sie die Deduplizierung in der Geschäftslogik, statt lediglich das Wiederholungsintervall zu verlängern.

Unterscheiden Sie außerdem zwischen einer erfolgreichen Verbindung und einer erfolgreichen Ereigniszustellung. Wenn der Server Bytes sendet und danach die Verbindung schließt, ist damit nicht bewiesen, dass der Client seinen lokalen Zustand gespeichert hat. Ebenso wenig beweist ein vom Client gespeicherter Cursor, dass sich eine entfernte Geschäftsoperation gefahrlos wiederholen lässt. Halten Sie Ereignis-ID, Verarbeitungsergebnis und anschließend gespeicherten Cursor im selben Testprotokoll fest. So können Sie nach Änderungen am Parser oder an der Wiederholungslogik jeden Punkt vergleichen, statt die Nachrichtenliste nur mit bloßem Auge zu beurteilen.

Häufig gestellte Fragen

Wann sollte die App Last-Event-ID speichern?

Erst wenn das vollständige Ereignis empfangen und erfolgreich verarbeitet wurde. Ein sofortiges Speichern der id-Zeile kann bei einem Abbruch Daten überspringen.

Erreicht der iOS-Simulator einen Dienst auf 127.0.0.1?

Normalerweise erreicht er einen lokalen Dienst auf demselben Mac. Prüfe den Port zuerst mit curl; auf einem physischen Gerät gilt diese Adresse nicht.

Möchten Sie es selbst testen?

Mieten Sie einen dedizierten Mac mini und starten Sie Ihren Build direkt

Wählen Sie zwischen zwei M4-Servermodellen und legen Sie Mietdauer und Standort nach Ihrem Bedarf fest. Die aktuelle Verfügbarkeit sehen Sie direkt in der Konsole.

Mac mini jetzt mieten