Added the rate limit middleware.

This commit is contained in:
javier committed 2026-10-25 18:14:19 +01:00
1 parent 35b3b7953d
commit 2d86dff608
4 files changed
+326

No files matched your search

+11
View File
@@ -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")!
}