Skip to content

Commit 6f4497e

Browse files
committed
fix: serialize token refresh across Dio instances
1 parent 99c6e5e commit 6f4497e

4 files changed

Lines changed: 87 additions & 29 deletions

File tree

lib/core/dio/app_dio.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import 'package:on_time_front/core/dio/interceptors/token_session_invalidator.da
88
import 'package:on_time_front/core/dio/transformers/logging_transformer.dart';
99
import 'package:on_time_front/data/data_sources/token_local_data_source.dart';
1010

11-
@Injectable(as: Dio)
11+
@LazySingleton(as: Dio)
1212
class AppDio with DioMixin implements Dio {
1313
AppDio(
1414
TokenLocalDataSource tokenLocalDataSource,

lib/core/dio/interceptors/token_interceptor.dart

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ class TokenInterceptor implements InterceptorsWrapper {
1111
static const _tokenUnavailableMessage =
1212
'Authentication token is unavailable for a protected request';
1313

14+
static bool _isRefreshing = false;
15+
static final _requestsNeedRetry = <_RequestNeedingRetry>[];
16+
1417
final Dio dio;
1518
final TokenLocalDataSource tokenLocalDataSource;
1619
final TokenSessionInvalidator _sessionInvalidator;
@@ -21,16 +24,6 @@ class TokenInterceptor implements InterceptorsWrapper {
2124
required TokenSessionInvalidator sessionInvalidator,
2225
}) : _sessionInvalidator = sessionInvalidator;
2326

24-
// when accessToken is expired & having multiple requests call
25-
// this variable to lock others request to make sure only trigger call refresh token 01 times
26-
// to prevent duplicate refresh call
27-
bool _isRefreshing = false;
28-
29-
// when having multiple requests call at the same time, you need to store them in a list
30-
// then loop this list to retry every request later, after call refresh token success
31-
final _requestsNeedRetry =
32-
<({RequestOptions options, ErrorInterceptorHandler handler})>[];
33-
3427
@override
3528
void onRequest(
3629
RequestOptions options,
@@ -62,7 +55,13 @@ class TokenInterceptor implements InterceptorsWrapper {
6255
if (_shouldRefreshToken(err)) {
6356
final response = err.response;
6457
// if hasn't not refreshing yet, let's start it
65-
_requestsNeedRetry.add((options: err.requestOptions, handler: handler));
58+
_requestsNeedRetry.add(
59+
_RequestNeedingRetry(
60+
dio: dio,
61+
options: err.requestOptions,
62+
handler: handler,
63+
),
64+
);
6665

6766
if (!_isRefreshing) {
6867
_isRefreshing = true;
@@ -167,16 +166,14 @@ class TokenInterceptor implements InterceptorsWrapper {
167166
}
168167
}
169168

170-
Future<void> _retryRequests(
171-
List<({RequestOptions options, ErrorInterceptorHandler handler})> requests,
172-
) async {
169+
Future<void> _retryRequests(List<_RequestNeedingRetry> requests) async {
173170
await Future.wait(
174171
requests.map((requestNeedRetry) async {
175172
final options = requestNeedRetry.options;
176173
options.extra[_retryAfterRefreshKey] = true;
177174

178175
try {
179-
final response = await dio.fetch(options);
176+
final response = await requestNeedRetry.dio.fetch(options);
180177
requestNeedRetry.handler.resolve(response);
181178
} on DioException catch (error) {
182179
requestNeedRetry.handler.reject(error);
@@ -198,3 +195,15 @@ class TokenInterceptor implements InterceptorsWrapper {
198195
handler.next(response);
199196
}
200197
}
198+
199+
class _RequestNeedingRetry {
200+
const _RequestNeedingRetry({
201+
required this.dio,
202+
required this.options,
203+
required this.handler,
204+
});
205+
206+
final Dio dio;
207+
final RequestOptions options;
208+
final ErrorInterceptorHandler handler;
209+
}

test/core/dio/interceptors/token_interceptor_test.dart

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,10 @@ void main() {
2121
sessionInvalidator = _FakeTokenSessionInvalidator(tokenLocalDataSource);
2222

2323
adapter = _TokenRefreshAdapter();
24-
dio = Dio(
25-
BaseOptions(
26-
baseUrl: 'https://example.com',
27-
receiveDataWhenStatusError: true,
28-
),
29-
)..httpClientAdapter = adapter;
30-
dio.interceptors.add(
31-
TokenInterceptor(
32-
dio,
33-
tokenLocalDataSource: tokenLocalDataSource,
34-
sessionInvalidator: sessionInvalidator,
35-
),
24+
dio = _dioWithTokenInterceptor(
25+
adapter,
26+
tokenLocalDataSource: tokenLocalDataSource,
27+
sessionInvalidator: sessionInvalidator,
3628
);
3729
});
3830

@@ -122,6 +114,41 @@ void main() {
122114
);
123115
});
124116

117+
test('shares refresh coordination across interceptor instances', () async {
118+
final refreshCompleter = Completer<void>();
119+
adapter = _TokenRefreshAdapter(refreshCompleter: refreshCompleter);
120+
final firstDio = _dioWithTokenInterceptor(
121+
adapter,
122+
tokenLocalDataSource: tokenLocalDataSource,
123+
sessionInvalidator: sessionInvalidator,
124+
);
125+
final secondDio = _dioWithTokenInterceptor(
126+
adapter,
127+
tokenLocalDataSource: tokenLocalDataSource,
128+
sessionInvalidator: sessionInvalidator,
129+
);
130+
131+
final firstRequest = firstDio.get<String>('/protected/one');
132+
await _flushMicrotasks();
133+
final secondRequest = secondDio.get<String>('/protected/two');
134+
await _flushMicrotasks();
135+
136+
expect(adapter.refreshRequests, 1);
137+
refreshCompleter.complete();
138+
139+
final responses = await Future.wait([firstRequest, secondRequest]);
140+
141+
expect(responses.map((response) => response.statusCode), everyElement(200));
142+
expect(adapter.refreshRequests, 1);
143+
expect(tokenLocalDataSource.storeTokensCallCount, 1);
144+
expect(
145+
adapter.protectedAuthorizationHeaders.where(
146+
(header) => header == 'Bearer new-access-token',
147+
),
148+
hasLength(2),
149+
);
150+
});
151+
125152
test('rejects original request when retry after refresh fails', () async {
126153
adapter = _TokenRefreshAdapter(retryStatusCode: 500);
127154
dio.httpClientAdapter = adapter;
@@ -211,6 +238,27 @@ void main() {
211238
);
212239
}
213240

241+
Dio _dioWithTokenInterceptor(
242+
HttpClientAdapter adapter, {
243+
required TokenLocalDataSource tokenLocalDataSource,
244+
required TokenSessionInvalidator sessionInvalidator,
245+
}) {
246+
final dio = Dio(
247+
BaseOptions(
248+
baseUrl: 'https://example.com',
249+
receiveDataWhenStatusError: true,
250+
),
251+
)..httpClientAdapter = adapter;
252+
dio.interceptors.add(
253+
TokenInterceptor(
254+
dio,
255+
tokenLocalDataSource: tokenLocalDataSource,
256+
sessionInvalidator: sessionInvalidator,
257+
),
258+
);
259+
return dio;
260+
}
261+
214262
Future<void> _flushMicrotasks() async {
215263
for (var i = 0; i < 5; i++) {
216264
await Future<void>.delayed(Duration.zero);

test/presentation/home/components/month_calendar_test.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ void main() {
1515
tester,
1616
) async {
1717
DateTime? selected;
18-
final targetDate = DateTime.now().add(const Duration(days: 2));
18+
final now = DateTime.now();
19+
final targetDate = DateTime(now.year, now.month, 15);
1920

2021
await tester.pumpWidget(
2122
_TestApp(

0 commit comments

Comments
 (0)