Tester la reprise d’un flux SSE sur iOS après une coupure

Tester la reprise d’un flux SSE sur iOS après une coupure

La liste des messages s’affiche correctement dans le simulateur iOS d’un Mac cloud. Mais sur un réseau réel, une brève coupure peut faire manquer une mise à jour d’état ou déclencher deux fois le traitement d’une même notification. Les flux SSE (Server-Sent Events, des événements textuels envoyés en continu par le serveur au client) sont particulièrement concernés : une requête HTTP réussie ne garantit pas que le client a terminé de traiter le dernier événement. Voici comment utiliser un serveur de test accessible uniquement en local pour vérifier, de façon reproductible, la coupure et la reprise du flux.

Définir d’abord le contrat de reprise

Un événement SSE se compose de plusieurs lignes de champs et se termine par une ligne vide. Le champ id est le curseur attribué par le serveur à l’événement. Lors d’une reconnexion, le client place l’ID du dernier événement traité dans l’en-tête de requête Last-Event-ID. Le serveur renvoie alors les événements suivants.

« Avoir lu un ID » ne signifie pas « avoir traité l’événement ». La connexion peut se couper juste entre id: 2 et la ligne data qui suit. Si le client enregistre déjà l’ID 2, la reprise commencera à 3 et l’événement 2 sera définitivement perdu. Il faut d’abord recevoir la ligne vide qui clôt l’événement, terminer le traitement métier, puis seulement valider le curseur. Si ce traitement écrit en base de données, il faut aussi dédupliquer par ID d’événement : une coupure peut survenir entre la validation du résultat et l’enregistrement du curseur.

Le but du test n’est pas de prouver que la connexion ne tombe jamais, mais de vérifier que l’ensemble des événements reste le même de part et d’autre de la coupure et que chaque ID produit au plus un effet métier.

Démarrer une source d’événements locale et contrôlable

Sur le Mac cloud, créez sse_fixture.py. Le script écoute uniquement sur 127.0.0.1 : le port de test n’est pas exposé à l’extérieur. Avec cut=1, il termine la réponse après avoir envoyé un événement. À la requête suivante, le serveur lit l’en-tête et reprend à l’événement d’après.

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()

Lancez python3 sse_fixture.py, puis ouvrez un autre terminal pour examiner les deux réponses :

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'

La première réponse ne doit contenir que l’ID 1 ; la seconde doit présenter les ID 2 puis 3. Ici, la fin normale de la réponse sert à tester une nouvelle requête après la fin du flux. Les réinitialisations de connexion, les délais d’attente et les événements partiels rencontrés sur un réseau réel exigent des injections de panne distinctes : une fin normale ne couvre pas tous les scénarios de coupure.

Valider les événements complets côté iOS

Le client de test peut lire le flux ligne par ligne avec URLSession.bytes(for:). L’extrait suivant suppose que la cible de déploiement prend en charge cette API. Il gère une seule connexion ; l’appelant se charge de planifier les tentatives de reconnexion. L’événement n’est transmis au traitement qu’à l’arrivée de la ligne vide. Tout fragment incomplet en fin de flux est écarté.

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
}

Cet exemple minimal conserve les champs data multilignes et ignore les lignes de pulsation ainsi que les autres champs. Un analyseur destiné à la production doit également gérer, conformément au protocole, les événements sans ID, les ID vides et les indications de délai avant une nouvelle tentative. Le tableau renvoyé facilite l’observation des résultats du test, mais ne convient pas à un flux de production qui reste ouvert indéfiniment : sur une connexion longue durée, traitez les événements au fur et à mesure au lieu de les accumuler en mémoire.

Effectuer deux vérifications de coupure dans le simulateur

Vérifiez d’abord avec curl que le service est joignable depuis le Mac, puis faites demander http://127.0.0.1:8765/events?cut=1 au simulateur iOS. Le simulateur peut généralement accéder aux services locaux du Mac qui l’héberge. Sur un appareil physique, 127.0.0.1 désigne l’appareil lui-même : cette adresse ne peut donc pas être reprise telle quelle. Si l’application bloque par défaut le HTTP non chiffré, n’ajoutez une exception locale de portée limitée que pour la configuration de débogage ; ne conservez pas d’exception générale dans la configuration de publication.

Au premier passage, enregistrez l’événement 1 et ne fixez le curseur à 1 qu’une fois son traitement réussi. Au second passage, retirez cut=1, envoyez Last-Event-ID: 1 et vérifiez que les ID reçus sont exactement 2 et 3. Faites ensuite échouer volontairement le premier traitement métier : le curseur doit conserver son ancienne valeur. Quand l’événement arrive de nouveau, dédupliquez-le par ID ou retentez l’opération de manière sûre. Enfin, annulez la tâche de lecture et vérifiez que le minuteur de reconnexion est lui aussi annulé ; sinon, des requêtes en arrière-plan peuvent subsister après la fermeture de la page.

Point de contrôle Critère de réussite
Événement complet Le traitement métier ne commence qu’après réception de la ligne vide finale
Reprise après coupure Les événements qui suivent l’ID validé arrivent dans l’ordre
Livraison en double Un même ID ne produit pas un second effet métier
Fermeture de la page La tâche de lecture et la nouvelle tentative en attente s’arrêtent ensemble

Attribuer chaque échec à la bonne couche

Si le second passage renvoie les événements depuis le début, vérifiez d’abord que l’en-tête de requête contient bien l’ID validé, puis que le serveur sait filtrer à partir de ce curseur. N’incriminez pas immédiatement le simulateur. Si seul le dernier événement manque, examinez surtout si l’analyseur prend à tort la « fin du flux » pour une frontière d’événement. Si la reconnexion entraîne des notifications en double, contrôlez la déduplication métier au lieu de simplement allonger le délai entre les tentatives.

Il faut aussi distinguer « connexion établie » et « événement livré ». Le fait que le serveur coupe la connexion après avoir envoyé les octets ne prouve pas que le client a enregistré son état local. De même, l’enregistrement du curseur côté client ne prouve pas qu’une opération métier distante peut être retentée sans risque. Consignez ensemble l’ID de l’événement, le résultat du traitement et le curseur après validation. Lors d’une prochaine modification de l’analyseur ou de la logique de reconnexion, vous pourrez ainsi comparer chaque point sans vous fier à la seule observation de la liste des messages.

Questions fréquentes

À quel moment enregistrer Last-Event-ID ?

Après réception complète et traitement réussi de l’événement. Enregistrer l’identifiant dès la ligne id risque de faire sauter des données lors d’une coupure.

Le simulateur iOS peut-il joindre un service sur 127.0.0.1 ?

Il peut généralement joindre un service local sur le même Mac. Vérifiez d’abord le port avec curl ; cette adresse ne convient pas à un appareil physique.

Envie de tester par vous-même ?

Louez un Mac mini dédié et lancez vos builds

Choisissez parmi deux configurations M4 dédiées, puis sélectionnez la durée de location et la région adaptées à vos besoins. La disponibilité est actualisée dans la console.

Louer un Mac mini