@@ -41,6 +41,14 @@ actor RequestProcessor {
4141 /// This applies to all requests processed by this instance.
4242 private let retryEvaluator : ( @Sendable ( Error ) -> RetryAction ) ?
4343
44+ /// A shared in-flight refresh task. All requests that need a token refresh
45+ /// will await this single task instead of each triggering their own refresh.
46+ ///
47+ /// Because `RequestProcessor` is an `actor`, access to this property is
48+ /// automatically serialised — no additional locking is required.
49+ /// When `nil`, no refresh is currently in flight.
50+ private var pendingRefreshTask : Task < Void , Error > ?
51+
4452 // MARK: - Initialization
4553
4654 /// Creates a new `RequestProcessor` instance.
@@ -52,6 +60,7 @@ actor RequestProcessor {
5260 /// - retryPolicyService: The retry policy service.
5361 /// - delegate: A thread-safe delegate for processor events.
5462 /// - interceptor: An authenticator interceptor.
63+ /// - retryEvaluator: A global closure that decides whether a failed request should be retried.
5564 init (
5665 configuration: Configuration ,
5766 requestBuilder: IRequestBuilder ,
@@ -87,6 +96,7 @@ actor RequestProcessor {
8796 /// - strategy: An optional override for the retry policy strategy.
8897 /// - delegate: A delegate to handle session-level events.
8998 /// - configure: A closure for final modifications to the `URLRequest`.
99+ /// - shouldRetry: An optional per-call closure that decides whether to retry on a given error.
90100 /// - Returns: A `Response` object containing the raw `Data`.
91101 private func performRequest(
92102 _ request: some IRequest ,
@@ -118,26 +128,46 @@ actor RequestProcessor {
118128 try await interceptor? . adapt ( request: & urlRequest, for: session)
119129 }
120130
121- /// Checks if a request requires a credential refresh and performs it if necessary.
131+ /// Ensures that only a single token refresh is in flight at any given time.
132+ ///
133+ /// When the first request detects that a refresh is needed it creates a shared `Task`
134+ /// and stores it in `pendingRefreshTask`. Every subsequent request that arrives while
135+ /// the refresh is still running will `await` that same task instead of starting a new
136+ /// one, preventing the "thundering herd" problem. Once the task completes (successfully
137+ /// or with an error) `pendingRefreshTask` is cleared so the next cycle can start fresh.
122138 ///
123139 /// - Parameters:
124- /// - urlRequest: The failed or unauthorized request.
125- /// - response: The received network response .
140+ /// - urlRequest: The unauthorized request that triggered the refresh check .
141+ /// - response: The HTTP response received for `urlRequest` .
126142 /// - session: The current `URLSession`.
127- /// - Returns: `true` if a refresh was triggered, `false` otherwise .
128- private func refresh (
143+ /// - Returns: `true` if a refresh was triggered or awaited , `false` if none was needed .
144+ private func refreshIfNeeded (
129145 urlRequest: URLRequest ,
130146 response: Response < some Any > ,
131147 session: URLSession
132148 ) async throws -> Bool {
133- guard let interceptor, let response = response. response as? HTTPURLResponse else { return false }
134-
135- if interceptor. isRequireRefresh ( urlRequest, response: response) {
136- try await interceptor. refresh ( urlRequest, with: response, for: session)
149+ guard
150+ let interceptor,
151+ let httpResponse = response. response as? HTTPURLResponse ,
152+ interceptor. isRequireRefresh ( urlRequest, response: httpResponse)
153+ else { return false }
154+
155+ // Re-use the existing task if a refresh is already running.
156+ if let existingTask = pendingRefreshTask {
157+ try await existingTask. value
137158 return true
138159 }
139160
140- return false
161+ // We are the first — own the refresh task.
162+ let refreshTask = Task < Void , Error > { [ interceptor] in
163+ try await interceptor. refresh ( urlRequest, with: httpResponse, for: session)
164+ }
165+
166+ pendingRefreshTask = refreshTask
167+ defer { pendingRefreshTask = nil }
168+
169+ try await refreshTask. value
170+ return true
141171 }
142172
143173 /// Wraps a request operation with retry logic provided by the `retryPolicyService`.
@@ -223,10 +253,16 @@ actor RequestProcessor {
223253 }
224254
225255 /// Retries the request once if the initial response requires a token refresh.
226- /// Throws if the response is still unauthorized after the retry.
256+ ///
257+ /// Uses `refreshIfNeeded` to ensure only one refresh is performed even when
258+ /// multiple requests detect an expired token simultaneously. After the shared
259+ /// refresh resolves, this method re-executes the data task with the new credential.
260+ /// If the retried response still requires a refresh the request is considered
261+ /// permanently unauthorized and `AuthenticatorInterceptorError.missingCredential`
262+ /// is thrown.
227263 ///
228264 /// - Parameters:
229- /// - urlRequest: The original `URLRequest` to retry.
265+ /// - urlRequest: The original `URLRequest` to retry after a token refresh .
230266 /// - response: The initial response received before any retry attempt.
231267 /// - delegate: An optional `URLSessionDelegate` for task-level events.
232268 /// - Returns: The original response if no retry was needed, or the retried response on success.
@@ -237,13 +273,18 @@ actor RequestProcessor {
237273 response: Response < Data > ,
238274 delegate: URLSessionDelegate ?
239275 ) async throws -> Response < Data > {
240- guard try await refresh ( urlRequest: urlRequest, response: response, session: session) else {
276+ guard try await refreshIfNeeded ( urlRequest: urlRequest, response: response, session: session) else {
241277 return response
242278 }
243279
244- let retryResponse = try await performDataTask ( urlRequest: urlRequest, delegate: delegate)
280+ var adaptedRequest = urlRequest
281+ try await interceptor? . adapt ( request: & adaptedRequest, for: session)
282+
283+ let retryResponse = try await performDataTask ( urlRequest: adaptedRequest, delegate: delegate)
245284
246- guard try await !refresh( urlRequest: urlRequest, response: retryResponse, session: session) else {
285+ if let httpRetryResponse = retryResponse. response as? HTTPURLResponse ,
286+ interceptor? . isRequireRefresh ( adaptedRequest, response: httpRetryResponse) == true
287+ {
247288 throw AuthenticatorInterceptorError . missingCredential
248289 }
249290
0 commit comments