Five requests fail with 401 at the same moment. How do you refresh the token once, retry each request once, and avoid a loop?

12.9.1 · L5 · Interview

Predict first

Five concurrent calls hit an expired token. How many refresh requests are sent?

import Foundation

struct Unauthorized: Error {}
actor TokenStore {
    private(set) var token = "old"
    private var refreshing: Task<String, Error>?
    private(set) var refreshCalls = 0
    func validToken() -> String { token }
    func refresh(stale: String) async throws -> String {
        if token != stale { return token }
        if let task = refreshing { return try await task.value }
        let task = Task { () -> String in
            try await Task.sleep(nanoseconds: 50_000_000)
            return "new"
        }
        refreshing = task
        refreshCalls += 1
        defer { refreshing = nil }
        token = try await task.value
        return token
    }
}
let store = TokenStore()

func call(_ id: Int) async throws -> String {
    var token = await store.validToken()
    for attempt in 0..<2 {
        if token == "new" { return "call \(id) ok" }
        if attempt == 1 { throw Unauthorized() }
        token = try await store.refresh(stale: token)
    }
    throw Unauthorized()
}
let results = try await withThrowingTaskGroup(of: String.self) { g -> [String] in
    for i in 1...5 { g.addTask { try await call(i) } }
    var r: [String] = []
    for try await v in g { r.append(v) }
    return r
}
print(results.count, "calls succeeded")
print("refresh requests sent:", await store.refreshCalls)
  • 5 calls succeeded / refresh requests sent: 1
  • 5 calls succeeded / refresh requests sent: 5
  • 1 calls succeeded / refresh requests sent: 1

Mental model. When the front door lock jams, five people queue and one goes to fetch the new key. The others wait for that one trip and share the result, rather than all running to the locksmith.

If every failing request refreshes the token itself you send five refresh calls, and with a rotating refresh token the second one invalidates the first and the user is logged out. The pattern is single-flight: keep the in-flight refresh as a stored Task inside an actor, and make every caller await that same task. In the check the five calls all start with the stale token, each calls refresh(stale:), the first creates the task and the others join it, so five calls succeed with exactly one refresh request. Two details make it correct: pass the stale token that failed so a caller who arrives after the refresh finished does not trigger another (if token != stale { return token }), and clear the stored task when it ends (defer) so the next expiry can refresh again.

To avoid a loop, retry once: a request that fails with 401 again after a fresh token should fail (the credentials really are bad), and if the refresh itself returns 401 or 400 the session is over, so sign the user out instead of retrying. Do not retry requests that are not safe to repeat unless they are idempotent or carry an idempotency key (Q324), proactively refresh slightly before the expiry time to avoid most 401s, never log tokens, and keep the refresh request itself outside the interceptor that adds the token and retries (or it will recurse). The same single-flight idea de-duplicates identical GET requests: a dictionary of in-flight tasks keyed by the request (Q295 and the image-loading pipeline, Q327).

The first caller stores the in-flight task and the rest await it, so there is one refresh.

Common traps

  • Refreshing in each failing request. Rotating refresh tokens make the second call invalidate the first.
  • Retrying forever on 401. Retry once, then fail and sign out.

They might follow up with

  • Why an actor? It makes “check for an in-flight refresh, else start one” an atomic step, without locks (but mind reentrancy: store the task before the first await).
  • What about requests that started with the old token while the refresh is running? They fail with 401, then call refresh(stale:) with the stale token and get the new one.

Related questions

  • 12.8.3 How do you classify what URLSession reports: which failures are URLError, which are not, and which are worth…
  • 12.9.2 How do you read a response as a stream with URLSession.bytes, and why does lines skip blank lines when you…
  • 12.3.3 How do you handle access-token expiry, refresh races and 401 loops?
  • 12.5.1 How should a client retry failed requests: what to retry, how long to wait, and what idempotency has to do…
  • 10.4.1 What does an actor guarantee, and what is actor reentrancy?
  • 12.7.1 How do you design an image loading pipeline: decoding, downsampling, caching and de-duplication?

How do you read a response as a stream with URLSession.bytes, and why… →