summaryrefslogtreecommitdiff
path: root/Hutch/Networking/SystemStatusRepository.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-12 00:15:13 -0500
committerChristian Cleberg <[email protected]>2026-04-12 00:15:13 -0500
commitcb1ea5ea4f163d87053285d3fb7999b12a3558b9 (patch)
tree84b562d12e642ff494194387b1b4e1d15b065eb0 /Hutch/Networking/SystemStatusRepository.swift
parent1c5a417277d986ee6fb7dc9dff0332338bdf6f3e (diff)
downloadhutch-cb1ea5ea4f163d87053285d3fb7999b12a3558b9.tar.gz
hutch-cb1ea5ea4f163d87053285d3fb7999b12a3558b9.tar.bz2
hutch-cb1ea5ea4f163d87053285d3fb7999b12a3558b9.zip
harden system status and app reliabilityv2.13.1
Diffstat (limited to 'Hutch/Networking/SystemStatusRepository.swift')
-rw-r--r--Hutch/Networking/SystemStatusRepository.swift172
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
}
}