krz/hutch

an ios client for sourcehut

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

v2.15.1: 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        if let error = error as? SRHTError {
134            switch error {
135            case .graphQLErrors(let errors):
136                let firstMessage = errors.first?.message.lowercased() ?? ""
137                if firstMessage.contains("unauthorized") || firstMessage.contains("forbidden") {
138                    return "You do not have permission to do that."
139                }
140                if firstMessage.contains("not found") || firstMessage.contains("no rows in result set") {
141                    return "That content is no longer available."
142                }
143                return "Something went wrong. Please try again."
144            case .httpError(let code):
145                if code == 401 {
146                    return "Please sign in again."
147                }
148                if code == 403 {
149                    return "You do not have permission to do that."
150                }
151                if code == 404 {
152                    return "That content is no longer available."
153                }
154                if (500...599).contains(code) {
155                    return "The server is unavailable right now. Please try again."
156                }
157                return "Something went wrong. Please try again."
158            case .invalidAuthenticatedURL:
159                return "That request could not be completed."
160            case .decodingError:
161                return "The response could not be loaded right now."
162            case .networkError(let underlyingError):
163                return refreshErrorMessage(from: underlyingError)
164            case .unauthorized:
165                return "Please sign in again."
166            }
167        }
168
169        let nsError = error as NSError
170        switch nsError.code {
171        case NSURLErrorNotConnectedToInternet,
172             NSURLErrorNetworkConnectionLost,
173             NSURLErrorTimedOut,
174             NSURLErrorCannotFindHost,
175             NSURLErrorCannotConnectToHost,
176             NSURLErrorDNSLookupFailed,
177             NSURLErrorInternationalRoamingOff,
178             NSURLErrorDataNotAllowed:
179            return "Check your connection and try again."
180        default:
181            return "Something went wrong. Please try again."
182        }
183    }
184}
185
186struct CacheEntry<Value: Sendable>: Sendable {
187    let value: Value
188    let timestamp: Date
189
190    nonisolated func isExpired(ttl: TimeInterval, now: @escaping @Sendable () -> Date = Date.init) -> Bool {
191        now().timeIntervalSince(timestamp) > ttl
192    }
193}