Fix AuthInterceptor replay logout-on-transient-error + add tests

Address PR re-review findings:
- Only a 401 on the replay falls through to logout; other DioExceptions
  (timeout, connection drop, 5xx) now propagate so a flaky network can't
  wipe a valid session and callers see the real error.
- Skip the refresh when the failed request's bearer no longer matches the
  stored one (a concurrent refresh already rotated it) and replay directly.
- Guard _logout() with a _loggingOut flag so the auth-failed callback fires
  once per burst rather than once per concurrent caller.

Add unit tests (fake Dio adapter + mocked prefs) covering the retry guard,
single-flight refresh, and non-401 replay propagation paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AmirReza Jamali
2026-06-13 16:20:23 +03:30
parent deda384aaa
commit 231d800b47
2 changed files with 214 additions and 14 deletions
+48 -14
View File
@@ -61,6 +61,13 @@ class AuthInterceptor extends Interceptor {
/// when the refresh completes, allowing a later expiry to refresh again.
Future<String?>? _ongoingRefresh;
/// Guards against the auth-failed callback running once per concurrent caller.
/// When the shared refresh fails, every request awaiting it falls through to
/// [_logout]; only the first should fire the (possibly non-idempotent)
/// callback. Set synchronously before the first `await` so the rest of the
/// burst short-circuits, and reset afterwards so a later session can log out.
bool _loggingOut = false;
/// Refreshes the token, coalescing concurrent callers onto a single refresh.
Future<String?> _refreshTokenOnce() {
return _ongoingRefresh ??= _refreshToken().whenComplete(() {
@@ -118,7 +125,21 @@ class AuthInterceptor extends Interceptor {
// tagged so a fresh 401 on it short-circuits the block above rather than
// refreshing again. The Authorization header is intentionally not set here:
// [onRequest] re-injects the (now refreshed) bearer token on the replay.
final String? newAccessToken = await _refreshTokenOnce();
//
// Refresh-window straggler guard: a request sent with the old token can 401
// just after a concurrent refresh already finished (so [_ongoingRefresh] is
// cleared and the single-flight no longer coalesces it). If the stored token
// no longer matches the one this request was sent with, it was already
// rotated by someone else — replay with the fresh token instead of starting
// a second refresh that would needlessly rotate the just-issued token again.
final String? storedToken = _prefs.getString(PrefKeys.bearer);
final String? sentAuthHeader =
error.requestOptions.headers['Authorization'] as String?;
final bool alreadyRefreshed =
storedToken != null && sentAuthHeader != 'Bearer $storedToken';
final String? newAccessToken =
alreadyRefreshed ? storedToken : await _refreshTokenOnce();
if (newAccessToken != null) {
try {
final RequestOptions options = error.requestOptions
@@ -126,7 +147,14 @@ class AuthInterceptor extends Interceptor {
final Response<dynamic> response = await _dio.fetch(options);
return handler.resolve(response);
} on DioException catch (e) {
// Replay still failed (e.g. token revoked) fall through to logout.
// Only a fresh 401 means refreshing didn't help (token revoked, account
// disabled) — fall through to logout. Anything else (timeout, connection
// drop, 5xx) is a transient/server failure unrelated to auth: propagate
// it so a flaky network doesn't wipe a perfectly valid session, and so
// the caller sees the real error instead of the stale pre-refresh 401.
if (e.response?.statusCode != 401) {
return handler.next(e);
}
appLogger.error('❌ Retry after token refresh failed: ${e.message}');
}
}
@@ -138,18 +166,24 @@ class AuthInterceptor extends Interceptor {
}
Future<void> _logout() async {
final Future<void> Function()? callback = _onAuthFailed();
if (callback != null) {
await callback();
return;
}
if (_loggingOut) return;
_loggingOut = true;
try {
final Future<void> Function()? callback = _onAuthFailed();
if (callback != null) {
await callback();
return;
}
// Defensive fallback: if no logout/navigation callback has been wired yet,
// at least clear the stored credentials so the router's auth guard can
// redirect the user away from protected routes.
appLogger.error('⚠️ No auth-failed callback registered; clearing tokens');
await _prefs.remove(PrefKeys.bearer);
await _prefs.remove(PrefKeys.refreshToken);
await _prefs.remove(PrefKeys.customerData);
// Defensive fallback: if no logout/navigation callback has been wired yet,
// at least clear the stored credentials so the router's auth guard can
// redirect the user away from protected routes.
appLogger.error('⚠️ No auth-failed callback registered; clearing tokens');
await _prefs.remove(PrefKeys.bearer);
await _prefs.remove(PrefKeys.refreshToken);
await _prefs.remove(PrefKeys.customerData);
} finally {
_loggingOut = false;
}
}
}