summaryrefslogtreecommitdiff
path: root/Hutch/Networking
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
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')
-rw-r--r--Hutch/Networking/SystemStatusCacheStore.swift59
-rw-r--r--Hutch/Networking/SystemStatusRepository.swift172
-rw-r--r--Hutch/Networking/SystemStatusService.swift118
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")