diff --git a/apps/lib/core/api/api_interceptor.dart b/apps/lib/core/api/api_interceptor.dart index e921ac1..6cd164c 100644 --- a/apps/lib/core/api/api_interceptor.dart +++ b/apps/lib/core/api/api_interceptor.dart @@ -4,11 +4,18 @@ import '../storage/token_storage.dart'; class ApiInterceptor extends Interceptor { final TokenStorage tokenStorage; final Dio dio; + final Duration refreshFailureCooldown; Future Function()? onTokenRefresh; + Future? _refreshFuture; + 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, }); @@ -26,22 +33,69 @@ class ApiInterceptor extends Interceptor { @override void onError(DioException err, ErrorInterceptorHandler handler) async { - if (err.response?.statusCode == 401 && onTokenRefresh != null) { - final refreshed = await onTokenRefresh!(); + final requestOptions = err.requestOptions; + if (err.response?.statusCode == 401 && + onTokenRefresh != null && + !_shouldSkipRefresh(requestOptions)) { + final refreshed = await _refreshTokenSingleflight(); if (refreshed) { final token = await tokenStorage.getAccessToken(); if (token != null) { - err.requestOptions.headers['Authorization'] = 'Bearer $token'; + 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(err.requestOptions); + final response = await dio.fetch(retryOptions); handler.resolve(response); return; } on DioException { - // Retry failed, proceed with original error + // Retry failed, proceed with original error. } } } } handler.next(err); } + + 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; + }); + } } diff --git a/apps/test/core/api/api_interceptor_test.dart b/apps/test/core/api/api_interceptor_test.dart new file mode 100644 index 0000000..e56c27f --- /dev/null +++ b/apps/test/core/api/api_interceptor_test.dart @@ -0,0 +1,112 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:social_app/core/api/api_interceptor.dart'; +import 'package:social_app/core/storage/token_storage.dart'; + +class MockTokenStorage extends Mock implements TokenStorage {} + +class MockErrorInterceptorHandler extends Mock + implements ErrorInterceptorHandler {} + +void main() { + late MockTokenStorage tokenStorage; + late MockErrorInterceptorHandler handler; + late ApiInterceptor interceptor; + + setUpAll(() { + registerFallbackValue( + DioException(requestOptions: RequestOptions(path: '/fallback')), + ); + registerFallbackValue( + Response(requestOptions: RequestOptions(path: '/fallback')), + ); + }); + + setUp(() { + tokenStorage = MockTokenStorage(); + handler = MockErrorInterceptorHandler(); + interceptor = ApiInterceptor( + tokenStorage: tokenStorage, + dio: Dio(), + refreshFailureCooldown: const Duration(milliseconds: 80), + ); + when(() => handler.next(any())).thenReturn(null); + when(() => handler.resolve(any())).thenReturn(null); + when(() => tokenStorage.getAccessToken()).thenAnswer((_) async => null); + }); + + DioException _unauthorized(String path) { + final requestOptions = RequestOptions(path: path); + return DioException( + requestOptions: requestOptions, + response: Response( + requestOptions: requestOptions, + statusCode: 401, + ), + type: DioExceptionType.badResponse, + ); + } + + test('401并发请求仅触发一次refresh', () async { + var refreshCalls = 0; + interceptor.onTokenRefresh = () async { + refreshCalls += 1; + await Future.delayed(const Duration(milliseconds: 20)); + return false; + }; + + interceptor.onError(_unauthorized('/api/v1/agent/history'), handler); + interceptor.onError(_unauthorized('/api/v1/agent/history'), handler); + await Future.delayed(const Duration(milliseconds: 60)); + + expect(refreshCalls, 1); + }); + + test('refresh接口401不应再次触发refresh', () async { + var refreshCalls = 0; + interceptor.onTokenRefresh = () async { + refreshCalls += 1; + return false; + }; + + interceptor.onError( + _unauthorized('/api/v1/auth/sessions/refresh'), + handler, + ); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(refreshCalls, 0); + }); + + test('refresh接口带query时401也不应再次触发refresh', () async { + var refreshCalls = 0; + interceptor.onTokenRefresh = () async { + refreshCalls += 1; + return false; + }; + + interceptor.onError( + _unauthorized('/api/v1/auth/sessions/refresh?source=boot'), + handler, + ); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(refreshCalls, 0); + }); + + test('refresh失败冷却期内不应重复触发refresh', () async { + var refreshCalls = 0; + interceptor.onTokenRefresh = () async { + refreshCalls += 1; + return false; + }; + + interceptor.onError(_unauthorized('/api/v1/agent/history'), handler); + await Future.delayed(const Duration(milliseconds: 20)); + interceptor.onError(_unauthorized('/api/v1/agent/history'), handler); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(refreshCalls, 1); + }); +}