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.
70 lines
2.4 KiB
Swift
70 lines
2.4 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|