From 2d86dff60811b6124fb765711741182616c13fb5 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 25 Oct 2026 18:14:19 +0100 Subject: [PATCH] Added the rate limit middleware. --- README.md | 11 ++ .../Catalogs/ASConnectService.docc/Library.md | 15 ++ .../Middlewares/RateLimitMiddleware.swift | 149 +++++++++++++++++ .../RateLimitMiddlewareTests.swift | 151 ++++++++++++++++++ 4 files changed, 326 insertions(+) create mode 100644 Sources/ASConnectService/Sources/Public/Middlewares/RateLimitMiddleware.swift create mode 100644 Tests/ASConnectService/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift diff --git a/README.md b/README.md index 52e48cf687..fc94c9795f 100644 --- a/README.md +++ b/README.md @@ -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+ diff --git a/Sources/ASConnectService/Catalogs/ASConnectService.docc/Library.md b/Sources/ASConnectService/Catalogs/ASConnectService.docc/Library.md index a75095c79f..fd48910970 100644 --- a/Sources/ASConnectService/Catalogs/ASConnectService.docc/Library.md +++ b/Sources/ASConnectService/Catalogs/ASConnectService.docc/Library.md @@ -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`` diff --git a/Sources/ASConnectService/Sources/Public/Middlewares/RateLimitMiddleware.swift b/Sources/ASConnectService/Sources/Public/Middlewares/RateLimitMiddleware.swift new file mode 100644 index 0000000000..e4588fabb0 --- /dev/null +++ b/Sources/ASConnectService/Sources/Public/Middlewares/RateLimitMiddleware.swift @@ -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) } + } +} diff --git a/Tests/ASConnectService/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift b/Tests/ASConnectService/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift new file mode 100644 index 0000000000..bee86db761 --- /dev/null +++ b/Tests/ASConnectService/Cases/Public/Middlewares/RateLimitMiddlewareTests.swift @@ -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 { $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")! +}