diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/ArtworkLoader.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/ArtworkLoader.swift index 4eb85fd..ae6c0ea 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Components/ArtworkLoader.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/ArtworkLoader.swift @@ -70,12 +70,7 @@ public actor ArtworkLoader { if let running = inFlight[url] { return await running.value } let task = Task { [session] in - guard let (data, response) = try? await session.data(from: url), - let http = response as? HTTPURLResponse, - (200..<300).contains(http.statusCode), - let image = PlatformImage(data: data) - else { return nil } - return image + await Self.fetch(url, using: session) } inFlight[url] = task @@ -85,6 +80,54 @@ public actor ArtworkLoader { return image } + /// Holt ein Bild — und gibt nicht beim ersten Nein auf. + /// + /// Der Grund ist ein Fehlerbild, das lange rätselhaft aussah: auf einem + /// großen Fenster blieb die **zweite** Reihe fast vollständig grau, + /// während die erste geladen hatte. Es lag nicht am Server und nicht an + /// fehlenden Bildern, sondern an der Gleichzeitigkeit — sechzig sichtbare + /// Kacheln teilen sich zwölf Verbindungen, die hinteren stehen so lange + /// in der Schlange, dass sie in den Zeitablauf laufen. + /// + /// Oben fragt niemand ein zweites Mal: `task(id:)` läuft einmal je + /// Adresse. Ein einziger verpasster Versuch war damit endgültig. + /// + /// **Nur wiederholt, was sich zu wiederholen lohnt.** Ein 404 heißt, dass + /// es das Bild nicht gibt — dreimal zu fragen macht es nicht vorhanden + /// und kostet den Server drei Antworten. Ein Zeitablauf oder ein + /// abgerissener Strang dagegen sagt nur, dass es *gerade* nicht ging. + private static func fetch( + _ url: URL, + using session: URLSession, + attempts: Int = 3 + ) async -> PlatformImage? { + for attempt in 1...attempts { + if Task.isCancelled { return nil } + + do { + let (data, response) = try await session.data(from: url) + guard let http = response as? HTTPURLResponse else { return nil } + + if (200..<300).contains(http.statusCode) { + return PlatformImage(data: data) + } + // Der Server hat geantwortet und nein gesagt. Bei 5xx und 429 + // lohnt ein zweiter Versuch, sonst nicht. + guard http.statusCode >= 500 || http.statusCode == 429 else { return nil } + } catch is CancellationError { + return nil + } catch { + // Zeitablauf, abgerissene Verbindung: der Fall, für den das + // alles hier gebaut ist. + } + + guard attempt < attempts else { return nil } + // Wachsender Abstand: ist die Leitung gerade verstopft, hilft es + // nicht, sofort noch einmal zu drängeln. + try? await Task.sleep(for: .milliseconds(300 * attempt * attempt)) + } + return nil + } } /// Ein Bildspeicher, der sich von überall lesen lässt.