In a Flutter app using dio, five parallel requests all get 401 when the access token expires; how do you refresh the token exactly once and replay them?
answer
- plain Interceptor runs callbacks concurrently
- QueuedInterceptor serialises onError
- compare the token the request carried
- refresh with a separate Dio
- replay on that plain client, then resolve
basics
~20 sUse a QueuedInterceptor so onError handles one 401 at a time. The first refreshes through a plain Dio without that interceptor; later ones see the token already changed. Each replays on the plain Dio and resolves its handler.
solid answer
~40 sA plain `Interceptor` handles concurrent requests concurrently, so five 401s start five refreshes; with rotating refresh tokens the later ones fail and log the user out. `QueuedInterceptor` (or `QueuedInterceptorsWrapper`) processes `onRequest`, `onResponse` and `onError` each in its own queue, one call at a time. In `onError`, compare the `Authorization` header the failed request carried with the current token: if they match, refresh once, then store the new tokens; if not, another request already refreshed. Refresh **and replay** through a plain `Dio` without this interceptor: `plain.fetch(err.requestOptions)` with the new header, then `handler.resolve(retried)`. Replaying through the same `Dio` is a trap, because a failing replay's error joins the error queue behind the very callback awaiting it. Reject and clear the session when the refresh fails.
code
dart · 49 linesimport 'package:dio/dio.dart';
abstract interface class Session {
String? get accessToken;
String? get refreshToken;
Future<void> save(String access, String refresh);
Future<void> clear();
}
class RefreshInterceptor extends QueuedInterceptor {
RefreshInterceptor(this._session, this._plain);
final Session _session;
// Same base URL, no RefreshInterceptor: refreshes and replays go here.
final Dio _plain;
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
final request = err.requestOptions;
if (err.response?.statusCode != 401) {
return handler.next(err);
}
final sentToken = request.headers['Authorization'];
if (sentToken == 'Bearer ${_session.accessToken}') {
try {
final r = await _plain.post<Map<String, dynamic>>(
'/auth/refresh',
data: {'refreshToken': _session.refreshToken},
);
await _session.save(
r.data!['accessToken'] as String,
r.data!['refreshToken'] as String,
);
} on DioException {
await _session.clear();
return handler.reject(err);
}
}
final token = _session.accessToken;
if (token == null) return handler.reject(err); // session already ended
request.headers['Authorization'] = 'Bearer $token';
try {
// A failure here is rejected, never re-queued behind this callback.
handler.resolve(await _plain.fetch<dynamic>(request));
} on DioException catch (e) {
handler.reject(e);
}
}
}go deeper
Know that an expired token produces 401 responses and that the app refreshes the token and repeats the request.
Explain why concurrent 401s cause several refreshes and how QueuedInterceptor serialises onError.
Implement compare-then-refresh, refresh and replay on a plain client so a failing replay cannot deadlock the queue, resolve with the replayed response, and test the five-request race.
Agree with the backend on refresh-token rotation and expiry so the client strategy, proactive or reactive, matches server behaviour.
## The race When the gym app's access token expires, the home screen may have five requests in flight: membership, bookings, class timetable, notifications, profile. All five come back 401 at about the same time. With a plain `Interceptor`, dio calls `onError` for each of them concurrently, so a naive handler starts **five refresh calls**: - the backend issues five new token pairs, and if refresh tokens rotate, four of the five refresh tokens are already invalid; - whichever response lands last overwrites the stored tokens, possibly with a pair the server has since revoked; - users get logged out "randomly" after the token expires. How the refresh grant itself works is an OAuth question; the Flutter problem is coordinating it inside dio. ## `QueuedInterceptor` dio's `QueuedInterceptor` keeps **three queues**, one each for `onRequest`, `onResponse` and `onError`. Within a queue, a callback does not start until the previous one has called its handler. The queues run independently of each other, so new requests can still be sent while an error is being handled. `QueuedInterceptorsWrapper` is the inline form. For token refresh this gives exactly the needed guarantee: the five `onError` calls run one after another. ## The algorithm 1. **Filter.** If the status is not 401, call `handler.next(err)`. 2. **Compare tokens.** Read the `Authorization` header from `err.requestOptions`. If it still equals `Bearer <current access token>`, this is the first 401 of the batch: refresh. 3. **Refresh with a plain `Dio`.** Use a client that does not carry this interceptor (build it with its own `BaseOptions`, or with `dio.clone(interceptors: ...)` and a list that leaves the refresh interceptor out), so the refresh request cannot re-enter the queue and does not get the expired token attached. 4. **Store the new tokens**, or on failure clear the session, `handler.reject(err)` and let the app route to login. 5. **Replay on the plain client.** Set the new header on `err.requestOptions`, call `plain.fetch(err.requestOptions)`, and `handler.resolve(response)` so the original caller receives the successful response as if nothing happened. If the replay throws a `DioException` (including a second 401), pass it to `handler.reject`. Why not replay through the app's own `Dio`? A `QueuedInterceptor` advances its error queue only when the current callback calls its handler. If the replay fails, its error is queued behind the `onError` call that is awaiting the replay, and neither ever finishes. The dio repository's example uses the same compare-then-refresh shape with a dedicated refresh `Dio`, but replays through the main instance, which is safe only while every replay succeeds. ## Why each guard exists | Guard | Failure it prevents | |---|---| | Queued interceptor | N concurrent refresh calls | | Token comparison | Requests 2 to 5 refreshing again after request 1 already did | | Plain `Dio` for the refresh | Refresh request re-entering the queued interceptor, or carrying the expired token | | Plain `Dio` for the replay | A failing replay waiting in the error queue behind itself, and refresh loops on a second 401 | | Reject on refresh failure | Requests hanging forever while the session is dead | ## Proactive refresh Some teams refresh **before** expiry in `onRequest`, using the token's expiry time. With `QueuedInterceptor`, `onRequest` calls are serialised too, so only the first request after expiry refreshes and the rest wait for it. Keep the 401 path anyway: clocks drift and servers revoke tokens early. ## Testing it - Use a fake adapter or a mock server that returns 401 for an old token and 200 for a new one. - Fire five requests with `Future.wait` and assert that the refresh endpoint was called once and all five callers got 200. - Add a case where the refresh fails, and assert that every caller receives an error and the session is cleared.
- Why not refresh or replay with the same Dio instance that has the RefreshInterceptor?Those calls would pass through the same interceptors: the refresh would get the expired token attached, and any error on the refresh or the replay would join the error queue behind the `onError` call that is awaiting it, so neither completes. A plain `Dio` without that interceptor keeps both independent.
- What stops an infinite loop when the replayed request also returns 401?The replay runs on the plain `Dio`, which has no refresh interceptor, so the second 401 comes back to this callback as a thrown `DioException` and is passed to `handler.reject`. The caller sees the 401 and the app can end the session instead of refreshing again.
- Does QueuedInterceptor stop new requests from being sent while a refresh is running?No. Its `onRequest`, `onResponse` and `onError` queues run independently, so new requests can still go out with the old token, fail and join the error queue, where the token comparison lets them replay without another refresh.
saying these in an interview costs you the question
- A plain Interceptor already handles 401s one at a time.
- QueuedInterceptor blocks every new request until the refresh ends.
- Replay through the same Dio, since a queued interceptor cannot block itself.
- Retrying a replayed 401 forever is safe because tokens eventually refresh.
- handler.next(err) after a successful replay returns the replayed data.