From 57aa7cf7c9b99a21dd260a48481f3b670f7538b0 Mon Sep 17 00:00:00 2001
From: fumoboy007 <2100868+fumoboy007@users.noreply.github.com>
Date: Sat, 6 Jan 2024 16:34:17 -0800
Subject: [PATCH] Add `RecoveryAction.retryAfter` to allow specifying a minimum
 retry delay.

---
 .../Advanced Use Cases/EnforceMinDelay.swift  | 28 +++++++++
 Sources/Retry/RecoveryAction.swift            | 11 ++++
 .../Retry/Retry.docc/Advanced Use Cases.md    |  4 ++
 Sources/Retry/Retry.swift                     | 48 ++++++++++++++-
 Tests/RetryTests/RetryTests.swift             | 59 +++++++++++++++++++
 5 files changed, 149 insertions(+), 1 deletion(-)
 create mode 100644 Snippets/Advanced Use Cases/EnforceMinDelay.swift

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<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-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<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
@@ -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
       }
    }
 }
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)