diff options
Diffstat (limited to 'Hutch/Networking/SystemStatusRepository.swift')
| -rw-r--r-- | Hutch/Networking/SystemStatusRepository.swift | 172 |
1 files changed, 154 insertions, 18 deletions
diff --git a/Hutch/Networking/SystemStatusRepository.swift b/Hutch/Networking/SystemStatusRepository.swift index 48c5b16..5f51315 100644 --- a/Hutch/Networking/SystemStatusRepository.swift +++ b/Hutch/Networking/SystemStatusRepository.swift @@ -1,57 +1,193 @@ import Foundation +protocol SystemStatusServing: Sendable { + func fetchSnapshotHTML() async throws -> String + func fetchIncidentFeedData() async throws -> Data +} + +struct CachedSystemStatusValue<Value: Sendable>: Sendable { + let value: Value + let lastSuccessfulAt: Date + let isStale: Bool + let refreshErrorMessage: String? +} + actor SystemStatusRepository { - private let service: SystemStatusService + private let service: any SystemStatusServing private let ttl: TimeInterval + private let cacheStore: SystemStatusCacheStore + private let now: @Sendable () -> Date private var snapshotCache: CacheEntry<SystemStatusSnapshot>? private var incidentsCache: CacheEntry<[StatusIncident]>? + private var hasLoadedPersistentCache = false - init(service: SystemStatusService = SystemStatusService(), ttl: TimeInterval = 10 * 60) { + init( + service: any SystemStatusServing = SystemStatusService(), + ttl: TimeInterval = 10 * 60, + cacheStore: SystemStatusCacheStore = SystemStatusCacheStore(), + now: @escaping @Sendable () -> Date = Date.init + ) { self.service = service self.ttl = ttl + self.cacheStore = cacheStore + self.now = now } func snapshot(forceRefresh: Bool = false) async throws -> SystemStatusSnapshot { - if let cached = snapshotCache, !forceRefresh, !cached.isExpired(ttl: ttl) { - return cached.value + try await snapshotResult(forceRefresh: forceRefresh).value + } + + func recentIncidents(forceRefresh: Bool = false) async throws -> [StatusIncident] { + try await recentIncidentsResult(forceRefresh: forceRefresh).value + } + + func snapshotResult(forceRefresh: Bool = false) async throws -> CachedSystemStatusValue<SystemStatusSnapshot> { + await loadPersistentCacheIfNeeded() + + if let cached = snapshotCache, !forceRefresh, !cached.isExpired(ttl: ttl, now: now) { + return CachedSystemStatusValue( + value: cached.value, + lastSuccessfulAt: cached.timestamp, + isStale: false, + refreshErrorMessage: nil + ) } do { - let snapshot = try await service.fetchSnapshot() - snapshotCache = CacheEntry(value: snapshot, timestamp: Date()) - return snapshot + let html = try await service.fetchSnapshotHTML() + let snapshot = try SystemStatusService.parseSnapshotHTML(html, fetchedAt: now()) + let entry = CacheEntry(value: snapshot, timestamp: now()) + snapshotCache = entry + await cacheStore.saveSnapshotHTML(html, timestamp: entry.timestamp) + return CachedSystemStatusValue( + value: snapshot, + lastSuccessfulAt: entry.timestamp, + isStale: false, + refreshErrorMessage: nil + ) } catch { if let cached = snapshotCache { - return cached.value + return CachedSystemStatusValue( + value: cached.value, + lastSuccessfulAt: cached.timestamp, + isStale: true, + refreshErrorMessage: refreshErrorMessage(from: error) + ) } throw error } } - func recentIncidents(forceRefresh: Bool = false) async throws -> [StatusIncident] { - if let cached = incidentsCache, !forceRefresh, !cached.isExpired(ttl: ttl) { - return cached.value + func recentIncidentsResult(forceRefresh: Bool = false) async throws -> CachedSystemStatusValue<[StatusIncident]> { + await loadPersistentCacheIfNeeded() + + if let cached = incidentsCache, !forceRefresh, !cached.isExpired(ttl: ttl, now: now) { + return CachedSystemStatusValue( + value: cached.value, + lastSuccessfulAt: cached.timestamp, + isStale: false, + refreshErrorMessage: nil + ) } do { - let incidents = try await service.fetchIncidentFeed() - incidentsCache = CacheEntry(value: incidents, timestamp: Date()) - return incidents + let feedData = try await service.fetchIncidentFeedData() + let incidents = try await SystemStatusService.parseIncidentFeedXML(feedData) + let entry = CacheEntry(value: incidents, timestamp: now()) + incidentsCache = entry + await cacheStore.saveIncidentFeedData(feedData, timestamp: entry.timestamp) + return CachedSystemStatusValue( + value: incidents, + lastSuccessfulAt: entry.timestamp, + isStale: false, + refreshErrorMessage: nil + ) } catch { if let cached = incidentsCache { - return cached.value + return CachedSystemStatusValue( + value: cached.value, + lastSuccessfulAt: cached.timestamp, + isStale: true, + refreshErrorMessage: refreshErrorMessage(from: error) + ) } throw error } } + + private func loadPersistentCacheIfNeeded() async { + guard !hasLoadedPersistentCache else { return } + if let persistedSnapshot = await cacheStore.loadSnapshotHTML(), + let snapshot = try? SystemStatusService.parseSnapshotHTML(persistedSnapshot.html, fetchedAt: persistedSnapshot.timestamp) { + snapshotCache = CacheEntry(value: snapshot, timestamp: persistedSnapshot.timestamp) + } + if let persistedFeed = await cacheStore.loadIncidentFeedData(), + let incidents = try? await SystemStatusService.parseIncidentFeedXML(persistedFeed.data) { + incidentsCache = CacheEntry(value: incidents, timestamp: persistedFeed.timestamp) + } + hasLoadedPersistentCache = true + } + + private func refreshErrorMessage(from error: any Error) -> String { + if let error = error as? SRHTError { + switch error { + case .graphQLErrors(let errors): + let firstMessage = errors.first?.message.lowercased() ?? "" + if firstMessage.contains("unauthorized") || firstMessage.contains("forbidden") { + return "You do not have permission to do that." + } + if firstMessage.contains("not found") || firstMessage.contains("no rows in result set") { + return "That content is no longer available." + } + return "Something went wrong. Please try again." + case .httpError(let code): + if code == 401 { + return "Please sign in again." + } + if code == 403 { + return "You do not have permission to do that." + } + if code == 404 { + return "That content is no longer available." + } + if (500...599).contains(code) { + return "The server is unavailable right now. Please try again." + } + return "Something went wrong. Please try again." + case .invalidAuthenticatedURL: + return "That request could not be completed." + case .decodingError: + return "The response could not be loaded right now." + case .networkError(let underlyingError): + return refreshErrorMessage(from: underlyingError) + case .unauthorized: + return "Please sign in again." + } + } + + let nsError = error as NSError + switch nsError.code { + case NSURLErrorNotConnectedToInternet, + NSURLErrorNetworkConnectionLost, + NSURLErrorTimedOut, + NSURLErrorCannotFindHost, + NSURLErrorCannotConnectToHost, + NSURLErrorDNSLookupFailed, + NSURLErrorInternationalRoamingOff, + NSURLErrorDataNotAllowed: + return "Check your connection and try again." + default: + return "Something went wrong. Please try again." + } + } } -private struct CacheEntry<Value: Sendable>: Sendable { +struct CacheEntry<Value: Sendable>: Sendable { let value: Value let timestamp: Date - nonisolated func isExpired(ttl: TimeInterval) -> Bool { - Date().timeIntervalSince(timestamp) > ttl + nonisolated func isExpired(ttl: TimeInterval, now: @escaping @Sendable () -> Date = Date.init) -> Bool { + now().timeIntervalSince(timestamp) > ttl } } |
