Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Changelog

## Unreleased

### Changed

- Read-style kDrive operations now recognize HTTP 429 responses and retry with
bounded, cancellation-aware backoff. Server-provided `Retry-After` values are
preferred; otherwise the provider uses jittered exponential delays.
- Mutation requests remain single-attempt so an ambiguous response cannot
replay a server-side change.
- Exhausted rate limits map to File Provider's recoverable
`.serverUnreachable` state and retain only sanitized diagnostics.

### Dependency state

- Requires the coordinated potassiumChannel 0.3.0 response-metadata API, which
preserves `Retry-After` without exposing arbitrary response headers.

## 0.3.0

### Added
Expand Down
165 changes: 165 additions & 0 deletions PotassiumProviderCore/KDriveRateLimitRetry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import Foundation

struct KDriveRateLimitRetryPolicy: Equatable, Sendable {
static let `default` = KDriveRateLimitRetryPolicy(
maximumRetryCount: 3,
initialBackoff: 1,
maximumDelay: 60,
jitterRange: 0.8...1.2
)

let maximumRetryCount: Int
let initialBackoff: TimeInterval
let maximumDelay: TimeInterval
let jitterRange: ClosedRange<Double>

func delay(
retryNumber: Int,
retryAfter: String?,
now: Date,
jitterUnitValue: Double
) -> KDriveRateLimitRetryDelay {
if let retryAfter,
let requestedDelay = Self.retryAfterDelay(retryAfter, relativeTo: now) {
return KDriveRateLimitRetryDelay(
seconds: min(requestedDelay, maximumDelay),
source: .server
)
}

let exponent = max(retryNumber - 1, 0)
let exponentialDelay = initialBackoff * pow(2, Double(exponent))
let clampedJitter = min(max(jitterUnitValue, 0), 1)
let jitterMultiplier = jitterRange.lowerBound
+ ((jitterRange.upperBound - jitterRange.lowerBound) * clampedJitter)
return KDriveRateLimitRetryDelay(
seconds: min(exponentialDelay * jitterMultiplier, maximumDelay),
source: .exponential
)
}

private static func retryAfterDelay(
_ rawValue: String,
relativeTo now: Date
) -> TimeInterval? {
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
if let seconds = Int(value), seconds >= 0 {
return TimeInterval(seconds)
}

for format in httpDateFormats {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.calendar = Calendar(identifier: .gregorian)
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = format
if let date = formatter.date(from: value) {
return max(0, date.timeIntervalSince(now))
}
}
return nil
}

private static let httpDateFormats = [
"EEE',' dd MMM yyyy HH':'mm':'ss z",
"EEEE',' dd-MMM-yy HH':'mm':'ss z",
"EEE MMM d HH':'mm':'ss yyyy",
]
}

struct KDriveRateLimitRetryDelay: Equatable, Sendable {
enum Source: String, Equatable, Sendable {
case server
case exponential
}

let seconds: TimeInterval
let source: Source
}

protocol KDriveRetrySleeping: Sendable {
func sleep(for seconds: TimeInterval) async throws
}

struct TaskKDriveRetrySleeper: KDriveRetrySleeping {
func sleep(for seconds: TimeInterval) async throws {
guard seconds > 0 else {
try Task.checkCancellation()
return
}
try await Task.sleep(for: .seconds(seconds))
}
}

protocol KDriveRetryClock: Sendable {
func now() -> Date
}

struct SystemKDriveRetryClock: KDriveRetryClock {
func now() -> Date {
Date()
}
}

protocol KDriveRetryJitterProviding: Sendable {
func unitValue() -> Double
}

struct SystemKDriveRetryJitter: KDriveRetryJitterProviding {
func unitValue() -> Double {
Double.random(in: 0...1)
}
}

struct KDriveRateLimitRetryExecutor: Sendable {
static let live = KDriveRateLimitRetryExecutor(
policy: .default,
sleeper: TaskKDriveRetrySleeper(),
clock: SystemKDriveRetryClock(),
jitter: SystemKDriveRetryJitter()
)

let policy: KDriveRateLimitRetryPolicy
let sleeper: any KDriveRetrySleeping
let clock: any KDriveRetryClock
let jitter: any KDriveRetryJitterProviding

func perform<Value>(
_ work: () async throws -> Value,
onRetry: (Int, KDriveRateLimitRetryDelay) -> Void
) async throws -> Value {
var retryCount = 0

while true {
try Task.checkCancellation()
do {
return try await work()
} catch is CancellationError {
throw CancellationError()
} catch {
if Task.isCancelled {
throw CancellationError()
}
guard let throttling = KDriveRemoteErrorClassifier.throttling(from: error),
retryCount < policy.maximumRetryCount else {
throw error
}

retryCount += 1
let delay = policy.delay(
retryNumber: retryCount,
retryAfter: throttling.retryAfter,
now: clock.now(),
jitterUnitValue: jitter.unitValue()
)
onRetry(retryCount, delay)
try await sleeper.sleep(for: delay.seconds)
}
}
}
}

struct KDriveRemoteThrottling: Equatable, Sendable {
let statusCode: Int
let retryAfter: String?
}
Loading
Loading