krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.13.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}