Gleichzeitige iOS-Token-Erneuerung auf einem Cloud-Mac testen

Gleichzeitige iOS-Token-Erneuerung auf einem Cloud-Mac testen

Gleichzeitige iOS-Token-Erneuerung auf einem Cloud-Mac testen

Liste, Profilbild und Nachrichtenzähler rufen gleichzeitig die API auf. Sobald das alte Token ungültig ist, erhalten alle drei Anfragen einen Authentifizierungsfehler. Erneuert jede Anfrage das Token selbst, können sich die Erneuerungsvorgänge bei der serverseitigen Rotation der Refresh-Zugangsdaten gegenseitig überschreiben. Meldet sich der Nutzer währenddessen ab, kann eine verspätete Antwort sogar den angemeldeten Zustand wiederherstellen. Auf einem Cloud-Mac lassen sich solche Race Conditions in feste Tests überführen: ohne Aufrufe der Produktions-API und ohne darauf zu hoffen, dass das Netzwerk zufällig langsam ist. Geprüft wird ausschließlich das Verhalten des Authentifizierungskoordinators.

Zuerst drei überprüfbare Anforderungen festlegen

Erstens: Gleichzeitige Token-Erneuerungen innerhalb derselben Sitzung werden nur einmal ausgeführt. Zweitens: Nach dem Abmelden darf das Ergebnis einer alten Erneuerung die aktuellen Zugangsdaten nicht mehr verändern. Drittens: Jede fachliche Anfrage darf höchstens einmal das Token erneuern und anschließend wiederholt werden. Ob ein Authentifizierungsfehler eine Erneuerung erlaubt, richtet sich nach dem API-Protokoll. Nicht jeder Berechtigungsfehler bedeutet, dass das Token abgelaufen ist.

Der Koordinator erhält das Token, das die betreffende Anfrage tatsächlich verwendet hat. Unterscheidet sich das aktuelle Token bereits davon, hat eine andere Anfrage die Erneuerung abgeschlossen. Dann wird direkt der neue Wert verwendet, ohne erneut zu aktualisieren. Andernfalls könnte eine verspätete Fehlerantwort eine zweite, unnötige Erneuerung auslösen.

Ein actor garantiert nur den isolierten Zugriff auf seinen Zustand, nicht die unterbrechungsfreie Ausführung einer gesamten asynchronen Methode. An einem await können das Abmelden und andere Anfragen weiterhin in den actor eintreten.

Im Beispiel sind Tokens einfache Zeichenketten; die Übertragungsoperation zur Erneuerung wird als Closure injiziert. In einer echten Anwendung müssen zusätzlich Ablaufzeiten, die Rotation der Refresh-Zugangsdaten und deren sichere persistente Speicherung verwaltet werden. Vollständige Zugangsdaten gehören nicht in Testberichte.

Erneuerungen mit einem gemeinsamen Task zusammenführen

Der folgende Koordinator wird dem Test-Target des Projekts hinzugefügt. Er hält seinen Zustand ausschließlich im Arbeitsspeicher und sendet selbst keine fachlichen Anfragen. Die Erneuerungs-Closure muss bei einer eindeutigen Fehlerantwort des Servers oder bei einem Timeout einen Fehler werfen, statt unbegrenzt zu warten.

import Foundation

actor RefreshGate {
    enum Failure: Error { case signedOut }

    private var token: String?
    private var generation = 0
    private var flight: (id: UUID, task: Task<String, Error>)?
    private let refresh: @Sendable () async throws -> String

    init(token: String, refresh: @escaping @Sendable () async throws -> String) {
        self.token = token
        self.refresh = refresh
    }

    func recover(rejected: String) async throws -> String {
        guard let current = token else { throw Failure.signedOut }
        if current != rejected {
            try Task.checkCancellation()
            return current
        }

        let epoch = generation
        let active: (id: UUID, task: Task<String, Error>)
        if let existing = flight {
            active = existing
        } else {
            let operation = refresh
            active = (UUID(), Task { try await operation() })
            flight = active
        }

        do {
            let updated = try await active.task.value
            guard generation == epoch else { throw Failure.signedOut }
            if flight?.id == active.id {
                token = updated
                flight = nil
            }
            try Task.checkCancellation()
            return updated
        } catch {
            if flight?.id == active.id {
                flight = nil
            }
            throw error
        }
    }

    func logout() {
        generation += 1
        token = nil
        flight?.task.cancel()
        flight = nil
    }
}

Der Task wird zuerst registriert, erst danach wird auf sein Ergebnis gewartet. So können weitere Aufrufe denselben Task nutzen. Beim Aufräumen wird die Task-ID verglichen, damit ein alter Aufruf nach seiner Fortsetzung nicht versehentlich einen neuen Task entfernt. Der hier verwendete unstrukturierte Task wird nicht automatisch abgebrochen, wenn einer der wartenden Aufrufer abgebrochen wird. Dieses gemeinsame Verhalten ist beabsichtigt; ein zusätzlicher Abbruch-Handler sollte den Task daher nicht beenden.

Bei einem erfolgreichen Ergebnis wird zuerst der gemeinsame Zustand aktualisiert und erst danach geprüft, ob der Aufrufer abgebrochen wurde. So verarbeitet eine bereits geschlossene Ansicht das Ergebnis nicht weiter, während noch wartende Anfragen das neue Token erhalten. Die Abbruchprüfung erfolgt erst nach dem Ende der Wartezeit. Diese Schnittstelle kehrt bei einem Abbruch also nicht sofort zurück.

Lokale Nebenläufigkeitstests auf dem Cloud-Mac ausführen

Ein Zähler ersetzt die API zur Token-Erneuerung; als Test-Zugangsdaten dienen ausschließlich feste Platzhalterwerte. Die folgende Testmethode gehört in eine XCTestCase-Unterklasse. Koordinator und Test müssen lediglich im selben Test-Target liegen.

import XCTest

actor RefreshProbe {
    private(set) var calls = 0

    func run() async throws -> String {
        calls += 1
        try await Task.sleep(nanoseconds: 100_000_000)
        return "fresh-token"
    }
}

func testRefreshIsShared() async throws {
    let probe = RefreshProbe()
    let gate = RefreshGate(token: "old-token") {
        try await probe.run()
    }

    let values = try await withThrowingTaskGroup(of: String.self) { group in
        for _ in 0..<20 {
            group.addTask {
                try await gate.recover(rejected: "old-token")
            }
        }
        var output: [String] = []
        for try await value in group {
            output.append(value)
        }
        return output
    }

    let calls = await probe.calls
    XCTAssertEqual(calls, 1)
    XCTAssertEqual(values.count, 20)
    XCTAssertTrue(values.allSatisfy { $0 == "fresh-token" })
}

Die kurze Pause erhöht die Wahrscheinlichkeit, dass sich die Aufrufe zeitlich überschneiden. Sie ist keine Leistungskennzahl. Ergänzt werden sollte außerdem ein Testfall, der erst nach abgeschlossener Erneuerung eine Fehlerantwort für das alte Token an den Koordinator weitergibt. Die Anzahl der Aufrufe muss dabei weiterhin eins betragen. Damit lassen sich Implementierungen erkennen, die zwar wartende Aufrufer zusammenführen, verspätete Antworten aber nicht berücksichtigen.

Der Test für die Race Condition beim Abmelden sollte nicht auf einer festen Wartezeit beruhen. Stattdessen meldet die simulierte API über eine kontrollierte Continuation an den Test, dass die Erneuerung begonnen hat. Anschließend ruft der Test logout auf und gibt erst danach das Ergebnis frei. Selbst wenn die simulierte API den Abbruch ignoriert und erfolgreich zurückkehrt, müssen die alten wartenden Aufrufer einen Fehler erhalten. Neue Aufrufe nach dem Abmelden müssen dagegen unmittelbar fehlschlagen.

Fehler, Abbruch und Wiederholung getrennt behandeln

Aus einem gemeinsamen Fehler keinen Wiederholungssturm machen

Wirft die Token-Erneuerung einen Fehler, erhalten die bereits wartenden Aufrufer denselben Fehlschlag, und der Koordinator entfernt den Task. Ob später ein neuer Versuch erlaubt ist, entscheidet die übergeordnete Strategie: Bei vorübergehenden Netzwerkfehlern kann ein Backoff vorgesehen werden; bei eindeutig ungültigen Zugangsdaten sollte die Sitzung beendet werden. Nicht jeder wartende Aufrufer darf im catch sofort erneut recover aufrufen.

Vor der Wiederholung einer fachlichen Anfrage muss außerdem feststehen, dass sich ihr Request-Body erneut senden lässt. Dateistreams, nur einmal lesbare Datenquellen und Operationen mit Schreibzugriff erfordern einen neu aufgebauten Request oder ein serverseitiges Idempotenzprotokoll. Eine erfolgreiche Token-Erneuerung bedeutet nicht, dass sich jede Anfrage gefahrlos erneut ausführen lässt.

Das Abmelden bildet eine Zustandsgrenze

Der Abbruch eines Tasks ist kooperativ. Die Transportschicht kann bereits fertig sein oder nicht rechtzeitig auf den Abbruch reagieren. Beim Abmelden müssen daher sowohl die Zugangsdaten gelöscht als auch die Sitzungsgeneration erhöht werden. In der Produktionsumgebung muss dieselbe Prüfung auch die persistente Speicherung schützen. Die Transport-Closure darf neue Zugangsdaten nicht eigenständig speichern, da sie sonst die Abmeldegrenze des Koordinators umgehen würde.

Race Conditions als feste Regressionstests aufnehmen

Vor dem Commit sollten mindestens diese Fälle abgedeckt sein:

Es genügt, Testnamen, Anzahl der Erneuerungsaufrufe und Endzustand zu protokollieren. Token-Inhalte werden nicht aufgezeichnet. Bei der wiederholten Ausführung dieser Tests auf einem Cloud-Mac geht es nicht darum, zu beweisen, dass Race Conditions niemals auftreten. Entscheidend ist, dass das Ergebnis auch dann die Sitzungsgrenzen einhält, wenn sie auftreten.

Häufig gestellte Fragen

Soll ein abgebrochener Bildschirm den gemeinsamen Task stoppen?

Nein. Andere Anfragen können noch auf diese Erneuerung warten. Nur die eigene Verarbeitung wird abgebrochen. Beim Abmelden wird der gemeinsame Task abgebrochen und die Sitzungsgeneration erhöht.

Darf ein zweiter Authentifizierungsfehler erneut eine Erneuerung auslösen?

Nein. Jede fachliche Anfrage darf das Token höchstens einmal erneuern und einmal wiederholt werden. Danach wird der Fehler zurückgegeben; das Serverprotokoll bestimmt, ob die Sitzung ungültig ist.

Der passende Cloud-Mac für Ihre Entwicklungsprojekte

Physischer Apple-Silicon-Knoten statt VM. Mieten Sie tage-, wochen-, monats- oder quartalsweise. Modelle und Preise sind transparent einsehbar; die aktuelle Verfügbarkeit wird im Portal in Echtzeit angezeigt.

Cloud-Mac jetzt mieten Modelle und Laufzeiten