import 'package:dio/dio.dart'; import '../storage/token_storage.dart'; class ApiInterceptor extends Interceptor { final TokenStorage tokenStorage; final Dio dio; final Duration refreshFailureCooldown; Future Function()? onTokenRefresh; Future Function()? onAuthFailure; Future? _refreshFuture; Future? _authFailureFuture; DateTime? _refreshBlockedUntil; static const _retriedRequestKey = '_auth_retry_once'; static const _refreshPathSuffix = '/api/v1/auth/sessions/refresh'; ApiInterceptor({ required this.tokenStorage, required this.dio, this.refreshFailureCooldown = const Duration(seconds: 5), this.onTokenRefresh, }); @override void onRequest( RequestOptions options, RequestInterceptorHandler handler, ) async { final token = await tokenStorage.getAccessToken(); if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } handler.next(options); } @override void onError(DioException err, ErrorInterceptorHandler handler) async { final requestOptions = err.requestOptions; final isUnauthorized = err.response?.statusCode == 401; final shouldHandleUnauthorized = isUnauthorized && _isAuthenticatedRequest(requestOptions); if (err.response?.statusCode == 401 && onTokenRefresh != null && !_shouldSkipRefresh(requestOptions)) { final refreshed = await _refreshTokenSingleflight(); if (refreshed) { final token = await tokenStorage.getAccessToken(); if (token != null) { final retryHeaders = Map.from(requestOptions.headers) ..['Authorization'] = 'Bearer $token'; final retryExtra = Map.from(requestOptions.extra) ..[_retriedRequestKey] = true; final retryOptions = requestOptions.copyWith( headers: retryHeaders, extra: retryExtra, ); try { final response = await dio.fetch(retryOptions); handler.resolve(response); return; } on DioException { // Retry failed, proceed with original error. } } } else if (shouldHandleUnauthorized) { await _notifyAuthFailureSingleflight(); } } else if (shouldHandleUnauthorized && _shouldSkipRefresh(requestOptions)) { await _notifyAuthFailureSingleflight(); } handler.next(err); } bool _isAuthenticatedRequest(RequestOptions options) { return options.headers['Authorization'] != null; } Future _notifyAuthFailureSingleflight() { final existing = _authFailureFuture; if (existing != null) { return existing; } final callback = onAuthFailure; if (callback == null) { return Future.value(); } final future = callback().whenComplete(() { _authFailureFuture = null; }); _authFailureFuture = future; return future; } bool _shouldSkipRefresh(RequestOptions options) { final blockedUntil = _refreshBlockedUntil; if (blockedUntil != null && DateTime.now().isBefore(blockedUntil)) { return true; } return _normalizePath(options.path) == _refreshPathSuffix || options.extra[_retriedRequestKey] == true; } String _normalizePath(String rawPath) { final parsed = Uri.tryParse(rawPath); if (parsed == null) { return rawPath.replaceFirst(RegExp(r'/+$'), ''); } final normalized = parsed.path.replaceFirst(RegExp(r'/+$'), ''); return normalized.isEmpty ? '/' : normalized; } Future _refreshTokenSingleflight() { final inflight = _refreshFuture; if (inflight != null) { return inflight; } final future = onTokenRefresh!().catchError((_) => false).whenComplete(() { _refreshFuture = null; }); _refreshFuture = future; return future.then((refreshed) { if (refreshed) { _refreshBlockedUntil = null; } else { _refreshBlockedUntil = DateTime.now().add(refreshFailureCooldown); } return refreshed; }); } void reset() { _refreshFuture = null; _authFailureFuture = null; _refreshBlockedUntil = null; } }