Skip to content

Commit 9a78793

Browse files
committed
feat(network): prevent concurrent token refresh by queueing pending requests
1 parent d136bd8 commit 9a78793

1 file changed

Lines changed: 56 additions & 15 deletions

File tree

Sources/NetworkLayer/Classes/Core/Services/RequestProcessor/RequestProcessor.swift

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)