Second half of the silent-failure fix - OutlineKit's RetryPolicy and
CachingOutlineAPIClient tracking landed in 512c6d2, this wires the
rest of the app onto it.
Every bare `try? await apiClient.X(...)` that bypasses
CachingOutlineAPIClient's own caching (listPins, listSubscriptions,
listViews, listStars, documentUsers, listUsers, listComments,
currentUser, installationInfo, authInfo, deleteAttachment - the
"pass-through" methods) now goes through RetryPolicy.withRetry first,
so a single transient blip gets absorbed automatically instead of
just returning nil. Calls that were already routed through
CachingOutlineAPIClient's cached-read path (documentInfo,
listDocuments, listCollections, etc.) are left alone - they picked up
retry and repeated-failure tracking for free from the previous commit
and wrapping them again would've just retried twice.
New: APIFailureCenter (Root/) turns CachingOutlineAPIClient's
repeatedFailureSummaries() into a banner - RootView polls it every
30s while signed in (cheap, no network call of its own) and shows
RepeatedFailureBanner for whichever category is currently past the
threshold. No manual "Retry" button - the retries already happened
automatically before the banner ever appears, so the only actions are
Report (opens a prefilled Gitea issue - category, generic error
description, app/OS version, no document content or server URL) and
dismiss, which starts a 15-minute cooldown so a still-flaky operation
doesn't immediately pop the same banner back up.
Not compiler-verified - the Outpost app target has no CLI build path,
only OutlineKit does (92/92 passing as of the previous commit, no
OutlineKit changes here).
67 lines
2.9 KiB
Swift
67 lines
2.9 KiB
Swift
import Observation
|
|
import OutlineKit
|
|
|
|
/// Turns `CachingOutlineAPIClient.repeatedFailureSummaries()` into a banner
|
|
/// the user can actually see and act on, instead of a silently-swallowed
|
|
/// `try?` — see the pins bug this whole mechanism exists to catch a repeat
|
|
/// of. `RootView` polls the client periodically and feeds results in via
|
|
/// `update(with:)`; nothing here talks to the network directly.
|
|
@MainActor
|
|
@Observable
|
|
final class APIFailureCenter {
|
|
/// The single most-relevant category to show right now, or nil if
|
|
/// nothing's currently past the threshold (or everything past it has
|
|
/// been dismissed and is still in its cooldown).
|
|
private(set) var activeBanner: RepeatedFailure?
|
|
|
|
/// Categories the user's already dismissed, and when — suppressed from
|
|
/// reappearing until `dismissCooldown` passes, so a still-flaky
|
|
/// operation doesn't pop the same banner right back up a few seconds
|
|
/// after being told to go away.
|
|
private var dismissedAt: [String: Date] = [:]
|
|
private let dismissCooldown: TimeInterval = 900
|
|
|
|
/// Called from `RootView`'s poll loop with the latest snapshot from
|
|
/// `CachingOutlineAPIClient`. Picks the worst-offending category
|
|
/// (highest failure count) that isn't in cooldown; clears the banner
|
|
/// entirely once nothing qualifies (e.g. the user went back online and
|
|
/// everything recovered).
|
|
func update(with summaries: [RepeatedFailure]) {
|
|
let now = Date()
|
|
dismissedAt = dismissedAt.filter { now.timeIntervalSince($0.value) < dismissCooldown }
|
|
|
|
let eligible = summaries
|
|
.filter { dismissedAt[$0.category] == nil }
|
|
.sorted { $0.count > $1.count }
|
|
|
|
activeBanner = eligible.first
|
|
}
|
|
|
|
/// Dismiss without reporting — starts that category's cooldown so it
|
|
/// won't immediately reappear on the next poll if it's still failing.
|
|
func dismiss() {
|
|
guard let category = activeBanner?.category else { return }
|
|
dismissedAt[category] = Date()
|
|
activeBanner = nil
|
|
}
|
|
|
|
/// Everything folded into the report is safe to paste into a public bug
|
|
/// tracker as-is: a category name, a generic error description, and
|
|
/// version numbers — no document content, no server URL, no token.
|
|
func reportURL(appVersion: String, osVersion: String) -> URL? {
|
|
guard let banner = activeBanner else { return nil }
|
|
var components = URLComponents(string: "https://git.psmattas.com/psmattas/Outpost/issues/new")
|
|
let body = """
|
|
Outpost kept failing to \(banner.category) (\(banner.count) times in the last few minutes).
|
|
|
|
Error: \(banner.message)
|
|
App version: \(appVersion)
|
|
macOS: \(osVersion)
|
|
|
|
<!-- Anything else you can add about what you were doing when this started would help. -->
|
|
"""
|
|
components?.queryItems = [URLQueryItem(name: "body", value: body)]
|
|
return components?.url
|
|
}
|
|
}
|