Skip to content

Commit

Permalink
Add RecoveryAction.retryAfter to allow specifying a minimum retry d…
Browse files Browse the repository at this point in the history
…elay.
  • Loading branch information
fumoboy007 committed Jan 7, 2024
1 parent 37be416 commit 7c571c6
Show file tree
Hide file tree
Showing 5 changed files with 149 additions and 1 deletion.
28 changes: 28 additions & 0 deletions Snippets/Advanced Use Cases/EnforceMinDelay.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Sleep a minimum duration before the next attempt.

// snippet.hide

import Retry

// snippet.show

try await retry {
try await doSomething()
} recoverFromFailure: { error in
switch error {
case let error as MyRetryAwareServerError:
return .retryAfter(ContinuousClock().now + error.minRetryDelay)

default:
return .retry
}
}

// snippet.hide

func doSomething() async throws {
}

struct MyRetryAwareServerError: Error {
let minRetryDelay: Duration
}
11 changes: 11 additions & 0 deletions Sources/Retry/RecoveryAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ public enum RecoveryAction<ClockType: Clock> {
/// Retries the operation, unless the number of attempts reached ``RetryConfiguration/maxAttempts``.
case retry

/// Retries the operation only after the given instant in time has been reached.
///
/// For example, an HTTP server may send a `Retry-After` header in its response, which indicates
/// to the client that the request should not be retried until after a minimum amount of time has passed.
/// This recovery action can be used for such a use case.
///
/// It is not guaranteed that the operation will be retried. The backoff process continues until the given
/// instant in time has been reached, incrementing the number of attempts as usual. The operation will
/// be retried only if the number of attempts has not reached ``RetryConfiguration/maxAttempts``.
case retryAfter(ClockType.Instant)

/// Throws the error without retrying the operation.
case `throw`
}
4 changes: 4 additions & 0 deletions Sources/Retry/Retry.docc/Advanced Use Cases.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@
## Adding Safe Retry Methods to a Request Type

@Snippet(path: "swift-retry/Snippets/Advanced Use Cases/RetryableRequest")

## Enforcing a Minimum Delay Before Retrying

@Snippet(path: "swift-retry/Snippets/Advanced Use Cases/EnforceMinDelay")
48 changes: 47 additions & 1 deletion Sources/Retry/Retry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ public func retry<ClockType, ReturnType>(
/// - Note: The function will log messages using the `debug` log level to ``RetryConfiguration/logger``
/// (and/or ``RetryConfiguration/appleLogger`` on Apple platforms).
///
/// - SeeAlso: ``RetryableRequest``
/// - SeeAlso: ``RetryableRequest``
public func retry<ClockType, ReturnType>(
with configuration: RetryConfiguration<ClockType>,
@_inheritActorContext @_implicitSelfCapture operation: () async throws -> ReturnType
Expand Down Expand Up @@ -372,6 +372,52 @@ public func retry<ClockType, ReturnType>(
try await clock.sleep(for: delay)

attempt += 1

case .retryAfter(let nextRetryMinInstant):
let minDelay = clock.now.duration(to: nextRetryMinInstant)
// Unfortunately, the generic `ClockType.Duration` does not have a way to convert `minDelay`
// to a number, so we have to settle for the implementation-defined string representation.
logger?[metadataKey: "retry.after"] = "\(minDelay)"

var delay = ClockType.Duration.zero
var attemptsUsedToAchieveMinDelay = 0
repeat {
if let maxAttempts {
guard attempt + attemptsUsedToAchieveMinDelay + 1 < maxAttempts else {
logger?.debug("Attempt failed. No remaining attempts after backing off normally to achieve the minimum delay.")
#if canImport(OSLog)
appleLogger?.debug("""
Attempt \(attempt, privacy: .public) failed with error of type `\(type(of: latestError), privacy: .public)`: `\(latestError)`. \
The `recoverFromFailure` closure requested a minimum delay of \(String(describing: minDelay)) before retrying. \
No remaining attempts after backing off normally to achieve the minimum delay.
""")
#endif

throw latestError
}
}

delay += backoff.nextDelay() as! ClockType.Duration

attemptsUsedToAchieveMinDelay += 1
} while delay < clock.now.duration(to: nextRetryMinInstant)

logger?.debug("Attempt failed. Will wait before retrying.", metadata: [
// Unfortunately, the generic `ClockType.Duration` does not have a way to convert `delay`
// to a number, so we have to settle for the implementation-defined string representation.
"retry.delay": "\(delay)"
])
#if canImport(OSLog)
appleLogger?.debug("""
Attempt \(attempt, privacy: .public) failed with error of type `\(type(of: latestError), privacy: .public)`: `\(latestError)`. \
The `recoverFromFailure` closure requested a minimum delay of \(String(describing: minDelay)) before retrying. \
Will wait \(String(describing: delay), privacy: .public) before retrying.
""")
#endif

try await clock.sleep(for: delay)

attempt += attemptsUsedToAchieveMinDelay
}
}
}
59 changes: 59 additions & 0 deletions Tests/RetryTests/RetryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,65 @@ final class RetryTests: XCTestCase {
assertRetried(times: 0)
}

func testOneFailure_recoverFromFailureRequestsMinDelay_didNotReachMaxAttempts_successAfterRetry() async throws {
precondition(Self.maxAttempts > 1)

let clockFake = clockFake!
let configuration = testingConfiguration.withRecoverFromFailure { _ in
let minDelay = (
BackoffAlgorithmFake.delays(ofCount: Self.maxAttempts - 1, for: clockFake)
.reduce(.zero, +)
)

return .retryAfter(clockFake.now + minDelay)
}

var isFirstAttempt = true

try await retry(with: configuration) {
if isFirstAttempt {
isFirstAttempt = false

throw ErrorFake()
} else {
// Success.
}
}

assertRetried(times: 1)
}

func testFailure_recoverFromFailureRequestsMinDelay_reachedMaxAttempts_failureWithoutRetry() async throws {
precondition(Self.maxAttempts > 1)

let clockFake = clockFake!
let configuration = testingConfiguration.withRecoverFromFailure { _ in
let minDelay = (
BackoffAlgorithmFake.delays(ofCount: Self.maxAttempts - 1, for: clockFake)
.reduce(.zero, +)
+ clockFake.minimumResolution
)

return .retryAfter(clockFake.now + minDelay)
}

var isFirstAttempt = true

try await assertThrows(ErrorFake.self) {
try await retry(with: configuration) {
if isFirstAttempt {
isFirstAttempt = false

throw ErrorFake()
} else {
// Success.
}
}
}

assertRetried(times: 0)
}

func testFailure_isNotRetryableError_recoverFromFailureNotCalled_failureWithoutRetry() async throws {
precondition(Self.maxAttempts > 1)

Expand Down

0 comments on commit 7c571c6

Please sign in to comment.