Added the rate limit middleware.
This commit is contained in:
1 parent
35b3b7953d
commit
2d86dff608
4 files changed
+326
No files matched your search
@@ -86,6 +86,17 @@ repeat {
|
|||||||
} while cursor != nil
|
} 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
|
## Supported Platforms
|
||||||
|
|
||||||
- iOS 14.0+
|
- iOS 14.0+
|
||||||
|
|||||||
@@ -63,6 +63,17 @@ repeat {
|
|||||||
} while cursor != nil
|
} 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
|
## Topics
|
||||||
|
|
||||||
### API Client
|
### API Client
|
||||||
@@ -75,6 +86,10 @@ repeat {
|
|||||||
- ``BearerAuthMiddleware``
|
- ``BearerAuthMiddleware``
|
||||||
- ``APIKeyTokenProvider``
|
- ``APIKeyTokenProvider``
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
- ``RateLimitMiddleware``
|
||||||
|
|
||||||
### Generated Types
|
### Generated Types
|
||||||
|
|
||||||
- ``Components``
|
- ``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