krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.7.0: Hutch/Networking/SystemStatusRepository.swift · raw

  1import Foundation
  2
  3protocol SystemStatusServing: Sendable {
  4    func fetchSnapshotHTML() async throws -> String
  5    func fetchIncidentFeedData() async throws -> Data
  6}
  7
  8struct CachedSystemStatusValue<Value: Sendable>: Sendable {
  9    let value: Value
 10    let lastSuccessfulAt: Date
 11    let isStale: Bool
 12    let refreshErrorMessage: String?
 13}
 14
 15actor SystemStatusRepository {
 16    private let service: any SystemStatusServing
 17    private let ttl: TimeInterval
 18    private let cacheStore: SystemStatusCacheStore
 19    private let now: @Sendable () -> Date
 20
 21    private var snapshotCache: CacheEntry<SystemStatusSnapshot>?
 22    private var incidentsCache: CacheEntry<[StatusIncident]>?
 23    private var hasLoadedPersistentCache = false
 24
 25    init(
 26        service: any SystemStatusServing = SystemStatusService(),
 27        ttl: TimeInterval = 10 * 60,
 28        cacheStore: SystemStatusCacheStore = SystemStatusCacheStore(),
 29        now: @escaping @Sendable () -> Date = Date.init
 30    ) {
 31        self.service = service
 32        self.ttl = ttl
 33        self.cacheStore = cacheStore
 34        self.now = now
 35    }
 36
 37    func snapshot(forceRefresh: Bool = false) async throws -> SystemStatusSnapshot {
 38        try await snapshotResult(forceRefresh: forceRefresh).value
 39    }
 40
 41    func recentIncidents(forceRefresh: Bool = false) async throws -> [StatusIncident] {
 42        try await recentIncidentsResult(forceRefresh: forceRefresh).value
 43    }
 44
 45    func snapshotResult(forceRefresh: Bool = false) async throws -> CachedSystemStatusValue<SystemStatusSnapshot> {
 46        await loadPersistentCacheIfNeeded()
 47
 48        if let cached = snapshotCache, !forceRefresh, !cached.isExpired(ttl: ttl, now: now) {
 49            return CachedSystemStatusValue(
 50                value: cached.value,
 51                lastSuccessfulAt: cached.timestamp,
 52                isStale: false,
 53                refreshErrorMessage: nil
 54            )
 55        }
 56
 57        do {
 58            let html = try await service.fetchSnapshotHTML()
 59            let snapshot = try SystemStatusService.parseSnapshotHTML(html, fetchedAt: now())
 60            let entry = CacheEntry(value: snapshot, timestamp: now())
 61            snapshotCache = entry
 62            await cacheStore.saveSnapshotHTML(html, timestamp: entry.timestamp)
 63            return CachedSystemStatusValue(
 64                value: snapshot,
 65                lastSuccessfulAt: entry.timestamp,
 66                isStale: false,
 67                refreshErrorMessage: nil
 68            )
 69        } catch {
 70            if let cached = snapshotCache {
 71                return CachedSystemStatusValue(
 72                    value: cached.value,
 73                    lastSuccessfulAt: cached.timestamp,
 74                    isStale: true,
 75                    refreshErrorMessage: refreshErrorMessage(from: error)
 76                )
 77            }
 78            throw error
 79        }
 80    }
 81
 82    func recentIncidentsResult(forceRefresh: Bool = false) async throws -> CachedSystemStatusValue<[StatusIncident]> {
 83        await loadPersistentCacheIfNeeded()
 84
 85        if let cached = incidentsCache, !forceRefresh, !cached.isExpired(ttl: ttl, now: now) {
 86            return CachedSystemStatusValue(
 87                value: cached.value,
 88                lastSuccessfulAt: cached.timestamp,
 89                isStale: false,
 90                refreshErrorMessage: nil
 91            )
 92        }
 93
 94        do {
 95            let feedData = try await service.fetchIncidentFeedData()
 96            let incidents = try await SystemStatusService.parseIncidentFeedXML(feedData)
 97            let entry = CacheEntry(value: incidents, timestamp: now())
 98            incidentsCache = entry
 99            await cacheStore.saveIncidentFeedData(feedData, timestamp: entry.timestamp)
100            return CachedSystemStatusValue(
101                value: incidents,
102                lastSuccessfulAt: entry.timestamp,
103                isStale: false,
104                refreshErrorMessage: nil
105            )
106        } catch {
107            if let cached = incidentsCache {
108                return CachedSystemStatusValue(
109                    value: cached.value,
110                    lastSuccessfulAt: cached.timestamp,
111                    isStale: true,
112                    refreshErrorMessage: refreshErrorMessage(from: error)
113                )
114            }
115            throw error
116        }
117    }
118
119    private func loadPersistentCacheIfNeeded() async {
120        guard !hasLoadedPersistentCache else { return }
121        if let persistedSnapshot = await cacheStore.loadSnapshotHTML(),
122           let snapshot = try? SystemStatusService.parseSnapshotHTML(persistedSnapshot.html, fetchedAt: persistedSnapshot.timestamp) {
123            snapshotCache = CacheEntry(value: snapshot, timestamp: persistedSnapshot.timestamp)
124        }
125        if let persistedFeed = await cacheStore.loadIncidentFeedData(),
126           let incidents = try? await SystemStatusService.parseIncidentFeedXML(persistedFeed.data) {
127            incidentsCache = CacheEntry(value: incidents, timestamp: persistedFeed.timestamp)
128        }
129        hasLoadedPersistentCache = true
130    }
131
132    private func refreshErrorMessage(from error: any Error) -> String {
133        error.userFacingMessage
134    }
135}
136
137struct CacheEntry<Value: Sendable>: Sendable {
138    let value: Value
139    let timestamp: Date
140
141    nonisolated func isExpired(ttl: TimeInterval, now: @escaping @Sendable () -> Date = Date.init) -> Bool {
142        now().timeIntervalSince(timestamp) > ttl
143    }
144}