From bd3f43ed9deaa0d296928e8bf0f02dcbd935fe14 Mon Sep 17 00:00:00 2001 From: fumoboy007 <2100868+fumoboy007@users.noreply.github.com> Date: Tue, 2 Jan 2024 18:22:55 -0800 Subject: [PATCH] Add a `RetryableRequest` protocol that adds safe retry methods to a conforming request type. --- .../Advanced Use Cases/RetryableRequest.swift | 64 ++++ .../Retry/Retry.docc/Advanced Use Cases.md | 4 + Sources/Retry/Retry.docc/Retry.md | 4 + Sources/Retry/Retry.swift | 8 + .../RetryableRequest+SafeRetry.swift | 300 ++++++++++++++++++ .../RetryableRequest/RetryableRequest.swift | 65 ++++ 6 files changed, 445 insertions(+) create mode 100644 Snippets/Advanced Use Cases/RetryableRequest.swift create mode 100644 Sources/Retry/RetryableRequest/RetryableRequest+SafeRetry.swift create mode 100644 Sources/Retry/RetryableRequest/RetryableRequest.swift diff --git a/Snippets/Advanced Use Cases/RetryableRequest.swift b/Snippets/Advanced Use Cases/RetryableRequest.swift new file mode 100644 index 0000000..817a584 --- /dev/null +++ b/Snippets/Advanced Use Cases/RetryableRequest.swift @@ -0,0 +1,64 @@ +// Conform a request type to ``RetryableRequest`` to add safe retry methods to the request type. + +// snippet.hide + +import Retry + +// snippet.show + +extension MyRequest: RetryableRequest { + var isIdempotent: Bool { + // ... + // snippet.hide + return true + // snippet.show + } + + func unsafeRetryIgnoringIdempotency( + 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 + switch error { + case is MyTransientCommunicationError: + return true + + case is MyNonTransientCommunicationError: + return false + + default: + return configuration.shouldRetry(error) + } + } + + return try await Retry.retry(with: configuration) { + return try await operation(self) + } + } +} + +// snippet.hide + +let myRequest = MyRequest() + +// snippet.show + +try await myRequest.retry { request in + try await perform(request) +} + +// snippet.hide + +struct MyRequest { +} + +enum MyTransientCommunicationError: Error { +} + +enum MyNonTransientCommunicationError: Error { +} + +func perform(_ request: MyRequest) async throws { +} diff --git a/Sources/Retry/Retry.docc/Advanced Use Cases.md b/Sources/Retry/Retry.docc/Advanced Use Cases.md index b8a53ca..42fcb55 100644 --- a/Sources/Retry/Retry.docc/Advanced Use Cases.md +++ b/Sources/Retry/Retry.docc/Advanced Use Cases.md @@ -7,3 +7,7 @@ ## Implementing a Custom Backoff Algorithm @Snippet(path: "swift-retry/Snippets/Advanced Use Cases/CustomBackoffAlgorithm") + +## Adding Safe Retry Methods to a Request Type + +@Snippet(path: "swift-retry/Snippets/Advanced Use Cases/RetryableRequest") diff --git a/Sources/Retry/Retry.docc/Retry.md b/Sources/Retry/Retry.docc/Retry.md index 5e867cc..72c57b9 100644 --- a/Sources/Retry/Retry.docc/Retry.md +++ b/Sources/Retry/Retry.docc/Retry.md @@ -46,3 +46,7 @@ The API provides several customization points to accommodate any use case: - ``Retryable`` - ``NotRetryable`` + +### Safely Retrying Requests + +- ``RetryableRequest`` diff --git a/Sources/Retry/Retry.swift b/Sources/Retry/Retry.swift index 88b4dc3..db96bd9 100644 --- a/Sources/Retry/Retry.swift +++ b/Sources/Retry/Retry.swift @@ -46,6 +46,7 @@ import OSLog /// will not be called if the error is ``Retryable`` or ``NotRetryable``. /// /// - SeeAlso: ``retry(with:operation:)`` +/// - SeeAlso: ``RetryableRequest`` public func retry( maxAttempts: Int? = 3, backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), @@ -84,6 +85,7 @@ public func retry( /// will not be called if the error is ``Retryable`` or ``NotRetryable``. /// /// - SeeAlso: ``retry(with:operation:)`` +/// - SeeAlso: ``RetryableRequest`` public func retry( maxAttempts: Int? = 3, clock: ClockType, @@ -125,6 +127,7 @@ public func retry( /// will not be called if the error is ``Retryable`` or ``NotRetryable``. /// /// - SeeAlso: ``retry(with:operation:)`` +/// - SeeAlso: ``RetryableRequest`` public func retry( maxAttempts: Int? = 3, clock: ClockType, @@ -163,6 +166,7 @@ public func retry( /// will not be called if the error is ``Retryable`` or ``NotRetryable``. /// /// - SeeAlso: ``retry(with:operation:)`` +/// - SeeAlso: ``RetryableRequest`` public func retry( maxAttempts: Int? = 3, backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), @@ -197,6 +201,7 @@ public func retry( /// will not be called if the error is ``Retryable`` or ``NotRetryable``. /// /// - SeeAlso: ``retry(with:operation:)`` +/// - SeeAlso: ``RetryableRequest`` public func retry( maxAttempts: Int? = 3, clock: ClockType, @@ -234,6 +239,7 @@ public func retry( /// will not be called if the error is ``Retryable`` or ``NotRetryable``. /// /// - SeeAlso: ``retry(with:operation:)`` +/// - SeeAlso: ``RetryableRequest`` public func retry( maxAttempts: Int? = 3, clock: ClockType, @@ -266,6 +272,8 @@ 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`` public func retry( with configuration: RetryConfiguration, @_inheritActorContext @_implicitSelfCapture operation: () async throws -> ReturnType diff --git a/Sources/Retry/RetryableRequest/RetryableRequest+SafeRetry.swift b/Sources/Retry/RetryableRequest/RetryableRequest+SafeRetry.swift new file mode 100644 index 0000000..e3999c8 --- /dev/null +++ b/Sources/Retry/RetryableRequest/RetryableRequest+SafeRetry.swift @@ -0,0 +1,300 @@ +// MIT License +// +// Copyright © 2023 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. + +import Logging +#if canImport(OSLog) +import OSLog +#endif + +extension RetryableRequest { +#if canImport(OSLog) + /// Attempts the given operation until it succeeds or until the failure is no longer retryable. + /// Sleeps in between attempts using `ContinuousClock`. + /// + /// Failures may not be retryable for the following reasons: + /// - The response indicates that the failure is not transient. + /// - `shouldRetry` returns `false`. + /// - The thrown error is ``NotRetryable``. + /// - The number of attempts reached `maxAttempts`. + /// + /// - Precondition: ``isIdempotent`` must return `true`. + /// + /// - Parameters: + /// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. + /// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. + /// - appleLogger: The logger that will be used to log a message when an attempt fails. The function will + /// 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. + /// - 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``. + /// + /// - SeeAlso: ``retry(with:operation:)`` + public func retry( + maxAttempts: Int? = 3, + backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), + appleLogger: os.Logger? = nil, + logger: Logging.Logger? = nil, + @_inheritActorContext @_implicitSelfCapture operation: (Self) async throws -> ReturnType, + shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + ) async throws -> ReturnType { + return try await retry(maxAttempts: maxAttempts, + clock: ContinuousClock(), + backoff: backoff, + appleLogger: appleLogger, + logger: logger, + operation: operation, + shouldRetry: shouldRetry) + } + + /// 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: + /// - The response indicates that the failure is not transient. + /// - `shouldRetry` returns `false`. + /// - The thrown error is ``NotRetryable``. + /// - The number of attempts reached `maxAttempts`. + /// + /// - Precondition: ``isIdempotent`` must return `true`. + /// + /// - Parameters: + /// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. + /// - clock: The clock that will be used to sleep in between attempts. + /// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. + /// - appleLogger: The logger that will be used to log a message when an attempt fails. The function will + /// 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. + /// - 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``. + /// + /// - SeeAlso: ``retry(with:operation:)`` + public func retry( + maxAttempts: Int? = 3, + clock: ClockType, + backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), + appleLogger: os.Logger? = nil, + logger: Logging.Logger? = nil, + @_inheritActorContext @_implicitSelfCapture operation: (Self) async throws -> ReturnType, + shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + ) async throws -> ReturnType where ClockType.Duration == Duration { + let configuration = RetryConfiguration(maxAttempts: maxAttempts, + clock: clock, + backoff: backoff, + appleLogger: appleLogger, + logger: logger, + shouldRetry: shouldRetry) + + return try await retry(with: configuration, + operation: operation) + } + + /// Attempts the given operation until it succeeds or until the failure is no longer retryable. + /// Sleeps in between attempts using the given clock. + /// + /// Failures may not be retryable for the following reasons: + /// - The response indicates that the failure is not transient. + /// - `shouldRetry` returns `false`. + /// - The thrown error is ``NotRetryable``. + /// - The number of attempts reached `maxAttempts`. + /// + /// - Precondition: ``isIdempotent`` must return `true`. + /// + /// - Parameters: + /// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. + /// - clock: The clock that will be used to sleep in between attempts. + /// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. + /// - appleLogger: The logger that will be used to log a message when an attempt fails. The function will + /// 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. + /// - 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``. + /// + /// - SeeAlso: ``retry(with:operation:)`` + public func retry( + maxAttempts: Int? = 3, + clock: ClockType, + backoff: Backoff, + appleLogger: os.Logger? = nil, + logger: Logging.Logger? = nil, + @_inheritActorContext @_implicitSelfCapture operation: (Self) async throws -> ReturnType, + shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + ) async throws -> ReturnType { + let configuration = RetryConfiguration(maxAttempts: maxAttempts, + clock: clock, + backoff: backoff, + appleLogger: appleLogger, + logger: logger, + shouldRetry: shouldRetry) + + return try await retry(with: configuration, + operation: operation) + } +#else + /// Attempts the given operation until it succeeds or until the failure is no longer retryable. + /// Sleeps in between attempts using `ContinuousClock`. + /// + /// Failures may not be retryable for the following reasons: + /// - The response indicates that the failure is not transient. + /// - `shouldRetry` returns `false`. + /// - The thrown error is ``NotRetryable``. + /// - The number of attempts reached `maxAttempts`. + /// + /// - Precondition: ``isIdempotent`` must return `true`. + /// + /// - Parameters: + /// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. + /// - 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. + /// - 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``. + /// + /// - SeeAlso: ``retry(with:operation:)`` + public func retry( + maxAttempts: Int? = 3, + 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 } + ) async throws -> ReturnType { + return try await retry(maxAttempts: maxAttempts, + clock: ContinuousClock(), + backoff: backoff, + logger: logger, + operation: operation, + shouldRetry: shouldRetry) + } + + /// 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: + /// - The response indicates that the failure is not transient. + /// - `shouldRetry` returns `false`. + /// - The thrown error is ``NotRetryable``. + /// - The number of attempts reached `maxAttempts`. + /// + /// - Precondition: ``isIdempotent`` must return `true`. + /// + /// - Parameters: + /// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. + /// - clock: The clock that will be used to sleep in between attempts. + /// - 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. + /// - 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``. + /// + /// - SeeAlso: ``retry(with:operation:)`` + public func retry( + maxAttempts: Int? = 3, + clock: ClockType, + 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 } + ) async throws -> ReturnType where ClockType.Duration == Duration { + let configuration = RetryConfiguration(maxAttempts: maxAttempts, + clock: clock, + backoff: backoff, + logger: logger, + shouldRetry: shouldRetry) + + return try await retry(with: configuration, + operation: operation) + } + + /// Attempts the given operation until it succeeds or until the failure is no longer retryable. + /// Sleeps in between attempts using the given clock. + /// + /// Failures may not be retryable for the following reasons: + /// - The response indicates that the failure is not transient. + /// - `shouldRetry` returns `false`. + /// - The thrown error is ``NotRetryable``. + /// - The number of attempts reached `maxAttempts`. + /// + /// - Precondition: ``isIdempotent`` must return `true`. + /// + /// - Parameters: + /// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. + /// - clock: The clock that will be used to sleep in between attempts. + /// - 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. + /// - 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``. + /// + /// - SeeAlso: ``retry(with:operation:)`` + public func retry( + maxAttempts: Int? = 3, + clock: ClockType, + backoff: Backoff, + logger: Logging.Logger? = nil, + @_inheritActorContext @_implicitSelfCapture operation: (Self) async throws -> ReturnType, + shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } + ) async throws -> ReturnType { + let configuration = RetryConfiguration(maxAttempts: maxAttempts, + clock: clock, + backoff: backoff, + logger: logger, + shouldRetry: shouldRetry) + + return try await retry(with: configuration, + operation: operation) + } +#endif + + /// Attempts the given operation until it succeeds or until the failure is no longer retryable. + /// + /// Failures may not be retryable for the following reasons: + /// - The response indicates that the failure is not transient. + /// - ``RetryConfiguration/shouldRetry`` returns `false`. + /// - The thrown error is ``NotRetryable``. + /// - The number of attempts reached ``RetryConfiguration/maxAttempts``. + /// + /// - Precondition: ``isIdempotent`` must return `true`. + /// + /// - Parameters: + /// - configuration: Configuration that specifies the behavior of this function. + /// - operation: Attempts the given request. + /// + /// - Note: The function will log messages using the `debug` log level to ``RetryConfiguration/logger`` + /// (and/or ``RetryConfiguration/appleLogger`` on Apple platforms). + public func retry( + with configuration: RetryConfiguration, + @_inheritActorContext @_implicitSelfCapture operation: (Self) async throws -> ReturnType + ) async throws -> ReturnType { + precondition(isIdempotent) + + return try await unsafeRetryIgnoringIdempotency(with: configuration, + operation: operation) + } +} diff --git a/Sources/Retry/RetryableRequest/RetryableRequest.swift b/Sources/Retry/RetryableRequest/RetryableRequest.swift new file mode 100644 index 0000000..32f4658 --- /dev/null +++ b/Sources/Retry/RetryableRequest/RetryableRequest.swift @@ -0,0 +1,65 @@ +// MIT License +// +// Copyright © 2023 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. + +/// A protocol that adds safe retry methods to a conforming request type. +/// +/// The retry methods in this protocol are similar to the top-level retry functions, but safer. The retry +/// methods in this protocol enforce that ``isIdempotent`` returns `true` since it is unsafe to +/// retry a non-idempotent request. +/// +/// Conform request types to this protocol when ``isIdempotent`` can be implemented accurately. +/// For example, the HTTP specification defines certain HTTP request methods as idempotent, so it +/// would be straightforward to conform an HTTP request type to this protocol. +/// +/// 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. +public protocol RetryableRequest { + /// Determines whether the request is idempotent. + /// + /// A request is considered idempotent if the intended effect on the server of multiple + /// identical requests is the same as the effect for a single such request. + var isIdempotent: Bool { get } + + /// Attempts the given operation until it succeeds or until the failure is no longer retryable. + /// + /// - Warning: This method is unsafe because it does not check ``isIdempotent``. + /// Consider using ``retry(with:operation:)`` instead. + /// + /// Failures may not be retryable for the following reasons: + /// - The response indicates that the failure is not transient. + /// - ``RetryConfiguration/shouldRetry`` returns `false`. + /// - The thrown error is ``NotRetryable``. + /// - The number of attempts reached ``RetryConfiguration/maxAttempts``. + /// + /// - Parameters: + /// - configuration: Configuration that specifies the behavior of this function. + /// - operation: Attempts the given request. + /// + /// - Note: The function will log messages using the `debug` log level to ``RetryConfiguration/logger`` + /// (and/or ``RetryConfiguration/appleLogger`` on Apple platforms). + func unsafeRetryIgnoringIdempotency( + with configuration: RetryConfiguration, + @_inheritActorContext @_implicitSelfCapture operation: (Self) async throws -> ReturnType + ) async throws -> ReturnType +}