From 8f243bbe2f52837427dbba0f769cbee554d22ce0 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 29 Apr 2024 11:26:00 -0700 Subject: [PATCH 1/2] [GitHub] Add retry interceptor --- PapyrusCore/Sources/Provider.swift | 61 ++++++++++++++++++++++++++- PapyrusCore/Tests/ProviderTests.swift | 44 ++++++++++++++++++- README.md | 18 ++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) diff --git a/PapyrusCore/Sources/Provider.swift b/PapyrusCore/Sources/Provider.swift index af16828..ecea151 100644 --- a/PapyrusCore/Sources/Provider.swift +++ b/PapyrusCore/Sources/Provider.swift @@ -6,12 +6,14 @@ public final class Provider { public let http: HTTPService public var interceptors: [Interceptor] public var modifiers: [RequestModifier] + public var retryInterceptor: RetryInterceptor? - public init(baseURL: String, http: HTTPService, modifiers: [RequestModifier] = [], interceptors: [Interceptor] = []) { + public init(baseURL: String, http: HTTPService, modifiers: [RequestModifier] = [], interceptors: [Interceptor] = [], retryInterceptor: RetryInterceptor? = nil) { self.baseURL = baseURL self.http = http self.interceptors = interceptors self.modifiers = modifiers + self.retryInterceptor = retryInterceptor } public func newBuilder(method: String, path: String) -> RequestBuilder { @@ -54,6 +56,11 @@ public final class Provider { next = { try await interceptor.intercept(req: $0, next: _next) } } + if let retryInterceptor = retryInterceptor { + let _next = next + next = { try await retryInterceptor.intercept(req: $0, next: _next) } + } + return try await next(request) } @@ -92,6 +99,13 @@ extension Provider { } } + if let retryInterceptor = retryInterceptor { + let _next = next + next = { + retryInterceptor.intercept(req: $0, completionHandler: $1, next: _next) + } + } + return next(request, completionHandler) } catch { completionHandler(.error(error)) @@ -118,3 +132,48 @@ extension Interceptor { } } } + +/// Retry interceptor that retries failed requests based on configurable conditions. +public class RetryInterceptor: Interceptor { + private let retryConditions: [(Request, Response) -> Bool] + private let maxRetryCount: Int + private let retryDelay: TimeInterval + + /// Initializes a new `RetryInterceptor`. + /// - Parameters: + /// - retryConditions: A list of conditions to evaluate whether a request should be retried. + /// - maxRetryCount: The maximum number of times a request should be retried. + /// - retryDelay: The delay, in seconds, before retrying a request. + public init(retryConditions: [(Request, Response) -> Bool], maxRetryCount: Int = 3, retryDelay: TimeInterval = 1.0) { + self.retryConditions = retryConditions + self.maxRetryCount = maxRetryCount + self.retryDelay = retryDelay + } + + public func intercept(req: Request, next: Next) async throws -> Response { + var retryCount = 0 + var lastResponse: Response? + + while retryCount < maxRetryCount { + let response = try await next(req) + if shouldRetry(request: req, response: response) { + retryCount += 1 + lastResponse = response + try await Task.sleep(nanoseconds: UInt64(retryDelay * 1_000_000_000)) + continue + } + return response + } + + throw PapyrusError("Request failed after \(maxRetryCount) retries.", req, lastResponse) + } + + private func shouldRetry(request: Request, response: Response) -> Bool { + for condition in retryConditions { + if condition(request, response) { + return true + } + } + return false + } +} diff --git a/PapyrusCore/Tests/ProviderTests.swift b/PapyrusCore/Tests/ProviderTests.swift index f795dec..732e119 100644 --- a/PapyrusCore/Tests/ProviderTests.swift +++ b/PapyrusCore/Tests/ProviderTests.swift @@ -9,6 +9,22 @@ final class ProviderTests: XCTestCase { XCTAssertEqual(req.method, "bar") XCTAssertEqual(req.path, "baz") } + + func testRetryInterceptor() async { + let retryInterceptor = RetryInterceptor(retryConditions: [{ _, response in + guard let statusCode = response.statusCode else { return false } + return statusCode == 500 + }]) + let provider = Provider(baseURL: "https://example.com", http: TestHTTPService(), retryInterceptor: retryInterceptor) + let builder = provider.newBuilder(method: "GET", path: "/test") + + do { + let response = try await provider.request(builder) + XCTAssertEqual(response.statusCode, 200) + } catch { + XCTFail("Request should not fail") + } + } } private struct TestHTTPService: HTTPService { @@ -17,10 +33,36 @@ private struct TestHTTPService: HTTPService { } func request(_ req: Request) async -> Response { - fatalError() + // Simulate a retry scenario + static var attempt = 0 + defer { attempt += 1 } + + if attempt < 2 { + return _Response(request: req.urlRequest, response: nil, error: nil, body: nil, statusCode: 500) + } else { + return _Response(request: req.urlRequest, response: nil, error: nil, body: nil, statusCode: 200) + } } func request(_ req: Request, completionHandler: @escaping (Response) -> Void) { } } + +private struct _Response: Response { + let urlRequest: URLRequest + let urlResponse: URLResponse? + let error: Error? + let body: Data? + let headers: [String : String]? + let statusCode: Int? + + init(request: URLRequest, response: URLResponse?, error: Error?, body: Data?, statusCode: Int?) { + self.urlRequest = request + self.urlResponse = response + self.error = error + self.body = body + self.headers = nil + self.statusCode = statusCode + } +} diff --git a/README.md b/README.md index 18ab0ea..f1d54a1 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ Annotations on the protocol, functions, and parameters help construct requests a - [x] Automatic Mocks for Testing - [x] Powered by `URLSession` or [Alamofire](https://github.com/Alamofire/Alamofire) Out of the Box - [x] Linux / Swift on Server Support Powered by [async-http-client](https://github.com/swift-server/async-http-client) +- [x] Retry Interceptor for handling failed requests based on configurable conditions ## Getting Started @@ -383,6 +384,23 @@ do { ## Advanced +### Retry Interceptor + +Papyrus supports adding a retry interceptor to your `Provider` to handle failed requests based on configurable conditions such as HTTP status codes or network errors. + +To use the retry interceptor, create an instance of `RetryInterceptor` with your desired retry conditions and maximum retry count. Then, add it to your `Provider` instance. + +```swift +let retryInterceptor = RetryInterceptor(retryConditions: [{ _, response in + guard let statusCode = response.statusCode else { return false } + return statusCode == 500 +}], maxRetryCount: 3, retryDelay: 1.0) + +let provider = Provider(baseURL: "https://api.example.com", retryInterceptor: retryInterceptor) +``` + +In this example, the interceptor will retry any request that fails with a 500 status code, up to 3 times, with a 1-second delay between retries. + ### Parameter Labels If you use two labels for a function parameter, the second one will be inferred as the relevant key. From 63f34d6ddb2cec7656d15455140a0b8104126c4b Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 29 Apr 2024 11:33:56 -0700 Subject: [PATCH 2/2] Update ProviderTests.swift --- PapyrusCore/Tests/ProviderTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PapyrusCore/Tests/ProviderTests.swift b/PapyrusCore/Tests/ProviderTests.swift index 732e119..88f6ab0 100644 --- a/PapyrusCore/Tests/ProviderTests.swift +++ b/PapyrusCore/Tests/ProviderTests.swift @@ -28,13 +28,14 @@ final class ProviderTests: XCTestCase { } private struct TestHTTPService: HTTPService { + static var attempt = 0 + func build(method: String, url: URL, headers: [String : String], body: Data?) -> Request { fatalError() } func request(_ req: Request) async -> Response { // Simulate a retry scenario - static var attempt = 0 defer { attempt += 1 } if attempt < 2 {