Overhauled the library with several new features #5
No files matched your search
@@ -86,6 +86,17 @@ repeat {
|
||||
} while cursor != nil
|
||||
```
|
||||
|
||||
### Handling Rate Limits
|
||||
|
||||
Add `RateLimitMiddleware` before the authentication middleware to retry `429 Too Many Requests` responses:
|
||||
|
||||
```swift
|
||||
middlewares: [
|
||||
RateLimitMiddleware(),
|
||||
BearerAuthMiddleware { try await tokenProvider.token() }
|
||||
]
|
||||
```
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
- iOS 14.0+
|
||||
|
||||
@@ -63,6 +63,17 @@ repeat {
|
||||
} while cursor != nil
|
||||
```
|
||||
|
||||
### Handling Rate Limits
|
||||
|
||||
Add ``RateLimitMiddleware`` before the authentication middleware to retry `429 Too Many Requests` responses:
|
||||
|
||||
```swift
|
||||
middlewares: [
|
||||
RateLimitMiddleware(),
|
||||
BearerAuthMiddleware { try await tokenProvider.token() }
|
||||
]
|
||||
```
|
||||
|
||||
## Topics
|
||||
|
||||
### API Client
|
||||
@@ -75,6 +86,10 @@ repeat {
|
||||
- ``BearerAuthMiddleware``
|
||||
- ``APIKeyTokenProvider``
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
- ``RateLimitMiddleware``
|
||||
|
||||
### Generated Types
|
||||
|
||||
- ``Components``
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// ===----------------------------------------------------------------------===
|
||||
//
|
||||
// This source file is part of the ASConnectService open source project
|
||||
//
|
||||
// Copyright (c) 2026 Röck+Cöde VoF. and the ASConnectService project authors
|
||||
// Licensed under Apache license v2.0
|
||||
//
|
||||
// See LICENSE for license information
|
||||
// See CONTRIBUTORS for the list of ASConnectService project authors
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// ===----------------------------------------------------------------------===
|
||||
|
||||
import struct Foundation.URL
|
||||
import typealias Foundation.TimeInterval
|
||||
import struct HTTPTypes.HTTPRequest
|
||||
import struct HTTPTypes.HTTPResponse
|
||||
import protocol OpenAPIRuntime.ClientMiddleware
|
||||
import class OpenAPIRuntime.HTTPBody
|
||||
|
||||
/// A client middleware that retries rate-limited requests.
|
||||
///
|
||||
/// On a `429 Too Many Requests` response, it waits for the `Retry-After` seconds, or a delay that doubles from
|
||||
/// `baseDelay`, and retries. It returns the `429` response instead when the retries run out, the delay exceeds
|
||||
/// `maximumDelay`, or the request body can only be sent once.
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// Place it before ``BearerAuthMiddleware``, so retries get a fresh token:
|
||||
///
|
||||
/// ```swift
|
||||
/// middlewares: [
|
||||
/// RateLimitMiddleware(),
|
||||
/// BearerAuthMiddleware { try await tokenProvider.token() }
|
||||
/// ]
|
||||
/// ```
|
||||
public struct RateLimitMiddleware {
|
||||
// MARK: Properties
|
||||
|
||||
/// The maximum number of times a request is retried.
|
||||
private let maximumRetries: Int
|
||||
|
||||
/// The delay before the first retry, when the response has no `Retry-After` header.
|
||||
private let baseDelay: TimeInterval
|
||||
|
||||
/// The longest delay to wait before a retry.
|
||||
private let maximumDelay: TimeInterval
|
||||
|
||||
/// The closure that waits for a given number of seconds.
|
||||
private let sleep: @Sendable (TimeInterval) async throws -> Void
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a new middleware instance.
|
||||
/// - Parameters:
|
||||
/// - maximumRetries: The maximum number of times a request is retried.
|
||||
/// - baseDelay: The delay, in seconds, before the first retry when the response has no `Retry-After` header.
|
||||
/// - maximumDelay: The longest delay, in seconds, to wait before a retry.
|
||||
public init(
|
||||
maximumRetries: Int = 3,
|
||||
baseDelay: TimeInterval = 1,
|
||||
maximumDelay: TimeInterval = 60
|
||||
) {
|
||||
self.init(
|
||||
maximumRetries: maximumRetries,
|
||||
baseDelay: baseDelay,
|
||||
maximumDelay: maximumDelay,
|
||||
sleep: { try await Task.sleep(nanoseconds: UInt64($0 * 1_000_000_000)) }
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a new middleware instance that waits with `sleep`, for testing.
|
||||
init(
|
||||
maximumRetries: Int,
|
||||
baseDelay: TimeInterval,
|
||||
maximumDelay: TimeInterval,
|
||||
sleep: @escaping @Sendable (TimeInterval) async throws -> Void
|
||||
) {
|
||||
self.maximumRetries = maximumRetries
|
||||
self.baseDelay = baseDelay
|
||||
self.maximumDelay = maximumDelay
|
||||
self.sleep = sleep
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ClientMiddleware
|
||||
|
||||
extension RateLimitMiddleware: ClientMiddleware {
|
||||
// MARK: Methods
|
||||
|
||||
/// Sends an HTTP request, and retries it while its response is `429 Too Many Requests`.
|
||||
/// - Parameters:
|
||||
/// - request: The original HTTP request.
|
||||
/// - body: The optional body of the request.
|
||||
/// - baseURL: The base URL for the request.
|
||||
/// - operationID: The identifier of the API operation being performed.
|
||||
/// - next: The next handler in the middleware chain.
|
||||
/// - Returns: The HTTP response and optional body returned by the next handler.
|
||||
/// - Throws: An error if the next handler throws, or the task is cancelled while waiting.
|
||||
public func intercept(
|
||||
_ request: HTTPRequest,
|
||||
body: HTTPBody?,
|
||||
baseURL: URL,
|
||||
operationID: String,
|
||||
next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
|
||||
) async throws -> (HTTPResponse, HTTPBody?) {
|
||||
var retries = 0
|
||||
var backoff = baseDelay
|
||||
|
||||
while true {
|
||||
let (response, responseBody) = try await next(
|
||||
request,
|
||||
body,
|
||||
baseURL
|
||||
)
|
||||
|
||||
guard
|
||||
response.status == .tooManyRequests,
|
||||
retries < maximumRetries,
|
||||
body?.iterationBehavior != .single
|
||||
else {
|
||||
return (response, responseBody)
|
||||
}
|
||||
|
||||
let delay = response.retryAfter ?? backoff
|
||||
|
||||
guard delay <= maximumDelay else {
|
||||
return (response, responseBody)
|
||||
}
|
||||
|
||||
try await sleep(delay)
|
||||
|
||||
retries += 1
|
||||
backoff *= 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HTTPResponse+RetryAfter
|
||||
|
||||
private extension HTTPResponse {
|
||||
/// The seconds in the `Retry-After` header, if any, and never negative.
|
||||
var retryAfter: TimeInterval? {
|
||||
headerFields[.retryAfter]
|
||||
.flatMap { TimeInterval($0) }
|
||||
.map { max($0, 0) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import Foundation
|
||||
import HTTPTypes
|
||||
import OpenAPIRuntime
|
||||
import Testing
|
||||
|
||||
// A testable import gives access to the initializer that injects the sleep closure.
|
||||
@testable import ASConnectService
|
||||
|
||||
@Suite("RateLimitMiddleware")
|
||||
struct RateLimitMiddlewareTests {
|
||||
// MARK: Properties
|
||||
|
||||
private let recorder = Recorder()
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
@Test("Returns a response that is not rate limited without retrying")
|
||||
func returnsResponseWithoutRetrying() async throws {
|
||||
let response = try await intercept(responses: [.ok])
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(await recorder.calls == 1)
|
||||
#expect(await recorder.delays.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Retries rate-limited requests with an exponential backoff")
|
||||
func retriesWithBackoff() async throws {
|
||||
let response = try await intercept(responses: [.tooManyRequests, .tooManyRequests, .ok])
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(await recorder.calls == 3)
|
||||
#expect(await recorder.delays == [1, 2])
|
||||
}
|
||||
|
||||
@Test("Waits for the seconds in the Retry-After header")
|
||||
func waitsForRetryAfter() async throws {
|
||||
let response = try await intercept(responses: [.tooManyRequests(retryAfter: "5"), .ok])
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(await recorder.delays == [5])
|
||||
}
|
||||
|
||||
@Test("Returns the rate-limited response when the retries run out")
|
||||
func returnsResponseWhenRetriesRunOut() async throws {
|
||||
let response = try await intercept(
|
||||
responses: [.tooManyRequests, .tooManyRequests, .tooManyRequests],
|
||||
maximumRetries: 2
|
||||
)
|
||||
|
||||
#expect(response.status == .tooManyRequests)
|
||||
#expect(await recorder.calls == 3)
|
||||
#expect(await recorder.delays == [1, 2])
|
||||
}
|
||||
|
||||
@Test("Returns the rate-limited response when the delay exceeds the maximum")
|
||||
func returnsResponseWhenDelayExceedsMaximum() async throws {
|
||||
let response = try await intercept(responses: [.tooManyRequests(retryAfter: "3600"), .ok])
|
||||
|
||||
#expect(response.status == .tooManyRequests)
|
||||
#expect(await recorder.calls == 1)
|
||||
#expect(await recorder.delays.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Does not retry a request whose body can only be sent once")
|
||||
func doesNotRetrySingleIterationBody() async throws {
|
||||
let body = HTTPBody(
|
||||
AsyncStream<HTTPBody.ByteChunk> { $0.finish() },
|
||||
length: .unknown,
|
||||
iterationBehavior: .single
|
||||
)
|
||||
|
||||
let response = try await intercept(responses: [.tooManyRequests, .ok], body: body)
|
||||
|
||||
#expect(response.status == .tooManyRequests)
|
||||
#expect(await recorder.calls == 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension RateLimitMiddlewareTests {
|
||||
func intercept(
|
||||
responses: [HTTPResponse],
|
||||
body: HTTPBody? = nil,
|
||||
maximumRetries: Int = 3
|
||||
) async throws -> HTTPResponse {
|
||||
let recorder = recorder
|
||||
let middleware = RateLimitMiddleware(
|
||||
maximumRetries: maximumRetries,
|
||||
baseDelay: 1,
|
||||
maximumDelay: 60,
|
||||
sleep: { await recorder.record(delay: $0) }
|
||||
)
|
||||
|
||||
let (response, _) = try await middleware.intercept(
|
||||
.get,
|
||||
body: body,
|
||||
baseURL: .base,
|
||||
operationID: "apps_getCollection",
|
||||
next: { _, _, _ in
|
||||
(await recorder.response(from: responses), nil)
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
/// An actor that records the calls to the next handler, and the delays the middleware waits for.
|
||||
private actor Recorder {
|
||||
private(set) var calls = 0
|
||||
private(set) var delays: [TimeInterval] = []
|
||||
|
||||
func response(from responses: [HTTPResponse]) -> HTTPResponse {
|
||||
defer { calls += 1 }
|
||||
|
||||
return responses[calls]
|
||||
}
|
||||
|
||||
func record(delay: TimeInterval) {
|
||||
delays.append(delay)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HTTPRequest+Samples
|
||||
|
||||
private extension HTTPRequest {
|
||||
static let get = HTTPRequest(
|
||||
method: .get,
|
||||
scheme: "https",
|
||||
authority: "api.appstoreconnect.apple.com",
|
||||
path: "/v1/apps"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - HTTPResponse+Samples
|
||||
|
||||
private extension HTTPResponse {
|
||||
static let ok = HTTPResponse(status: .ok)
|
||||
static let tooManyRequests = HTTPResponse(status: .tooManyRequests)
|
||||
|
||||
static func tooManyRequests(retryAfter: String) -> HTTPResponse {
|
||||
HTTPResponse(status: .tooManyRequests, headerFields: [.retryAfter: retryAfter])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URL+Samples
|
||||
|
||||
private extension URL {
|
||||
static let base = URL(string: "https://api.appstoreconnect.apple.com")!
|
||||
}
|
||||
Reference in new issue
Block a user