diff options
| author | Christian Cleberg <[email protected]> | 2026-04-12 00:15:13 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-12 00:15:13 -0500 |
| commit | cb1ea5ea4f163d87053285d3fb7999b12a3558b9 (patch) | |
| tree | 84b562d12e642ff494194387b1b4e1d15b065eb0 /Hutch/Networking | |
| parent | 1c5a417277d986ee6fb7dc9dff0332338bdf6f3e (diff) | |
| download | hutch-cb1ea5ea4f163d87053285d3fb7999b12a3558b9.tar.gz hutch-cb1ea5ea4f163d87053285d3fb7999b12a3558b9.tar.bz2 hutch-cb1ea5ea4f163d87053285d3fb7999b12a3558b9.zip | |
harden system status and app reliabilityv2.13.1
Diffstat (limited to 'Hutch/Networking')
| -rw-r--r-- | Hutch/Networking/SystemStatusCacheStore.swift | 59 | ||||
| -rw-r--r-- | Hutch/Networking/SystemStatusRepository.swift | 172 | ||||
| -rw-r--r-- | Hutch/Networking/SystemStatusService.swift | 118 |
3 files changed, 303 insertions, 46 deletions
diff --git a/Hutch/Networking/SystemStatusCacheStore.swift b/Hutch/Networking/SystemStatusCacheStore.swift new file mode 100644 index 0000000..63c64b9 --- /dev/null +++ b/Hutch/Networking/SystemStatusCacheStore.swift @@ -0,0 +1,59 @@ +import Foundation + +actor SystemStatusCacheStore { + private let snapshotCacheKey = "systemStatusSnapshotCache" + private let incidentCacheKey = "systemStatusIncidentCache" + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func loadSnapshotHTML() -> (html: String, timestamp: Date)? { + guard let html = defaults.string(forKey: snapshotCacheKey) else { + return nil + } + + let timestampValue = defaults.double(forKey: snapshotTimestampKey) + guard timestampValue > 0 else { + defaults.removeObject(forKey: snapshotCacheKey) + defaults.removeObject(forKey: snapshotTimestampKey) + return nil + } + + return (html, Date(timeIntervalSince1970: timestampValue)) + } + + func saveSnapshotHTML(_ html: String, timestamp: Date) { + defaults.set(html, forKey: snapshotCacheKey) + defaults.set(timestamp.timeIntervalSince1970, forKey: snapshotTimestampKey) + } + + func loadIncidentFeedData() -> (data: Data, timestamp: Date)? { + guard let data = defaults.data(forKey: incidentCacheKey) else { + return nil + } + + let timestampValue = defaults.double(forKey: incidentTimestampKey) + guard timestampValue > 0 else { + defaults.removeObject(forKey: incidentCacheKey) + defaults.removeObject(forKey: incidentTimestampKey) + return nil + } + + return (data, Date(timeIntervalSince1970: timestampValue)) + } + + func saveIncidentFeedData(_ data: Data, timestamp: Date) { + defaults.set(data, forKey: incidentCacheKey) + defaults.set(timestamp.timeIntervalSince1970, forKey: incidentTimestampKey) + } + + private var snapshotTimestampKey: String { + "\(snapshotCacheKey).timestamp" + } + + private var incidentTimestampKey: String { + "\(incidentCacheKey).timestamp" + } +} 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 } } diff --git a/Hutch/Networking/SystemStatusService.swift b/Hutch/Networking/SystemStatusService.swift index fd38fd3..5dd27b5 100644 --- a/Hutch/Networking/SystemStatusService.swift +++ b/Hutch/Networking/SystemStatusService.swift @@ -13,15 +13,23 @@ struct SystemStatusService: Sendable { } func fetchSnapshot() async throws -> SystemStatusSnapshot { - let html = try await fetchText(from: Self.statusURL, accept: "text/html,application/xhtml+xml") + let html = try await fetchSnapshotHTML() return try Self.parseSnapshotHTML(html, fetchedAt: now()) } func fetchIncidentFeed() async throws -> [StatusIncident] { - let data = try await fetchData(from: Self.feedURL, accept: "application/rss+xml,application/xml,text/xml") + let data = try await fetchIncidentFeedData() return try await Self.parseIncidentFeedXML(data) } + func fetchSnapshotHTML() async throws -> String { + try await fetchText(from: Self.statusURL, accept: "text/html,application/xhtml+xml") + } + + func fetchIncidentFeedData() async throws -> Data { + try await fetchData(from: Self.feedURL, accept: "application/rss+xml,application/xml,text/xml") + } + private func fetchText(from url: URL, accept: String) async throws -> String { let data = try await fetchData(from: url, accept: accept) guard let text = String(data: data, encoding: .utf8) else { @@ -62,6 +70,8 @@ struct SystemStatusService: Sendable { } } +extension SystemStatusService: SystemStatusServing {} + extension SystemStatusService { nonisolated static func parseSnapshotHTML(_ html: String, fetchedAt: Date) throws -> SystemStatusSnapshot { let services = parseServices(in: html) @@ -96,7 +106,7 @@ extension SystemStatusService { nonisolated private static func parseServices(in html: String) -> [StatusServiceState] { firstMatches( in: html, - pattern: #"<div class="component" data-status="([^"]+)">([\s\S]*?)</div>"# + pattern: #"<div\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bcomponent\b[^"']*["'])(?=[^>]*\bdata-status\s*=\s*["']([^"']+)["'])[^>]*>([\s\S]*?)</div>"# ).compactMap { captures in guard captures.count >= 2 else { return nil } @@ -104,16 +114,18 @@ extension SystemStatusService { let content = captures[1] guard let linkCaptures = firstMatches( in: content, - pattern: #"<a[^>]*href="([^"]+)"[^>]*>\s*(.*?)\s*</a>"# + pattern: #"<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)</a>"# ).first, - linkCaptures.count >= 2, - let statusText = firstMatch(in: content, pattern: #"<span class="component-status">\s*(.*?)\s*</span>"#) else { + linkCaptures.count >= 2 else { return nil } let href = linkCaptures[0] let cleanedName = cleanText(linkCaptures[1]) - let readableStatus = cleanText(statusText) + let readableStatus = firstMatch( + in: content, + pattern: #"<(?:span|small|div)\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bcomponent-status\b[^"']*["'])[^>]*>([\s\S]*?)</(?:span|small|div)>"# + ).map(cleanText) ?? firstStatusLabel(in: content) ?? "" let level = statusLevel(fromHTMLStatus: rawStatus) return StatusServiceState( @@ -129,19 +141,19 @@ extension SystemStatusService { nonisolated private static func parseHTMLIncidentCards(in html: String) -> [StatusIncident] { firstMatches( in: html, - pattern: #"<a href="([^"]+)" class="issue no-underline">([\s\S]*?)</a>"# + pattern: #"<a\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bissue\b[^"']*["'])(?=[^>]*\bhref\s*=\s*["']([^"']+)["'])[^>]*>([\s\S]*?)</a>"# ).compactMap { captures in guard captures.count >= 2 else { return nil } let href = captures[0] let content = captures[1] - guard let titleHTML = firstMatch(in: content, pattern: #"<h3>\s*([\s\S]*?)\s*</h3>"#), - let titleAttribute = firstMatch(in: content, pattern: #"<small class="date[^"]*" title="([^"]+)">"#), - let publishedAt = htmlIssueDateFormatter.date(from: cleanText(titleAttribute)) else { + guard let titleHTML = firstMatch(in: content, pattern: #"<h[1-6][^>]*>\s*([\s\S]*?)\s*</h[1-6]>"#) + ?? firstMatch(in: content, pattern: #"<strong[^>]*>\s*([\s\S]*?)\s*</strong>"#) + ?? firstMatch(in: content, pattern: #"<span[^>]*>\s*([\s\S]*?)\s*</span>"#), + let publishedAt = publishedIncidentDate(in: content) else { return nil } let url = URL(string: href, relativeTo: statusURL)?.absoluteURL - let isActive = content.localizedCaseInsensitiveContains("This issue is not resolved yet") return StatusIncident( id: url?.absoluteString ?? cleanText(titleHTML), title: cleanText(titleHTML), @@ -149,7 +161,7 @@ extension SystemStatusService { url: url, publishedAt: publishedAt, updatedAt: nil, - isActive: isActive + isActive: isActiveIncidentCard(content) ) } } @@ -157,12 +169,12 @@ extension SystemStatusService { nonisolated private static func parseActiveIncidentSummaries(in html: String) -> [String: String] { firstMatches( in: html, - pattern: #"<div class="announcement-box"[\s\S]*?<div class="padding">([\s\S]*?)</div>\s*<hr class="clean announcement-box">"# + pattern: #"<div\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bannouncement-box\b[^"']*["'])[^>]*>([\s\S]*?)</div>\s*(?:<hr\b[^>]*\bannouncement-box\b[^>]*>)?"# ).reduce(into: [:]) { partialResult, captures in guard let content = captures.first, let titleLinkCaptures = firstMatches( in: content, - pattern: #"<a href="([^"]+)"><strong class="bold">([\s\S]*?)</strong></a>"# + pattern: #"<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)</a>"# ).first, let href = titleLinkCaptures.first else { return @@ -197,17 +209,22 @@ extension SystemStatusService { } nonisolated private static func statusLevel(fromLabel label: String) -> StatusLevel { - switch label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "operational": - .operational - case "disrupted", "degraded": - .degraded - case "down", "major outage": - .majorOutage - case "maintenance": - .maintenance + let normalizedLabel = label + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + + return switch normalizedLabel { + case "operational", "all systems operational": + StatusLevel.operational + case "disrupted", "degraded", "partial outage": + StatusLevel.degraded + case "down", "major outage", "outage": + StatusLevel.majorOutage + case "maintenance", "scheduled maintenance", "under maintenance": + StatusLevel.maintenance default: - .unknown + StatusLevel.unknown } } @@ -252,6 +269,47 @@ extension SystemStatusService { .trimmingCharacters(in: .whitespacesAndNewlines) } + nonisolated private static func firstStatusLabel(in text: String) -> String? { + cleanText(text) + .split(separator: "•") + .map(String.init) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .first(where: { statusLevel(fromLabel: $0) != .unknown }) + } + + nonisolated private static func publishedIncidentDate(in content: String) -> Date? { + if let timeValue = firstMatch(in: content, pattern: #"<time\b[^>]*\bdatetime\s*=\s*["']([^"']+)["'][^>]*>"#), + let parsed = parseISO8601Date(cleanText(timeValue)) { + return parsed + } + + if let titleAttribute = firstMatch( + in: content, + pattern: #"<(?:small|time)\b(?=[^>]*\bclass\s*=\s*["'][^"']*\bdate\b[^"']*["'])[^>]*\btitle\s*=\s*["']([^"']+)["'][^>]*>"# + ) ?? firstMatch(in: content, pattern: #"\btitle\s*=\s*["']([^"']+UTC)["']"#) { + return htmlIssueDateFormatter.date(from: cleanText(titleAttribute)) + } + + return nil + } + + nonisolated private static func isActiveIncidentCard(_ content: String) -> Bool { + let normalizedContent = cleanText(content).lowercased() + return normalizedContent.contains("not resolved yet") + || normalizedContent.contains("ongoing") + || normalizedContent.contains("investigating") + } + + nonisolated fileprivate static func parseISO8601Date(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: value) ?? { + let fallbackFormatter = ISO8601DateFormatter() + fallbackFormatter.formatOptions = [.withInternetDateTime] + return fallbackFormatter.date(from: value) + }() + } + nonisolated private static let htmlIssueDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") @@ -259,9 +317,9 @@ extension SystemStatusService { formatter.dateFormat = "MMM d HH:mm:ss yyyy zzz" return formatter }() + } -@MainActor private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchecked Sendable { private var incidents: [StatusIncident] = [] private var currentItem: FeedItem? @@ -315,7 +373,7 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec currentItem.guid = value case "description": currentItem.description = value - case "pubDate": + case "pubDate", "dc:date": currentItem.pubDate = value case "category": currentItem.category = value @@ -345,7 +403,7 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec func makeIncident() -> StatusIncident? { let cleanedTitle = title.replacingOccurrences(of: "[Resolved] ", with: "") guard !cleanedTitle.isEmpty, - let publishedAt = SystemStatusFeedParser.pubDateFormatter.date(from: pubDate) else { + let publishedAt = SystemStatusFeedParser.parsePubDate(pubDate) else { return nil } @@ -382,6 +440,10 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec return decodeHTMLEntities(stripped) } + nonisolated private static func parsePubDate(_ value: String) -> Date? { + pubDateFormatter.date(from: value) ?? SystemStatusService.parseISO8601Date(value) + } + nonisolated private static let pubDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") |
