From 6f4497e7a273ca5669c3fdb680cee87cca40edcf Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Mon, 29 Jun 2026 01:50:18 +0900 Subject: [PATCH] fix: serialize token refresh across Dio instances --- lib/core/dio/app_dio.dart | 2 +- .../dio/interceptors/token_interceptor.dart | 39 ++++++---- .../interceptors/token_interceptor_test.dart | 72 +++++++++++++++---- .../home/components/month_calendar_test.dart | 3 +- 4 files changed, 87 insertions(+), 29 deletions(-) diff --git a/lib/core/dio/app_dio.dart b/lib/core/dio/app_dio.dart index f02ea1a2..5af19696 100644 --- a/lib/core/dio/app_dio.dart +++ b/lib/core/dio/app_dio.dart @@ -8,7 +8,7 @@ import 'package:on_time_front/core/dio/interceptors/token_session_invalidator.da import 'package:on_time_front/core/dio/transformers/logging_transformer.dart'; import 'package:on_time_front/data/data_sources/token_local_data_source.dart'; -@Injectable(as: Dio) +@LazySingleton(as: Dio) class AppDio with DioMixin implements Dio { AppDio( TokenLocalDataSource tokenLocalDataSource, diff --git a/lib/core/dio/interceptors/token_interceptor.dart b/lib/core/dio/interceptors/token_interceptor.dart index 7b3df3a6..8a35b07d 100644 --- a/lib/core/dio/interceptors/token_interceptor.dart +++ b/lib/core/dio/interceptors/token_interceptor.dart @@ -11,6 +11,9 @@ class TokenInterceptor implements InterceptorsWrapper { static const _tokenUnavailableMessage = 'Authentication token is unavailable for a protected request'; + static bool _isRefreshing = false; + static final _requestsNeedRetry = <_RequestNeedingRetry>[]; + final Dio dio; final TokenLocalDataSource tokenLocalDataSource; final TokenSessionInvalidator _sessionInvalidator; @@ -21,16 +24,6 @@ class TokenInterceptor implements InterceptorsWrapper { required TokenSessionInvalidator sessionInvalidator, }) : _sessionInvalidator = sessionInvalidator; - // when accessToken is expired & having multiple requests call - // this variable to lock others request to make sure only trigger call refresh token 01 times - // to prevent duplicate refresh call - bool _isRefreshing = false; - - // when having multiple requests call at the same time, you need to store them in a list - // then loop this list to retry every request later, after call refresh token success - final _requestsNeedRetry = - <({RequestOptions options, ErrorInterceptorHandler handler})>[]; - @override void onRequest( RequestOptions options, @@ -62,7 +55,13 @@ class TokenInterceptor implements InterceptorsWrapper { if (_shouldRefreshToken(err)) { final response = err.response; // if hasn't not refreshing yet, let's start it - _requestsNeedRetry.add((options: err.requestOptions, handler: handler)); + _requestsNeedRetry.add( + _RequestNeedingRetry( + dio: dio, + options: err.requestOptions, + handler: handler, + ), + ); if (!_isRefreshing) { _isRefreshing = true; @@ -167,16 +166,14 @@ class TokenInterceptor implements InterceptorsWrapper { } } - Future _retryRequests( - List<({RequestOptions options, ErrorInterceptorHandler handler})> requests, - ) async { + Future _retryRequests(List<_RequestNeedingRetry> requests) async { await Future.wait( requests.map((requestNeedRetry) async { final options = requestNeedRetry.options; options.extra[_retryAfterRefreshKey] = true; try { - final response = await dio.fetch(options); + final response = await requestNeedRetry.dio.fetch(options); requestNeedRetry.handler.resolve(response); } on DioException catch (error) { requestNeedRetry.handler.reject(error); @@ -198,3 +195,15 @@ class TokenInterceptor implements InterceptorsWrapper { handler.next(response); } } + +class _RequestNeedingRetry { + const _RequestNeedingRetry({ + required this.dio, + required this.options, + required this.handler, + }); + + final Dio dio; + final RequestOptions options; + final ErrorInterceptorHandler handler; +} diff --git a/test/core/dio/interceptors/token_interceptor_test.dart b/test/core/dio/interceptors/token_interceptor_test.dart index 40eb6bb3..41750546 100644 --- a/test/core/dio/interceptors/token_interceptor_test.dart +++ b/test/core/dio/interceptors/token_interceptor_test.dart @@ -21,18 +21,10 @@ void main() { sessionInvalidator = _FakeTokenSessionInvalidator(tokenLocalDataSource); adapter = _TokenRefreshAdapter(); - dio = Dio( - BaseOptions( - baseUrl: 'https://example.com', - receiveDataWhenStatusError: true, - ), - )..httpClientAdapter = adapter; - dio.interceptors.add( - TokenInterceptor( - dio, - tokenLocalDataSource: tokenLocalDataSource, - sessionInvalidator: sessionInvalidator, - ), + dio = _dioWithTokenInterceptor( + adapter, + tokenLocalDataSource: tokenLocalDataSource, + sessionInvalidator: sessionInvalidator, ); }); @@ -122,6 +114,41 @@ void main() { ); }); + test('shares refresh coordination across interceptor instances', () async { + final refreshCompleter = Completer(); + adapter = _TokenRefreshAdapter(refreshCompleter: refreshCompleter); + final firstDio = _dioWithTokenInterceptor( + adapter, + tokenLocalDataSource: tokenLocalDataSource, + sessionInvalidator: sessionInvalidator, + ); + final secondDio = _dioWithTokenInterceptor( + adapter, + tokenLocalDataSource: tokenLocalDataSource, + sessionInvalidator: sessionInvalidator, + ); + + final firstRequest = firstDio.get('/protected/one'); + await _flushMicrotasks(); + final secondRequest = secondDio.get('/protected/two'); + await _flushMicrotasks(); + + expect(adapter.refreshRequests, 1); + refreshCompleter.complete(); + + final responses = await Future.wait([firstRequest, secondRequest]); + + expect(responses.map((response) => response.statusCode), everyElement(200)); + expect(adapter.refreshRequests, 1); + expect(tokenLocalDataSource.storeTokensCallCount, 1); + expect( + adapter.protectedAuthorizationHeaders.where( + (header) => header == 'Bearer new-access-token', + ), + hasLength(2), + ); + }); + test('rejects original request when retry after refresh fails', () async { adapter = _TokenRefreshAdapter(retryStatusCode: 500); dio.httpClientAdapter = adapter; @@ -211,6 +238,27 @@ void main() { ); } +Dio _dioWithTokenInterceptor( + HttpClientAdapter adapter, { + required TokenLocalDataSource tokenLocalDataSource, + required TokenSessionInvalidator sessionInvalidator, +}) { + final dio = Dio( + BaseOptions( + baseUrl: 'https://example.com', + receiveDataWhenStatusError: true, + ), + )..httpClientAdapter = adapter; + dio.interceptors.add( + TokenInterceptor( + dio, + tokenLocalDataSource: tokenLocalDataSource, + sessionInvalidator: sessionInvalidator, + ), + ); + return dio; +} + Future _flushMicrotasks() async { for (var i = 0; i < 5; i++) { await Future.delayed(Duration.zero); diff --git a/test/presentation/home/components/month_calendar_test.dart b/test/presentation/home/components/month_calendar_test.dart index 68c5c45e..ddfa371f 100644 --- a/test/presentation/home/components/month_calendar_test.dart +++ b/test/presentation/home/components/month_calendar_test.dart @@ -15,7 +15,8 @@ void main() { tester, ) async { DateTime? selected; - final targetDate = DateTime.now().add(const Duration(days: 2)); + final now = DateTime.now(); + final targetDate = DateTime(now.year, now.month, 15); await tester.pumpWidget( _TestApp(