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.
This commit is contained in:
@@ -37,6 +37,16 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
private let decoder: JSONDecoder
|
private let decoder: JSONDecoder
|
||||||
private let keyEncoder: JSONEncoder
|
private let keyEncoder: JSONEncoder
|
||||||
|
|
||||||
|
/// Recent failure timestamps per category — see `recordFailure` /
|
||||||
|
/// `repeatedFailureSummaries()`. A category only shows up there once it's
|
||||||
|
/// failed `failureThreshold` times within `failureWindow`; a single
|
||||||
|
/// transient blip (which `RetryPolicy` already tries to absorb) never
|
||||||
|
/// reaches this at all.
|
||||||
|
private var failureLog: [String: [Date]] = [:]
|
||||||
|
private var lastFailureMessage: [String: String] = [:]
|
||||||
|
private let failureWindow: TimeInterval = 300
|
||||||
|
private let failureThreshold: Int = 3
|
||||||
|
|
||||||
public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) {
|
public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) {
|
||||||
self.live = live
|
self.live = live
|
||||||
self.cache = cache
|
self.cache = cache
|
||||||
@@ -62,7 +72,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
// MARK: - Cached reads
|
// MARK: - Cached reads
|
||||||
|
|
||||||
public func documentInfo(id: String) async throws -> OutlineDocument {
|
public func documentInfo(id: String) async throws -> OutlineDocument {
|
||||||
try await cachedFetch(key: "document:\(id)") { try await self.live.documentInfo(id: id) }
|
try await cachedFetch(key: "document:\(id)", category: "document") { try await self.live.documentInfo(id: id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
public func listDocuments(
|
public func listDocuments(
|
||||||
@@ -72,7 +82,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
limit: Int
|
limit: Int
|
||||||
) async throws -> [OutlineDocument] {
|
) async throws -> [OutlineDocument] {
|
||||||
let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)"
|
let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)"
|
||||||
return try await cachedFetch(key: key) {
|
return try await cachedFetch(key: key, category: "documents") {
|
||||||
try await self.live.listDocuments(
|
try await self.live.listDocuments(
|
||||||
collectionId: collectionId,
|
collectionId: collectionId,
|
||||||
parentDocumentId: parentDocumentId,
|
parentDocumentId: parentDocumentId,
|
||||||
@@ -83,29 +93,29 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] {
|
public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] {
|
||||||
try await cachedFetch(key: requestKey("documentsList", request)) { try await self.live.documentsList(request) }
|
try await cachedFetch(key: requestKey("documentsList", request), category: "documents") { try await self.live.documentsList(request) }
|
||||||
}
|
}
|
||||||
|
|
||||||
public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] {
|
public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] {
|
||||||
try await cachedFetch(key: "documentsViewed:\(offset):\(limit)") {
|
try await cachedFetch(key: "documentsViewed:\(offset):\(limit)", category: "documents-viewed") {
|
||||||
try await self.live.listViewedDocuments(offset: offset, limit: limit)
|
try await self.live.listViewedDocuments(offset: offset, limit: limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] {
|
public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] {
|
||||||
try await cachedFetch(key: "documentsDrafts:\(request.offset):\(request.limit)") {
|
try await cachedFetch(key: "documentsDrafts:\(request.offset):\(request.limit)", category: "drafts") {
|
||||||
try await self.live.listDrafts(request)
|
try await self.live.listDrafts(request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
||||||
try await cachedFetch(key: "collections:\(offset):\(limit)") {
|
try await cachedFetch(key: "collections:\(offset):\(limit)", category: "collections") {
|
||||||
try await self.live.listCollections(offset: offset, limit: limit)
|
try await self.live.listCollections(offset: offset, limit: limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func collectionInfo(id: String) async throws -> OutlineCollection {
|
public func collectionInfo(id: String) async throws -> OutlineCollection {
|
||||||
try await cachedFetch(key: "collection:\(id)") { try await self.live.collectionInfo(id: id) }
|
try await cachedFetch(key: "collection:\(id)", category: "collections") { try await self.live.collectionInfo(id: id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Queueable writes
|
// MARK: - Queueable writes
|
||||||
@@ -121,10 +131,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do {
|
do {
|
||||||
let result = try await live.createDocument(request)
|
let result = try await RetryPolicy.withRetry { try await self.live.createDocument(request) }
|
||||||
|
recordSuccess(category: "documents-write")
|
||||||
await cacheDocument(result)
|
await cacheDocument(result)
|
||||||
return result
|
return result
|
||||||
} catch {
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "documents-write", error)
|
||||||
return await queueDocumentCreate(request)
|
return await queueDocumentCreate(request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,10 +146,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do {
|
do {
|
||||||
let result = try await live.updateDocument(request)
|
let result = try await RetryPolicy.withRetry { try await self.live.updateDocument(request) }
|
||||||
|
recordSuccess(category: "documents-write")
|
||||||
await cacheDocument(result)
|
await cacheDocument(result)
|
||||||
return result
|
return result
|
||||||
} catch {
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "documents-write", error)
|
||||||
return try await queueDocumentUpdate(request, dueTo: error)
|
return try await queueDocumentUpdate(request, dueTo: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,10 +161,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
|
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do {
|
do {
|
||||||
let result = try await live.updateCollection(request)
|
let result = try await RetryPolicy.withRetry { try await self.live.updateCollection(request) }
|
||||||
|
recordSuccess(category: "collections-write")
|
||||||
await cacheCollection(result)
|
await cacheCollection(result)
|
||||||
return result
|
return result
|
||||||
} catch {
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "collections-write", error)
|
||||||
return try await queueCollectionUpdate(request, dueTo: error)
|
return try await queueCollectionUpdate(request, dueTo: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +175,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
|
|
||||||
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
|
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do { return try await live.createPin(request) } catch { return await queuePinCreate(request) }
|
do {
|
||||||
|
let result = try await RetryPolicy.withRetry { try await self.live.createPin(request) }
|
||||||
|
recordSuccess(category: "pins")
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "pins", error)
|
||||||
|
return await queuePinCreate(request)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return await queuePinCreate(request)
|
return await queuePinCreate(request)
|
||||||
}
|
}
|
||||||
@@ -168,9 +191,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
if await cancelIfNeverSynced(id: id) { return }
|
if await cancelIfNeverSynced(id: id) { return }
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do {
|
do {
|
||||||
try await live.deletePin(id: id)
|
try await RetryPolicy.withRetry { try await self.live.deletePin(id: id) }
|
||||||
|
recordSuccess(category: "pins")
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "pins", error)
|
||||||
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
|
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -180,7 +205,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
|
|
||||||
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
|
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do { return try await live.createSubscription(request) } catch { return await queueSubscriptionCreate(request) }
|
do {
|
||||||
|
let result = try await RetryPolicy.withRetry { try await self.live.createSubscription(request) }
|
||||||
|
recordSuccess(category: "subscriptions")
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "subscriptions", error)
|
||||||
|
return await queueSubscriptionCreate(request)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return await queueSubscriptionCreate(request)
|
return await queueSubscriptionCreate(request)
|
||||||
}
|
}
|
||||||
@@ -189,9 +221,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
if await cancelIfNeverSynced(id: id) { return }
|
if await cancelIfNeverSynced(id: id) { return }
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do {
|
do {
|
||||||
try await live.deleteSubscription(id: id)
|
try await RetryPolicy.withRetry { try await self.live.deleteSubscription(id: id) }
|
||||||
|
recordSuccess(category: "subscriptions")
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "subscriptions", error)
|
||||||
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
|
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -201,14 +235,28 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
|
|
||||||
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
|
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do { return try await live.starDocument(request) } catch { return await queueStarDocumentCreate(request) }
|
do {
|
||||||
|
let result = try await RetryPolicy.withRetry { try await self.live.starDocument(request) }
|
||||||
|
recordSuccess(category: "stars")
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "stars", error)
|
||||||
|
return await queueStarDocumentCreate(request)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return await queueStarDocumentCreate(request)
|
return await queueStarDocumentCreate(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
|
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do { return try await live.starCollection(request) } catch { return await queueStarCollectionCreate(request) }
|
do {
|
||||||
|
let result = try await RetryPolicy.withRetry { try await self.live.starCollection(request) }
|
||||||
|
recordSuccess(category: "stars")
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "stars", error)
|
||||||
|
return await queueStarCollectionCreate(request)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return await queueStarCollectionCreate(request)
|
return await queueStarCollectionCreate(request)
|
||||||
}
|
}
|
||||||
@@ -217,9 +265,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
if await cancelIfNeverSynced(id: id) { return }
|
if await cancelIfNeverSynced(id: id) { return }
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do {
|
do {
|
||||||
try await live.deleteStar(id: id)
|
try await RetryPolicy.withRetry { try await self.live.deleteStar(id: id) }
|
||||||
|
recordSuccess(category: "stars")
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "stars", error)
|
||||||
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
|
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -564,7 +614,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T {
|
private func cachedFetch<T: Codable>(key: String, category: String, fetch: () async throws -> T) async throws -> T {
|
||||||
// Manual offline mode means "skip the network entirely," not just
|
// Manual offline mode means "skip the network entirely," not just
|
||||||
// "prefer it" — without this check, a read would still hit `live`
|
// "prefer it" — without this check, a read would still hit `live`
|
||||||
// (and succeed, showing content beyond whatever's cached) any time
|
// (and succeed, showing content beyond whatever's cached) any time
|
||||||
@@ -577,12 +627,21 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
let result = try await fetch()
|
let result = try await RetryPolicy.withRetry { try await fetch() }
|
||||||
|
recordSuccess(category: category)
|
||||||
if let data = try? encoder.encode(result) {
|
if let data = try? encoder.encode(result) {
|
||||||
await cache.save(data, forKey: key)
|
await cache.save(data, forKey: key)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
} catch {
|
} catch {
|
||||||
|
// Only a structural failure (decode/auth/server — the server
|
||||||
|
// answered, but something's actually wrong) counts toward the
|
||||||
|
// repeated-failure log. Plain connectivity loss already has its
|
||||||
|
// own offline UI elsewhere; logging it here too would just be a
|
||||||
|
// second banner for the same thing every time Wi-Fi drops.
|
||||||
|
if !RetryPolicy.isRetryable(error) {
|
||||||
|
recordFailure(category: category, message: errorDescription(error))
|
||||||
|
}
|
||||||
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
|
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
@@ -788,6 +847,46 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Writes always queue on any failure (existing behavior, unchanged) —
|
||||||
|
/// this only decides whether the failure is worth logging. A structural
|
||||||
|
/// error (decode/auth/server) queuing for later replay will likely just
|
||||||
|
/// fail the same way again next sync; a transient one might not. Either
|
||||||
|
/// way the queue doesn't change, only whether it's counted toward
|
||||||
|
/// `repeatedFailureSummaries()`.
|
||||||
|
private func recordWriteFailureIfStructural(category: String, _ error: Error) {
|
||||||
|
guard !RetryPolicy.isRetryable(error) else { return }
|
||||||
|
recordFailure(category: category, message: errorDescription(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recordFailure(category: String, message: String) {
|
||||||
|
let now = Date()
|
||||||
|
var timestamps = (failureLog[category] ?? []).filter { now.timeIntervalSince($0) < failureWindow }
|
||||||
|
timestamps.append(now)
|
||||||
|
failureLog[category] = timestamps
|
||||||
|
lastFailureMessage[category] = message
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recordSuccess(category: String) {
|
||||||
|
failureLog[category] = nil
|
||||||
|
lastFailureMessage[category] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Categories that have failed `failureThreshold`+ times within the last
|
||||||
|
/// `failureWindow` seconds — meant to be polled periodically (see
|
||||||
|
/// `RootView`), not pushed, since this actor has no UI-facing dependency
|
||||||
|
/// of its own. A single blip never shows up here: `RetryPolicy` absorbs
|
||||||
|
/// transient failures before they're ever logged, and only structural
|
||||||
|
/// ones (decode/auth/server) get logged at all — see
|
||||||
|
/// `recordWriteFailureIfStructural` and `cachedFetch`.
|
||||||
|
public func repeatedFailureSummaries() async -> [RepeatedFailure] {
|
||||||
|
let now = Date()
|
||||||
|
return failureLog.compactMap { category, timestamps in
|
||||||
|
let recent = timestamps.filter { now.timeIntervalSince($0) < failureWindow }
|
||||||
|
guard recent.count >= failureThreshold, let message = lastFailureMessage[category] else { return nil }
|
||||||
|
return RepeatedFailure(category: category, message: message, count: recent.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func errorDescription(_ error: Error) -> String {
|
private func errorDescription(_ error: Error) -> String {
|
||||||
if let apiError = error as? OutlineAPIError {
|
if let apiError = error as? OutlineAPIError {
|
||||||
switch apiError {
|
switch apiError {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A category of API call that's failed repeatedly within a short window —
|
||||||
|
/// see `CachingOutlineAPIClient.repeatedFailureSummaries()`. Deliberately
|
||||||
|
/// carries no request/response payload, document content, or server URL:
|
||||||
|
/// this is meant to be safe to show a user or attach to a bug report as-is.
|
||||||
|
public struct RepeatedFailure: Sendable, Identifiable, Equatable {
|
||||||
|
public let category: String
|
||||||
|
public let message: String
|
||||||
|
public let count: Int
|
||||||
|
|
||||||
|
public var id: String { category }
|
||||||
|
|
||||||
|
public init(category: String, message: String, count: Int) {
|
||||||
|
self.category = category
|
||||||
|
self.message = message
|
||||||
|
self.count = count
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -553,4 +553,102 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
// expected — nothing left under the old id
|
// expected — nothing left under the old id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Repeated-failure tracking
|
||||||
|
|
||||||
|
/// A structural failure (decode/auth/server) isn't retried — it fails
|
||||||
|
/// the same way every time, so `RetryPolicy` gives up after one attempt
|
||||||
|
/// and it's logged immediately. No cache entry means no fallback either,
|
||||||
|
/// so every call rethrows.
|
||||||
|
func testRepeatedDecodingFailuresSurfaceAfterThreshold() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||||
|
|
||||||
|
for _ in 0..<3 {
|
||||||
|
_ = try? await sut.documentInfo(id: "doc-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
let summaries = await sut.repeatedFailureSummaries()
|
||||||
|
XCTAssertEqual(summaries.count, 1)
|
||||||
|
XCTAssertEqual(summaries.first?.category, "document")
|
||||||
|
XCTAssertEqual(summaries.first?.count, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two failures alone shouldn't trip the banner — only three or more
|
||||||
|
/// within the window counts as "repeated."
|
||||||
|
func testFewerThanThresholdFailuresDoNotSurface() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||||
|
|
||||||
|
for _ in 0..<2 {
|
||||||
|
_ = try? await sut.documentInfo(id: "doc-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
let summaries = await sut.repeatedFailureSummaries()
|
||||||
|
XCTAssertTrue(summaries.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A later success clears the category entirely — a transient run of
|
||||||
|
/// bad luck shouldn't leave a stale banner up after things recover.
|
||||||
|
func testSuccessAfterRepeatedFailuresClearsTheLog() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
let document = makeDocument()
|
||||||
|
var callCount = 0
|
||||||
|
stub.documentInfoHandler = { _ in
|
||||||
|
callCount += 1
|
||||||
|
if callCount <= 3 { throw OutlineAPIError.decoding(NotStubbed()) }
|
||||||
|
return document
|
||||||
|
}
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||||
|
|
||||||
|
for _ in 0..<3 {
|
||||||
|
_ = try? await sut.documentInfo(id: "doc-1")
|
||||||
|
}
|
||||||
|
let beforeRecovery = await sut.repeatedFailureSummaries()
|
||||||
|
XCTAssertEqual(beforeRecovery.count, 1)
|
||||||
|
|
||||||
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
|
|
||||||
|
let afterRecovery = await sut.repeatedFailureSummaries()
|
||||||
|
XCTAssertTrue(afterRecovery.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain connectivity loss (`OutlineAPIError.transport`) already has its
|
||||||
|
/// own offline UI elsewhere — it shouldn't also pile up in the repeated-
|
||||||
|
/// failure log and pop a second, redundant banner.
|
||||||
|
func testTransportFailuresDoNotCountTowardTheRepeatedFailureLog() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.documentInfoHandler = { _ in throw OutlineAPIError.transport(URLError(.notConnectedToInternet)) }
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||||
|
|
||||||
|
_ = try? await sut.documentInfo(id: "doc-1")
|
||||||
|
|
||||||
|
let summaries = await sut.repeatedFailureSummaries()
|
||||||
|
XCTAssertTrue(summaries.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Different categories (document reads vs. pin writes) track
|
||||||
|
/// independently — a broken pins endpoint shouldn't mask, or be masked
|
||||||
|
/// by, unrelated document failures, and one crossing the threshold
|
||||||
|
/// shouldn't drag an unrelated one along with it.
|
||||||
|
func testFailuresInDifferentCategoriesDoNotMix() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
||||||
|
stub.createPinHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||||
|
|
||||||
|
// Document reads cross the threshold...
|
||||||
|
for _ in 0..<3 {
|
||||||
|
_ = try? await sut.documentInfo(id: "doc-1")
|
||||||
|
}
|
||||||
|
// ...pin creates don't.
|
||||||
|
for _ in 0..<2 {
|
||||||
|
_ = try? await sut.createPin(CreatePinRequest(documentId: "doc-1", collectionId: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
let summaries = await sut.repeatedFailureSummaries()
|
||||||
|
XCTAssertEqual(summaries.map(\.category), ["document"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import OutlineKit
|
||||||
|
|
||||||
|
private struct PlainError: Error {}
|
||||||
|
|
||||||
|
final class RetryPolicyTests: XCTestCase {
|
||||||
|
func testSucceedsOnFirstAttemptWithoutRetrying() async throws {
|
||||||
|
var callCount = 0
|
||||||
|
let result = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) {
|
||||||
|
callCount += 1
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
XCTAssertEqual(result, "ok")
|
||||||
|
XCTAssertEqual(callCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRetriesTransportErrorsAndSucceedsOnceItStopsFailing() async throws {
|
||||||
|
var callCount = 0
|
||||||
|
let result = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in
|
||||||
|
callCount += 1
|
||||||
|
if callCount < 3 { throw OutlineAPIError.transport(URLError(.timedOut)) }
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
XCTAssertEqual(result, "ok")
|
||||||
|
XCTAssertEqual(callCount, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGivesUpAfterMaxAttemptsAndRethrowsTheLastError() async throws {
|
||||||
|
var callCount = 0
|
||||||
|
do {
|
||||||
|
_ = try await RetryPolicy.withRetry(maxAttempts: 3, initialDelay: .milliseconds(1)) { () -> String in
|
||||||
|
callCount += 1
|
||||||
|
throw OutlineAPIError.transport(URLError(.timedOut))
|
||||||
|
}
|
||||||
|
XCTFail("Expected the persistent failure to be rethrown")
|
||||||
|
} catch {
|
||||||
|
XCTAssertEqual(callCount, 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A decode failure means the response is structurally wrong — trying
|
||||||
|
/// again gets the exact same wrong response, so it isn't worth the
|
||||||
|
/// cooldown window the way a network blip is.
|
||||||
|
func testDoesNotRetryNonTransportErrors() async throws {
|
||||||
|
var callCount = 0
|
||||||
|
do {
|
||||||
|
_ = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in
|
||||||
|
callCount += 1
|
||||||
|
throw OutlineAPIError.decoding(PlainError())
|
||||||
|
}
|
||||||
|
XCTFail("Expected the decoding error to be rethrown without retrying")
|
||||||
|
} catch {
|
||||||
|
XCTAssertEqual(callCount, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDoesNotRetryErrorsThatAreNotOutlineAPIErrors() async throws {
|
||||||
|
var callCount = 0
|
||||||
|
do {
|
||||||
|
_ = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in
|
||||||
|
callCount += 1
|
||||||
|
throw PlainError()
|
||||||
|
}
|
||||||
|
XCTFail("Expected the error to be rethrown without retrying")
|
||||||
|
} catch {
|
||||||
|
XCTAssertEqual(callCount, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user