Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[GitHub] Add RetryInterceptor #53

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion PapyrusCore/Sources/Provider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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))
Expand All @@ -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
}
}
45 changes: 44 additions & 1 deletion PapyrusCore/Tests/ProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,61 @@ 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 {
static var attempt = 0

func build(method: String, url: URL, headers: [String : String], body: Data?) -> Request {
fatalError()
}

func request(_ req: Request) async -> Response {
fatalError()
// Simulate a retry scenario
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
}
}
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
Loading