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:
- Mehrere Anfragen mit altem Token scheitern gleichzeitig: Die Erneuerung wird genau einmal aufgerufen.
- Das neue Token ist bereits aktiv, bevor die alte Fehlerantwort eintrifft: Es erfolgt keine weitere Erneuerung.
- Die Erneuerung schlägt fehl: Alle wartenden Aufrufer erhalten einen Fehler, und der gemeinsame Task wird entfernt.
- Ein einzelner wartender Aufrufer wird abgebrochen: Die übrigen erhalten weiterhin ein erfolgreiches Ergebnis.
- Das Ergebnis der Erneuerung trifft erst nach dem Abmelden ein: Weder werden Zugangsdaten wiederhergestellt noch fachliche Anfragen erneut ausgeführt.
- Auch die wiederholte Anfrage scheitert an der Authentifizierung: Der Fehler wird direkt zurückgegeben, ohne rekursive Token-Erneuerung.
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.