Files
Outpost/OutlineKit/Sources/OutlineKit/Support/RetryPolicy.swift
T
Puranjay Savar Mattas 512c6d22bf feat(outlinekit): auto-retry with backoff and repeated-failure tracking
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.
2026-08-21 00:41:26 +01:00

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
}
}