diff --git a/Snippets/Advanced Use Cases/EnforceMinDelay.swift b/Snippets/Advanced Use Cases/EnforceMinDelay.swift new file mode 100644 index 0000000..902d426 --- /dev/null +++ b/Snippets/Advanced Use Cases/EnforceMinDelay.swift @@ -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 +} diff --git a/Sources/Retry/RecoveryAction.swift b/Sources/Retry/RecoveryAction.swift index 84314f9..a79d64d 100644 --- a/Sources/Retry/RecoveryAction.swift +++ b/Sources/Retry/RecoveryAction.swift @@ -25,6 +25,17 @@ public enum RecoveryAction { /// 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-Delay` 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` } diff --git a/Sources/Retry/Retry.docc/Advanced Use Cases.md b/Sources/Retry/Retry.docc/Advanced Use Cases.md index 42fcb55..e2bebc4 100644 --- a/Sources/Retry/Retry.docc/Advanced Use Cases.md +++ b/Sources/Retry/Retry.docc/Advanced Use Cases.md @@ -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") diff --git a/Sources/Retry/Retry.swift b/Sources/Retry/Retry.swift index 02d1c76..a2afd86 100644 --- a/Sources/Retry/Retry.swift +++ b/Sources/Retry/Retry.swift @@ -273,7 +273,7 @@ public func retry( /// - 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( with configuration: RetryConfiguration, @_inheritActorContext @_implicitSelfCapture operation: () async throws -> ReturnType @@ -372,6 +372,52 @@ public func retry( 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 } } } diff --git a/Tests/RetryTests/RetryTests.swift b/Tests/RetryTests/RetryTests.swift index 14de517..6baa233 100644 --- a/Tests/RetryTests/RetryTests.swift +++ b/Tests/RetryTests/RetryTests.swift @@ -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)