diff --git a/README.md b/README.md index 156b6d7..0306042 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ An important but often overlooked default is the choice of backoff algorithm, wh ### Powerful Flexibility The API provides several customization points to accommodate any use case: -- Retries can be selectively enabled or disabled for specific error cases by providing a custom `shouldRetry` closure. Retries can also be selectively enabled or disabled for specific code paths by wrapping thrown errors with `Retryable` or `NotRetryable`. +- Retries can be selectively enabled or disabled for specific error cases by providing a custom `recoverFromFailure` closure. Retries can also be selectively enabled or disabled for specific code paths by wrapping thrown errors with `Retryable` or `NotRetryable`. - The `RetryConfiguration` type encapsulates the retry behavior so that it can be reused across multiple call sites without duplicating code. - The `Backoff` type represents the choice of algorithm that will be used to determine how long to sleep in between attempts. It has built-in support for common algorithms but can be initialized with a custom `BackoffAlgorithm` implementation if needed. - The clock that is used to sleep in between attempts can be replaced. For example, one might use a fake `Clock` implementation in automated tests to ensure the tests are deterministic and efficient. diff --git a/Snippets/Advanced Use Cases/RetryableRequest.swift b/Snippets/Advanced Use Cases/RetryableRequest.swift index 817a584..3cfa31e 100644 --- a/Snippets/Advanced Use Cases/RetryableRequest.swift +++ b/Snippets/Advanced Use Cases/RetryableRequest.swift @@ -18,18 +18,18 @@ extension MyRequest: RetryableRequest { with configuration: RetryConfiguration, @_inheritActorContext @_implicitSelfCapture operation: (Self) async throws -> ReturnType ) async throws -> ReturnType { - // We can override the `shouldRetry` closure to automatically handle errors specific to - // the communication protocol. - let configuration = configuration.withShouldRetry { error in + // We can override the `recoverFromFailure` closure to automatically handle errors + // specific to the communication protocol. + let configuration = configuration.withRecoverFromFailure { error in switch error { case is MyTransientCommunicationError: - return true + return .retry case is MyNonTransientCommunicationError: - return false + return .throw default: - return configuration.shouldRetry(error) + return configuration.recoverFromFailure(error) } } diff --git a/Snippets/Common Use Cases/EnableOrDisableRetriesForSpecificErrorCases.swift b/Snippets/Common Use Cases/EnableOrDisableRetriesForSpecificErrorCases.swift index 402d8fa..651e24f 100644 --- a/Snippets/Common Use Cases/EnableOrDisableRetriesForSpecificErrorCases.swift +++ b/Snippets/Common Use Cases/EnableOrDisableRetriesForSpecificErrorCases.swift @@ -8,8 +8,8 @@ import Retry try await retry { try await doSomething() -} shouldRetry: { error in - return error.isRetryable +} recoverFromFailure: { error in + return error.isRetryable ? .retry : .throw } extension Error { diff --git a/Snippets/Common Use Cases/ReuseRetryConfiguration.swift b/Snippets/Common Use Cases/ReuseRetryConfiguration.swift index e5986ee..fce0097 100644 --- a/Snippets/Common Use Cases/ReuseRetryConfiguration.swift +++ b/Snippets/Common Use Cases/ReuseRetryConfiguration.swift @@ -8,7 +8,9 @@ import Retry // snippet.show extension RetryConfiguration { - static let standard = RetryConfiguration(shouldRetry: { $0.isRetryable }) + static let standard = RetryConfiguration( + recoverFromFailure: { $0.isRetryable ? .retry : .throw } + ) static let highTolerance = ( Self.standard diff --git a/Sources/Retry/RecoveryAction.swift b/Sources/Retry/RecoveryAction.swift new file mode 100644 index 0000000..84314f9 --- /dev/null +++ b/Sources/Retry/RecoveryAction.swift @@ -0,0 +1,30 @@ +// MIT License +// +// Copyright © 2024 Darren Mo. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +/// The action to take after an attempt fails. +public enum RecoveryAction { + /// Retries the operation, unless the number of attempts reached ``RetryConfiguration/maxAttempts``. + case retry + + /// Throws the error without retrying the operation. + case `throw` +} diff --git a/Sources/Retry/Retry.docc/Retry.md b/Sources/Retry/Retry.docc/Retry.md index 72c57b9..9a442b7 100644 --- a/Sources/Retry/Retry.docc/Retry.md +++ b/Sources/Retry/Retry.docc/Retry.md @@ -6,7 +6,7 @@ Retries with sensible defaults and powerful flexibility. ### Designed for Swift Concurrency -The ``retry(maxAttempts:backoff:appleLogger:logger:operation:shouldRetry:)`` function is an `async` function that runs the given `async` closure repeatedly until it succeeds or until the failure is no longer retryable. The function sleeps in between attempts while respecting task cancellation. +The ``retry(maxAttempts:backoff:appleLogger:logger:operation:recoverFromFailure:)`` function is an `async` function that runs the given `async` closure repeatedly until it succeeds or until the failure is no longer retryable. The function sleeps in between attempts while respecting task cancellation. ### Sensible Defaults @@ -17,7 +17,7 @@ An important but often overlooked default is the choice of backoff algorithm, wh ### Powerful Flexibility The API provides several customization points to accommodate any use case: -- Retries can be selectively enabled or disabled for specific error cases by providing a custom ``RetryConfiguration/shouldRetry`` closure. Retries can also be selectively enabled or disabled for specific code paths by wrapping thrown errors with ``Retryable`` or ``NotRetryable``. +- Retries can be selectively enabled or disabled for specific error cases by providing a custom ``RetryConfiguration/recoverFromFailure`` closure. Retries can also be selectively enabled or disabled for specific code paths by wrapping thrown errors with ``Retryable`` or ``NotRetryable``. - The ``RetryConfiguration`` type encapsulates the retry behavior so that it can be reused across multiple call sites without duplicating code. - The ``Backoff`` type represents the choice of algorithm that will be used to determine how long to sleep in between attempts. It has built-in support for common algorithms but can be initialized with a custom ``BackoffAlgorithm`` implementation if needed. - The ``RetryConfiguration/clock`` that is used to sleep in between attempts can be replaced. For example, one might use a fake `Clock` implementation in automated tests to ensure the tests are deterministic and efficient. @@ -31,14 +31,15 @@ The API provides several customization points to accommodate any use case: ### Retrying Operations -- ``retry(maxAttempts:backoff:appleLogger:logger:operation:shouldRetry:)`` -- ``retry(maxAttempts:clock:backoff:appleLogger:logger:operation:shouldRetry:)-2cjan`` -- ``retry(maxAttempts:clock:backoff:appleLogger:logger:operation:shouldRetry:)-2aiqm`` +- ``retry(maxAttempts:backoff:appleLogger:logger:operation:recoverFromFailure:)`` +- ``retry(maxAttempts:clock:backoff:appleLogger:logger:operation:recoverFromFailure:)-6s251`` +- ``retry(maxAttempts:clock:backoff:appleLogger:logger:operation:recoverFromFailure:)-2e9va`` - ``retry(with:operation:)`` ### Configuring the Retry Behavior - ``RetryConfiguration`` +- ``RecoveryAction`` - ``Backoff`` - ``BackoffAlgorithm`` diff --git a/Sources/Retry/Retry.swift b/Sources/Retry/Retry.swift index 108ab89..02d1c76 100644 --- a/Sources/Retry/Retry.swift +++ b/Sources/Retry/Retry.swift @@ -30,7 +30,7 @@ import OSLog /// Sleeps in between attempts using `ContinuousClock`. /// /// Failures may not be retryable for the following reasons: -/// - `shouldRetry` returns `false`. +/// - `recoverFromFailure` returns ``RecoveryAction/throw``. /// - The thrown error is ``NotRetryable``. /// - The number of attempts reached `maxAttempts`. /// @@ -42,8 +42,8 @@ import OSLog /// - logger: The logger that will be used to log a message when an attempt fails. The function will log /// messages using the `debug` log level. Consider using `appleLogger` when possible. /// - operation: The operation to attempt. -/// - shouldRetry: A closure that determines whether to retry, given the error that was thrown. The closure -/// will not be called if the error is ``Retryable`` or ``NotRetryable``. +/// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. +/// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. /// /// - SeeAlso: ``retry(with:operation:)`` /// - SeeAlso: ``RetryableRequest`` @@ -53,7 +53,7 @@ public func retry( appleLogger: os.Logger? = nil, logger: Logging.Logger? = nil, @_inheritActorContext @_implicitSelfCapture operation: () async throws -> ReturnType, - shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } ) async throws -> ReturnType { return try await retry(maxAttempts: maxAttempts, clock: ContinuousClock(), @@ -61,14 +61,14 @@ public func retry( appleLogger: appleLogger, logger: logger, operation: operation, - shouldRetry: shouldRetry) + recoverFromFailure: recoverFromFailure) } /// Attempts the given operation until it succeeds or until the failure is no longer retryable. /// Sleeps in between attempts using the given clock whose duration type is the standard `Duration` type. /// /// Failures may not be retryable for the following reasons: -/// - `shouldRetry` returns `false`. +/// - `recoverFromFailure` returns ``RecoveryAction/throw``. /// - The thrown error is ``NotRetryable``. /// - The number of attempts reached `maxAttempts`. /// @@ -81,8 +81,8 @@ public func retry( /// - logger: The logger that will be used to log a message when an attempt fails. The function will log /// messages using the `debug` log level. Consider using `appleLogger` when possible. /// - operation: The operation to attempt. -/// - shouldRetry: A closure that determines whether to retry, given the error that was thrown. The closure -/// will not be called if the error is ``Retryable`` or ``NotRetryable``. +/// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. +/// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. /// /// - SeeAlso: ``retry(with:operation:)`` /// - SeeAlso: ``RetryableRequest`` @@ -93,14 +93,14 @@ public func retry( appleLogger: os.Logger? = nil, logger: Logging.Logger? = nil, @_inheritActorContext @_implicitSelfCapture operation: () async throws -> ReturnType, - shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } ) async throws -> ReturnType where ClockType.Duration == Duration { let configuration = RetryConfiguration(maxAttempts: maxAttempts, clock: clock, backoff: backoff, appleLogger: appleLogger, logger: logger, - shouldRetry: shouldRetry) + recoverFromFailure: recoverFromFailure) return try await retry(with: configuration, operation: operation) @@ -110,7 +110,7 @@ public func retry( /// Sleeps in between attempts using the given clock. /// /// Failures may not be retryable for the following reasons: -/// - `shouldRetry` returns `false`. +/// - `recoverFromFailure` returns ``RecoveryAction/throw``. /// - The thrown error is ``NotRetryable``. /// - The number of attempts reached `maxAttempts`. /// @@ -123,8 +123,8 @@ public func retry( /// - logger: The logger that will be used to log a message when an attempt fails. The function will log /// messages using the `debug` log level. Consider using `appleLogger` when possible. /// - operation: The operation to attempt. -/// - shouldRetry: A closure that determines whether to retry, given the error that was thrown. The closure -/// will not be called if the error is ``Retryable`` or ``NotRetryable``. +/// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. +/// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. /// /// - SeeAlso: ``retry(with:operation:)`` /// - SeeAlso: ``RetryableRequest`` @@ -135,14 +135,14 @@ public func retry( appleLogger: os.Logger? = nil, logger: Logging.Logger? = nil, @_inheritActorContext @_implicitSelfCapture operation: () async throws -> ReturnType, - shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } ) async throws -> ReturnType { let configuration = RetryConfiguration(maxAttempts: maxAttempts, clock: clock, backoff: backoff, appleLogger: appleLogger, logger: logger, - shouldRetry: shouldRetry) + recoverFromFailure: recoverFromFailure) return try await retry(with: configuration, operation: operation) @@ -152,7 +152,7 @@ public func retry( /// Sleeps in between attempts using `ContinuousClock`. /// /// Failures may not be retryable for the following reasons: -/// - `shouldRetry` returns `false`. +/// - `recoverFromFailure` returns ``RecoveryAction/throw``. /// - The thrown error is ``NotRetryable``. /// - The number of attempts reached `maxAttempts`. /// @@ -162,8 +162,8 @@ public func retry( /// - logger: The logger that will be used to log a message when an attempt fails. The function will log /// messages using the `debug` log level. /// - operation: The operation to attempt. -/// - shouldRetry: A closure that determines whether to retry, given the error that was thrown. The closure -/// will not be called if the error is ``Retryable`` or ``NotRetryable``. +/// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. +/// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. /// /// - SeeAlso: ``retry(with:operation:)`` /// - SeeAlso: ``RetryableRequest`` @@ -172,21 +172,21 @@ public func retry( backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), logger: Logging.Logger? = nil, @_inheritActorContext @_implicitSelfCapture operation: () async throws -> ReturnType, - shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } ) async throws -> ReturnType { return try await retry(maxAttempts: maxAttempts, clock: ContinuousClock(), backoff: backoff, logger: logger, operation: operation, - shouldRetry: shouldRetry) + recoverFromFailure: recoverFromFailure) } /// Attempts the given operation until it succeeds or until the failure is no longer retryable. /// Sleeps in between attempts using the given clock whose duration type is the standard `Duration` type. /// /// Failures may not be retryable for the following reasons: -/// - `shouldRetry` returns `false`. +/// - `recoverFromFailure` returns ``RecoveryAction/throw``. /// - The thrown error is ``NotRetryable``. /// - The number of attempts reached `maxAttempts`. /// @@ -197,8 +197,8 @@ public func retry( /// - logger: The logger that will be used to log a message when an attempt fails. The function will log /// messages using the `debug` log level. /// - operation: The operation to attempt. -/// - shouldRetry: A closure that determines whether to retry, given the error that was thrown. The closure -/// will not be called if the error is ``Retryable`` or ``NotRetryable``. +/// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. +/// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. /// /// - SeeAlso: ``retry(with:operation:)`` /// - SeeAlso: ``RetryableRequest`` @@ -208,13 +208,13 @@ public func retry( backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), logger: Logging.Logger? = nil, @_inheritActorContext @_implicitSelfCapture operation: () async throws -> ReturnType, - shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } ) async throws -> ReturnType where ClockType.Duration == Duration { let configuration = RetryConfiguration(maxAttempts: maxAttempts, clock: clock, backoff: backoff, logger: logger, - shouldRetry: shouldRetry) + recoverFromFailure: recoverFromFailure) return try await retry(with: configuration, operation: operation) @@ -224,7 +224,7 @@ public func retry( /// Sleeps in between attempts using the given clock. /// /// Failures may not be retryable for the following reasons: -/// - `shouldRetry` returns `false`. +/// - `recoverFromFailure` returns ``RecoveryAction/throw``. /// - The thrown error is ``NotRetryable``. /// - The number of attempts reached `maxAttempts`. /// @@ -235,8 +235,8 @@ public func retry( /// - logger: The logger that will be used to log a message when an attempt fails. The function will log /// messages using the `debug` log level. /// - operation: The operation to attempt. -/// - shouldRetry: A closure that determines whether to retry, given the error that was thrown. The closure -/// will not be called if the error is ``Retryable`` or ``NotRetryable``. +/// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. +/// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. /// /// - SeeAlso: ``retry(with:operation:)`` /// - SeeAlso: ``RetryableRequest`` @@ -246,13 +246,13 @@ public func retry( backoff: Backoff, logger: Logging.Logger? = nil, @_inheritActorContext @_implicitSelfCapture operation: () async throws -> ReturnType, - shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } ) async throws -> ReturnType { let configuration = RetryConfiguration(maxAttempts: maxAttempts, clock: clock, backoff: backoff, logger: logger, - shouldRetry: shouldRetry) + recoverFromFailure: recoverFromFailure) return try await retry(with: configuration, operation: operation) @@ -262,7 +262,7 @@ public func retry( /// Attempts the given operation until it succeeds or until the failure is no longer retryable. /// /// Failures may not be retryable for the following reasons: -/// - ``RetryConfiguration/shouldRetry`` returns `false`. +/// - ``RetryConfiguration/recoverFromFailure`` returns ``RecoveryAction/throw``. /// - The thrown error is ``NotRetryable``. /// - The number of attempts reached ``RetryConfiguration/maxAttempts``. /// @@ -288,12 +288,12 @@ public func retry( let appleLogger = configuration.appleLogger #endif - let shouldRetry = configuration.shouldRetry + let recoverFromFailure = configuration.recoverFromFailure var attempt = 0 while true { var latestError: any Error - var isErrorRetryable: Bool + var recoveryAction: RecoveryAction do { return try await operation() @@ -301,26 +301,26 @@ public func retry( switch error { case let error as Retryable: latestError = error - isErrorRetryable = true + recoveryAction = .retry case let error as NotRetryable: latestError = error - isErrorRetryable = false + recoveryAction = .throw case let error as CancellationError: latestError = error - isErrorRetryable = false + recoveryAction = .throw default: latestError = error - isErrorRetryable = shouldRetry(error) + recoveryAction = recoverFromFailure(error) } latestError = latestError.originalError // Need to check again because the error could have been wrapped. if latestError is CancellationError { - isErrorRetryable = false + recoveryAction = .throw } } @@ -330,7 +330,8 @@ public func retry( // public and private data. logger?[metadataKey: "retry.error.type"] = "\(type(of: latestError))" - guard isErrorRetryable else { + switch recoveryAction { + case .throw: logger?.debug("Attempt failed. Error is not retryable.") #if canImport(OSLog) appleLogger?.debug(""" @@ -340,36 +341,37 @@ public func retry( #endif throw latestError - } - if let maxAttempts, attempt + 1 >= maxAttempts { - logger?.debug("Attempt failed. No remaining attempts.") + case .retry: + if let maxAttempts, attempt + 1 >= maxAttempts { + logger?.debug("Attempt failed. No remaining attempts.") #if canImport(OSLog) - appleLogger?.debug(""" + appleLogger?.debug(""" Attempt \(attempt, privacy: .public) failed with error of type `\(type(of: latestError), privacy: .public)`: `\(latestError)`. \ No remaining attempts. """) #endif - throw latestError - } + throw latestError + } - let delay = backoff.nextDelay() as! ClockType.Duration + let delay = backoff.nextDelay() as! ClockType.Duration - 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)" - ]) + 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(""" + appleLogger?.debug(""" Attempt \(attempt, privacy: .public) failed with error of type `\(type(of: latestError), privacy: .public)`: `\(latestError)`. \ Will wait \(String(describing: delay), privacy: .public) before retrying. """) #endif - try await clock.sleep(for: delay) + try await clock.sleep(for: delay) - attempt += 1 + attempt += 1 + } } } diff --git a/Sources/Retry/RetryConfiguration.swift b/Sources/Retry/RetryConfiguration.swift index 4fff8d4..8a4fb34 100644 --- a/Sources/Retry/RetryConfiguration.swift +++ b/Sources/Retry/RetryConfiguration.swift @@ -49,10 +49,10 @@ public struct RetryConfiguration { /// detailed log messages and better integration with the logging system. public var logger: Logging.Logger? - /// A closure that determines whether to retry, given the error that was thrown. + /// A closure that determines what action to take, given the error that was thrown. /// /// - Note: The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. - public var shouldRetry: @Sendable (any Error) -> Bool + public var recoverFromFailure: @Sendable (any Error) -> RecoveryAction #if canImport(OSLog) /// Configures the retry behavior when the clock type is `ContinuousClock`. @@ -64,21 +64,21 @@ public struct RetryConfiguration { /// log messages using the `debug` log level. /// - logger: The logger that will be used to log a message when an attempt fails. The function will log /// messages using the `debug` log level. Consider using `appleLogger` when possible. - /// - shouldRetry: A closure that determines whether to retry, given the error that was thrown. The closure - /// will not be called if the error is ``Retryable`` or ``NotRetryable``. + /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. + /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. public init( maxAttempts: Int? = 3, backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), appleLogger: os.Logger? = nil, logger: Logging.Logger? = nil, - shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } ) where ClockType == ContinuousClock { self.init(maxAttempts: maxAttempts, clock: ContinuousClock(), backoff: backoff, appleLogger: appleLogger, logger: logger, - shouldRetry: shouldRetry) + recoverFromFailure: recoverFromFailure) } /// Configures the retry behavior when the clock’s duration type is the standard `Duration` type. @@ -91,15 +91,15 @@ public struct RetryConfiguration { /// log messages using the `debug` log level. /// - logger: The logger that will be used to log a message when an attempt fails. The function will log /// messages using the `debug` log level. Consider using `appleLogger` when possible. - /// - shouldRetry: A closure that determines whether to retry, given the error that was thrown. The closure - /// will not be called if the error is ``Retryable`` or ``NotRetryable``. + /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. + /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. public init( maxAttempts: Int? = 3, clock: ClockType, backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), appleLogger: os.Logger? = nil, logger: Logging.Logger? = nil, - shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } ) where ClockType.Duration == Duration { if let maxAttempts { precondition(maxAttempts > 0) @@ -113,7 +113,7 @@ public struct RetryConfiguration { self.appleLogger = appleLogger self.logger = logger - self.shouldRetry = shouldRetry + self.recoverFromFailure = recoverFromFailure } /// Configures the retry behavior. @@ -126,15 +126,15 @@ public struct RetryConfiguration { /// log messages using the `debug` log level. /// - logger: The logger that will be used to log a message when an attempt fails. The function will log /// messages using the `debug` log level. Consider using `appleLogger` when possible. - /// - shouldRetry: A closure that determines whether to retry, given the error that was thrown. The closure - /// will not be called if the error is ``Retryable`` or ``NotRetryable``. + /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. + /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. public init( maxAttempts: Int? = 3, clock: ClockType, backoff: Backoff, appleLogger: os.Logger? = nil, logger: Logging.Logger? = nil, - shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } ) { if let maxAttempts { precondition(maxAttempts > 0) @@ -148,7 +148,7 @@ public struct RetryConfiguration { self.appleLogger = appleLogger self.logger = logger - self.shouldRetry = shouldRetry + self.recoverFromFailure = recoverFromFailure } #else /// Configures the retry behavior when the clock type is `ContinuousClock`. @@ -158,19 +158,19 @@ public struct RetryConfiguration { /// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. /// - logger: The logger that will be used to log a message when an attempt fails. The function will log /// messages using the `debug` log level. - /// - shouldRetry: A closure that determines whether to retry, given the error that was thrown. The closure - /// will not be called if the error is ``Retryable`` or ``NotRetryable``. + /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. + /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. public init( maxAttempts: Int? = 3, backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), logger: Logging.Logger? = nil, - shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } ) where ClockType == ContinuousClock { self.init(maxAttempts: maxAttempts, clock: ContinuousClock(), backoff: backoff, logger: logger, - shouldRetry: shouldRetry) + recoverFromFailure: recoverFromFailure) } /// Configures the retry behavior when the clock’s duration type is the standard `Duration` type. @@ -181,14 +181,14 @@ public struct RetryConfiguration { /// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. /// - logger: The logger that will be used to log a message when an attempt fails. The function will log /// messages using the `debug` log level. - /// - shouldRetry: A closure that determines whether to retry, given the error that was thrown. The closure - /// will not be called if the error is ``Retryable`` or ``NotRetryable``. + /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. + /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. public init( maxAttempts: Int? = 3, clock: ClockType, backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), logger: Logging.Logger? = nil, - shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } ) where ClockType.Duration == Duration { if let maxAttempts { precondition(maxAttempts > 0) @@ -201,7 +201,7 @@ public struct RetryConfiguration { self.logger = logger - self.shouldRetry = shouldRetry + self.recoverFromFailure = recoverFromFailure } /// Configures the retry behavior. @@ -212,14 +212,14 @@ public struct RetryConfiguration { /// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. /// - logger: The logger that will be used to log a message when an attempt fails. The function will log /// messages using the `debug` log level. - /// - shouldRetry: A closure that determines whether to retry, given the error that was thrown. The closure - /// will not be called if the error is ``Retryable`` or ``NotRetryable``. + /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. + /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. public init( maxAttempts: Int? = 3, clock: ClockType, backoff: Backoff, logger: Logging.Logger? = nil, - shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } ) { if let maxAttempts { precondition(maxAttempts > 0) @@ -232,7 +232,7 @@ public struct RetryConfiguration { self.logger = logger - self.shouldRetry = shouldRetry + self.recoverFromFailure = recoverFromFailure } #endif @@ -268,9 +268,9 @@ public struct RetryConfiguration { return newConfiguration } - public func withShouldRetry(_ newValue: @escaping @Sendable (any Error) -> Bool) -> Self { + public func withRecoverFromFailure(_ newValue: @escaping @Sendable (any Error) -> RecoveryAction) -> Self { var newConfiguration = self - newConfiguration.shouldRetry = newValue + newConfiguration.recoverFromFailure = newValue return newConfiguration } } diff --git a/Sources/Retry/Retryable/NotRetryable.swift b/Sources/Retry/Retryable/NotRetryable.swift index 87dd0e0..ad1f654 100644 --- a/Sources/Retry/Retryable/NotRetryable.swift +++ b/Sources/Retry/Retryable/NotRetryable.swift @@ -24,8 +24,8 @@ /// /// Throwing this error will prevent a retry. /// -/// This wrapper type exists for the cases where ``RetryConfiguration/shouldRetry`` cannot make -/// a good decision (e.g. the underlying error type is not exposed by a library dependency). +/// This wrapper type exists for the cases where ``RetryConfiguration/recoverFromFailure`` +/// cannot make a good decision (e.g. the underlying error type is not exposed by a library dependency). public struct NotRetryable: Error { let underlyingError: any Error diff --git a/Sources/Retry/Retryable/Retryable.swift b/Sources/Retry/Retryable/Retryable.swift index d270a1f..447f3bc 100644 --- a/Sources/Retry/Retryable/Retryable.swift +++ b/Sources/Retry/Retryable/Retryable.swift @@ -25,8 +25,8 @@ /// Throwing this error will always result in a retry, unless there are other conditions that make the failure /// not retryable like reaching the maximum number of attempts. /// -/// This wrapper type exists for the cases where ``RetryConfiguration/shouldRetry`` cannot make -/// a good decision (e.g. the underlying error type is not exposed by a library dependency). +/// This wrapper type exists for the cases where ``RetryConfiguration/recoverFromFailure`` +/// cannot make a good decision (e.g. the underlying error type is not exposed by a library dependency). public struct Retryable: Error { let underlyingError: any Error diff --git a/Sources/Retry/RetryableRequest/RetryableRequest+SafeRetry.swift b/Sources/Retry/RetryableRequest/RetryableRequest+SafeRetry.swift index e3999c8..adbd4ef 100644 --- a/Sources/Retry/RetryableRequest/RetryableRequest+SafeRetry.swift +++ b/Sources/Retry/RetryableRequest/RetryableRequest+SafeRetry.swift @@ -32,7 +32,7 @@ extension RetryableRequest { /// /// Failures may not be retryable for the following reasons: /// - The response indicates that the failure is not transient. - /// - `shouldRetry` returns `false`. + /// - `recoverFromFailure` returns ``RecoveryAction/throw``. /// - The thrown error is ``NotRetryable``. /// - The number of attempts reached `maxAttempts`. /// @@ -46,8 +46,8 @@ extension RetryableRequest { /// - logger: The logger that will be used to log a message when an attempt fails. The function will log /// messages using the `debug` log level. Consider using `appleLogger` when possible. /// - operation: Attempts the given request. - /// - shouldRetry: A closure that determines whether to retry, given the error that was thrown. The closure - /// will not be called if the error is ``Retryable`` or ``NotRetryable``. + /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. + /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. /// /// - SeeAlso: ``retry(with:operation:)`` public func retry( @@ -56,7 +56,7 @@ extension RetryableRequest { appleLogger: os.Logger? = nil, logger: Logging.Logger? = nil, @_inheritActorContext @_implicitSelfCapture operation: (Self) async throws -> ReturnType, - shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } ) async throws -> ReturnType { return try await retry(maxAttempts: maxAttempts, clock: ContinuousClock(), @@ -64,7 +64,7 @@ extension RetryableRequest { appleLogger: appleLogger, logger: logger, operation: operation, - shouldRetry: shouldRetry) + recoverFromFailure: recoverFromFailure) } /// Attempts the given operation until it succeeds or until the failure is no longer retryable. @@ -72,7 +72,7 @@ extension RetryableRequest { /// /// Failures may not be retryable for the following reasons: /// - The response indicates that the failure is not transient. - /// - `shouldRetry` returns `false`. + /// - `recoverFromFailure` returns ``RecoveryAction/throw``. /// - The thrown error is ``NotRetryable``. /// - The number of attempts reached `maxAttempts`. /// @@ -87,8 +87,8 @@ extension RetryableRequest { /// - logger: The logger that will be used to log a message when an attempt fails. The function will log /// messages using the `debug` log level. Consider using `appleLogger` when possible. /// - operation: Attempts the given request. - /// - shouldRetry: A closure that determines whether to retry, given the error that was thrown. The closure - /// will not be called if the error is ``Retryable`` or ``NotRetryable``. + /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. + /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. /// /// - SeeAlso: ``retry(with:operation:)`` public func retry( @@ -98,14 +98,14 @@ extension RetryableRequest { appleLogger: os.Logger? = nil, logger: Logging.Logger? = nil, @_inheritActorContext @_implicitSelfCapture operation: (Self) async throws -> ReturnType, - shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } ) async throws -> ReturnType where ClockType.Duration == Duration { let configuration = RetryConfiguration(maxAttempts: maxAttempts, clock: clock, backoff: backoff, appleLogger: appleLogger, logger: logger, - shouldRetry: shouldRetry) + recoverFromFailure: recoverFromFailure) return try await retry(with: configuration, operation: operation) @@ -116,7 +116,7 @@ extension RetryableRequest { /// /// Failures may not be retryable for the following reasons: /// - The response indicates that the failure is not transient. - /// - `shouldRetry` returns `false`. + /// - `recoverFromFailure` returns ``RecoveryAction/throw``. /// - The thrown error is ``NotRetryable``. /// - The number of attempts reached `maxAttempts`. /// @@ -131,8 +131,8 @@ extension RetryableRequest { /// - logger: The logger that will be used to log a message when an attempt fails. The function will log /// messages using the `debug` log level. Consider using `appleLogger` when possible. /// - operation: Attempts the given request. - /// - shouldRetry: A closure that determines whether to retry, given the error that was thrown. The closure - /// will not be called if the error is ``Retryable`` or ``NotRetryable``. + /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. + /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. /// /// - SeeAlso: ``retry(with:operation:)`` public func retry( @@ -142,14 +142,14 @@ extension RetryableRequest { appleLogger: os.Logger? = nil, logger: Logging.Logger? = nil, @_inheritActorContext @_implicitSelfCapture operation: (Self) async throws -> ReturnType, - shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } ) async throws -> ReturnType { let configuration = RetryConfiguration(maxAttempts: maxAttempts, clock: clock, backoff: backoff, appleLogger: appleLogger, logger: logger, - shouldRetry: shouldRetry) + recoverFromFailure: recoverFromFailure) return try await retry(with: configuration, operation: operation) @@ -160,7 +160,7 @@ extension RetryableRequest { /// /// Failures may not be retryable for the following reasons: /// - The response indicates that the failure is not transient. - /// - `shouldRetry` returns `false`. + /// - `recoverFromFailure` returns ``RecoveryAction/throw``. /// - The thrown error is ``NotRetryable``. /// - The number of attempts reached `maxAttempts`. /// @@ -172,8 +172,8 @@ extension RetryableRequest { /// - logger: The logger that will be used to log a message when an attempt fails. The function will log /// messages using the `debug` log level. /// - operation: Attempts the given request. - /// - shouldRetry: A closure that determines whether to retry, given the error that was thrown. The closure - /// will not be called if the error is ``Retryable`` or ``NotRetryable``. + /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. + /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. /// /// - SeeAlso: ``retry(with:operation:)`` public func retry( @@ -181,14 +181,14 @@ extension RetryableRequest { backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), logger: Logging.Logger? = nil, @_inheritActorContext @_implicitSelfCapture operation: (Self) async throws -> ReturnType, - shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } ) async throws -> ReturnType { return try await retry(maxAttempts: maxAttempts, clock: ContinuousClock(), backoff: backoff, logger: logger, operation: operation, - shouldRetry: shouldRetry) + recoverFromFailure: recoverFromFailure) } /// Attempts the given operation until it succeeds or until the failure is no longer retryable. @@ -196,7 +196,7 @@ extension RetryableRequest { /// /// Failures may not be retryable for the following reasons: /// - The response indicates that the failure is not transient. - /// - `shouldRetry` returns `false`. + /// - `recoverFromFailure` returns ``RecoveryAction/throw``. /// - The thrown error is ``NotRetryable``. /// - The number of attempts reached `maxAttempts`. /// @@ -209,8 +209,8 @@ extension RetryableRequest { /// - logger: The logger that will be used to log a message when an attempt fails. The function will log /// messages using the `debug` log level. /// - operation: Attempts the given request. - /// - shouldRetry: A closure that determines whether to retry, given the error that was thrown. The closure - /// will not be called if the error is ``Retryable`` or ``NotRetryable``. + /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. + /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. /// /// - SeeAlso: ``retry(with:operation:)`` public func retry( @@ -219,13 +219,13 @@ extension RetryableRequest { backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), logger: Logging.Logger? = nil, @_inheritActorContext @_implicitSelfCapture operation: (Self) async throws -> ReturnType, - shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } ) async throws -> ReturnType where ClockType.Duration == Duration { let configuration = RetryConfiguration(maxAttempts: maxAttempts, clock: clock, backoff: backoff, logger: logger, - shouldRetry: shouldRetry) + recoverFromFailure: recoverFromFailure) return try await retry(with: configuration, operation: operation) @@ -236,7 +236,7 @@ extension RetryableRequest { /// /// Failures may not be retryable for the following reasons: /// - The response indicates that the failure is not transient. - /// - `shouldRetry` returns `false`. + /// - `recoverFromFailure` returns ``RecoveryAction/throw``. /// - The thrown error is ``NotRetryable``. /// - The number of attempts reached `maxAttempts`. /// @@ -249,8 +249,8 @@ extension RetryableRequest { /// - logger: The logger that will be used to log a message when an attempt fails. The function will log /// messages using the `debug` log level. /// - operation: Attempts the given request. - /// - shouldRetry: A closure that determines whether to retry, given the error that was thrown. The closure - /// will not be called if the error is ``Retryable`` or ``NotRetryable``. + /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. + /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. /// /// - SeeAlso: ``retry(with:operation:)`` public func retry( @@ -259,13 +259,13 @@ extension RetryableRequest { backoff: Backoff, logger: Logging.Logger? = nil, @_inheritActorContext @_implicitSelfCapture operation: (Self) async throws -> ReturnType, - shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } ) async throws -> ReturnType { let configuration = RetryConfiguration(maxAttempts: maxAttempts, clock: clock, backoff: backoff, logger: logger, - shouldRetry: shouldRetry) + recoverFromFailure: recoverFromFailure) return try await retry(with: configuration, operation: operation) @@ -276,7 +276,7 @@ extension RetryableRequest { /// /// Failures may not be retryable for the following reasons: /// - The response indicates that the failure is not transient. - /// - ``RetryConfiguration/shouldRetry`` returns `false`. + /// - ``RetryConfiguration/recoverFromFailure`` returns ``RecoveryAction/throw``. /// - The thrown error is ``NotRetryable``. /// - The number of attempts reached ``RetryConfiguration/maxAttempts``. /// diff --git a/Sources/Retry/RetryableRequest/RetryableRequest.swift b/Sources/Retry/RetryableRequest/RetryableRequest.swift index 32f4658..031f35e 100644 --- a/Sources/Retry/RetryableRequest/RetryableRequest.swift +++ b/Sources/Retry/RetryableRequest/RetryableRequest.swift @@ -32,8 +32,8 @@ /// /// Conforming request types also need to implement /// ``unsafeRetryIgnoringIdempotency(with:operation:)``. Implementations may choose -/// to override ``RetryConfiguration/shouldRetry`` to automatically handle errors specific to -/// the communication protocol. +/// to override ``RetryConfiguration/recoverFromFailure`` to automatically handle errors +/// specific to the communication protocol. public protocol RetryableRequest { /// Determines whether the request is idempotent. /// @@ -48,7 +48,7 @@ public protocol RetryableRequest { /// /// Failures may not be retryable for the following reasons: /// - The response indicates that the failure is not transient. - /// - ``RetryConfiguration/shouldRetry`` returns `false`. + /// - ``RetryConfiguration/recoverFromFailure`` returns ``RecoveryAction/throw``. /// - The thrown error is ``NotRetryable``. /// - The number of attempts reached ``RetryConfiguration/maxAttempts``. /// diff --git a/Tests/RetryTests/RetryTests.swift b/Tests/RetryTests/RetryTests.swift index dcb827c..14de517 100644 --- a/Tests/RetryTests/RetryTests.swift +++ b/Tests/RetryTests/RetryTests.swift @@ -38,7 +38,7 @@ final class RetryTests: XCTestCase { maxAttempts: Self.maxAttempts, clock: clockFake, backoff: Backoff { BackoffAlgorithmFake(clock: $0) }, - shouldRetry: { _ in true } + recoverFromFailure: { _ in .retry } ) } @@ -87,11 +87,11 @@ final class RetryTests: XCTestCase { assertRetried(times: Self.maxAttempts - 1) } - func testFailure_shouldRetryReturnsFalse_failureWithoutRetry() async throws { + func testFailure_recoverFromFailureDecidesToThrow_failureWithoutRetry() async throws { precondition(Self.maxAttempts > 1) try await assertThrows(ErrorFake.self) { - try await retry(with: testingConfiguration.withShouldRetry({ _ in false })) { + try await retry(with: testingConfiguration.withRecoverFromFailure({ _ in .throw })) { throw ErrorFake() } } @@ -99,12 +99,12 @@ final class RetryTests: XCTestCase { assertRetried(times: 0) } - func testFailure_isNotRetryableError_shouldRetryNotCalled_failureWithoutRetry() async throws { + func testFailure_isNotRetryableError_recoverFromFailureNotCalled_failureWithoutRetry() async throws { precondition(Self.maxAttempts > 1) - let configuration = testingConfiguration.withShouldRetry { error in - XCTFail("`shouldRetry` should not be called when the error is `NotRetryable`.") - return true + let configuration = testingConfiguration.withRecoverFromFailure { error in + XCTFail("`recoverFromFailure` should not be called when the error is `NotRetryable`.") + return .retry } try await assertThrows(ErrorFake.self) { @@ -116,12 +116,12 @@ final class RetryTests: XCTestCase { assertRetried(times: 0) } - func testOneFailure_isRetryableError_shouldRetryNotCalled_successAfterRetry() async throws { + func testOneFailure_isRetryableError_recoverFromFailureNotCalled_successAfterRetry() async throws { precondition(Self.maxAttempts > 1) - let configuration = testingConfiguration.withShouldRetry { error in - XCTFail("`shouldRetry` should not be called when the error is `Retryable`.") - return false + let configuration = testingConfiguration.withRecoverFromFailure { error in + XCTFail("`recoverFromFailure` should not be called when the error is `Retryable`.") + return .throw } var isFirstAttempt = true @@ -159,12 +159,12 @@ final class RetryTests: XCTestCase { assertRetried(times: 0) } - func testFailure_isCancellationError_shouldRetryNotCalled_failureWithoutRetry() async throws { + func testFailure_isCancellationError_recoverFromFailureNotCalled_failureWithoutRetry() async throws { precondition(Self.maxAttempts > 1) - let configuration = testingConfiguration.withShouldRetry { error in - XCTFail("`shouldRetry` should not be called when the error is `CancellationError`.") - return true + let configuration = testingConfiguration.withRecoverFromFailure { error in + XCTFail("`recoverFromFailure` should not be called when the error is `CancellationError`.") + return .retry } try await assertThrows(CancellationError.self) {