Foundation for turning the app's try?-swallowed API failures (see the pins bug) into something self-diagnosing instead of silent, without a manual "Retry" button nagging the user for every blip. RetryPolicy.withRetry wraps a call with exponential backoff, but only for OutlineAPIError.transport - a decode/auth/server error will look identical on a second try, so those fail immediately instead of burning the cooldown window. CachingOutlineAPIClient now runs every live call (both the cached-read path and the queueable-write path) through it, and keeps a per-category sliding-window failure log: repeatedFailureSummaries() surfaces a category only once it's failed 3+ times in 5 minutes with a structural (non-transport) error - plain connectivity loss is deliberately excluded since that already has its own offline UI elsewhere, and logging it here too would just be a redundant second banner every time Wi-Fi drops. Pull-based (polled), not push - this actor has no UI dependency of its own, so the Outpost-side banner reads this periodically instead of the client taking a callback. Categories are coarse (documents, collections, pins, subscriptions, stars, drafts, etc.) and the summaries carry no document content or server URL, only a generic error description - safe to show a user or attach to a bug report as-is. 10 new tests (RetryPolicyTests + CachingOutlineAPIClientTests), 92/92 passing overall.
39 lines
1.4 KiB
Swift
39 lines
1.4 KiB
Swift
import Foundation
|
|
|
|
/// Automatic retry-with-backoff for API calls, so a single transient network
|
|
/// blip doesn't turn into a user-visible failure (or a silently swallowed
|
|
/// one) the way one `try?` used to.
|
|
///
|
|
/// Only retries `OutlineAPIError.transport` — a dropped connection or
|
|
/// timeout might succeed a second later. Everything else (`.decoding`,
|
|
/// `.unauthorized`, `.notFound`, `.server`, `.tokenUnavailable`) is retried
|
|
/// zero times: a response-shape mismatch or a 404 will look exactly the same
|
|
/// on attempt two, so retrying just burns the cooldown window for nothing —
|
|
/// callers should treat those as immediate failures instead.
|
|
public enum RetryPolicy {
|
|
public static func withRetry<T: Sendable>(
|
|
maxAttempts: Int = 3,
|
|
initialDelay: Duration = .seconds(1),
|
|
operation: () async throws -> T
|
|
) async throws -> T {
|
|
var attempt = 1
|
|
var delay = initialDelay
|
|
while true {
|
|
do {
|
|
return try await operation()
|
|
} catch {
|
|
guard attempt < maxAttempts, isRetryable(error) else { throw error }
|
|
attempt += 1
|
|
try? await Task.sleep(for: delay)
|
|
delay *= 2
|
|
}
|
|
}
|
|
}
|
|
|
|
static func isRetryable(_ error: Error) -> Bool {
|
|
guard let apiError = error as? OutlineAPIError else { return false }
|
|
if case .transport = apiError { return true }
|
|
return false
|
|
}
|
|
}
|