Skip to content

Commit a9c25ef

Browse files
committed
fix: serialize token refresh across Dio instances
1 parent 616fb54 commit a9c25ef

4 files changed

Lines changed: 78 additions & 35 deletions

File tree

lib/core/dio/app_dio.dart

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,24 @@ import 'package:on_time_front/core/dio/interceptors/logger_interceptor.dart';
66
import 'package:on_time_front/core/dio/interceptors/token_interceptor.dart';
77
import 'package:on_time_front/core/dio/transformers/logging_transformer.dart';
88

9-
@Injectable(as: Dio)
9+
@LazySingleton(as: Dio)
1010
class AppDio with DioMixin implements Dio {
1111
AppDio() {
1212
httpClientAdapter = getAdapter();
1313
transformer = LoggingTransformer(inner: BackgroundTransformer());
1414
options = BaseOptions(
15-
contentType: Headers.jsonContentType,
16-
baseUrl: EnvironmentVariable.restApiUrl,
17-
connectTimeout: const Duration(milliseconds: 30000),
18-
receiveTimeout: const Duration(milliseconds: 30000),
19-
sendTimeout: const Duration(milliseconds: 30000),
20-
receiveDataWhenStatusError: true,
21-
followRedirects: false,
22-
headers: {
23-
"Accept": "application/json",
24-
"Authorization": EnvironmentVariable.restAuthToken
25-
});
15+
contentType: Headers.jsonContentType,
16+
baseUrl: EnvironmentVariable.restApiUrl,
17+
connectTimeout: const Duration(milliseconds: 30000),
18+
receiveTimeout: const Duration(milliseconds: 30000),
19+
sendTimeout: const Duration(milliseconds: 30000),
20+
receiveDataWhenStatusError: true,
21+
followRedirects: false,
22+
headers: {
23+
"Accept": "application/json",
24+
"Authorization": EnvironmentVariable.restAuthToken,
25+
},
26+
);
2627

2728
interceptors.addAll([TokenInterceptor(this), LoggerInterceptor()]);
2829
}

lib/core/dio/interceptors/token_interceptor.dart

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

15+
static bool _isRefreshing = false;
16+
static final _requestsNeedRetry = <_RequestNeedingRetry>[];
17+
1518
final Dio dio;
1619
TokenInterceptor(this.dio);
1720
final TokenLocalDataSource tokenLocalDataSource = getIt
1821
.get<TokenLocalDataSource>();
1922

20-
// when accessToken is expired & having multiple requests call
21-
// this variable to lock others request to make sure only trigger call refresh token 01 times
22-
// to prevent duplicate refresh call
23-
bool _isRefreshing = false;
24-
25-
// when having multiple requests call at the same time, you need to store them in a list
26-
// then loop this list to retry every request later, after call refresh token success
27-
final _requestsNeedRetry =
28-
<({RequestOptions options, ErrorInterceptorHandler handler})>[];
29-
3023
@override
3124
void onRequest(
3225
RequestOptions options,
@@ -58,7 +51,13 @@ class TokenInterceptor implements InterceptorsWrapper {
5851
if (_shouldRefreshToken(err)) {
5952
final response = err.response;
6053
// if hasn't not refreshing yet, let's start it
61-
_requestsNeedRetry.add((options: err.requestOptions, handler: handler));
54+
_requestsNeedRetry.add(
55+
_RequestNeedingRetry(
56+
dio: dio,
57+
options: err.requestOptions,
58+
handler: handler,
59+
),
60+
);
6261

6362
if (!_isRefreshing) {
6463
_isRefreshing = true;
@@ -163,16 +162,14 @@ class TokenInterceptor implements InterceptorsWrapper {
163162
}
164163
}
165164

166-
Future<void> _retryRequests(
167-
List<({RequestOptions options, ErrorInterceptorHandler handler})> requests,
168-
) async {
165+
Future<void> _retryRequests(List<_RequestNeedingRetry> requests) async {
169166
await Future.wait(
170167
requests.map((requestNeedRetry) async {
171168
final options = requestNeedRetry.options;
172169
options.extra[_retryAfterRefreshKey] = true;
173170

174171
try {
175-
final response = await dio.fetch(options);
172+
final response = await requestNeedRetry.dio.fetch(options);
176173
requestNeedRetry.handler.resolve(response);
177174
} on DioException catch (error) {
178175
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: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,7 @@ void main() {
2929
getIt.registerSingleton<UserRepository>(userRepository);
3030

3131
adapter = _TokenRefreshAdapter();
32-
dio = Dio(
33-
BaseOptions(
34-
baseUrl: 'https://example.com',
35-
receiveDataWhenStatusError: true,
36-
),
37-
)..httpClientAdapter = adapter;
38-
dio.interceptors.add(TokenInterceptor(dio));
32+
dio = _dioWithTokenInterceptor(adapter);
3933
});
4034

4135
tearDown(() async {
@@ -89,6 +83,33 @@ void main() {
8983
);
9084
});
9185

86+
test('shares refresh coordination across interceptor instances', () async {
87+
final refreshCompleter = Completer<void>();
88+
adapter = _TokenRefreshAdapter(refreshCompleter: refreshCompleter);
89+
final firstDio = _dioWithTokenInterceptor(adapter);
90+
final secondDio = _dioWithTokenInterceptor(adapter);
91+
92+
final firstRequest = firstDio.get<String>('/protected/one');
93+
await _flushMicrotasks();
94+
final secondRequest = secondDio.get<String>('/protected/two');
95+
await _flushMicrotasks();
96+
97+
expect(adapter.refreshRequests, 1);
98+
refreshCompleter.complete();
99+
100+
final responses = await Future.wait([firstRequest, secondRequest]);
101+
102+
expect(responses.map((response) => response.statusCode), everyElement(200));
103+
expect(adapter.refreshRequests, 1);
104+
expect(tokenLocalDataSource.storeTokensCallCount, 1);
105+
expect(
106+
adapter.protectedAuthorizationHeaders.where(
107+
(header) => header == 'Bearer new-access-token',
108+
),
109+
hasLength(2),
110+
);
111+
});
112+
92113
test('rejects original request when retry after refresh fails', () async {
93114
adapter = _TokenRefreshAdapter(retryStatusCode: 500);
94115
dio.httpClientAdapter = adapter;
@@ -178,6 +199,17 @@ void main() {
178199
);
179200
}
180201

202+
Dio _dioWithTokenInterceptor(HttpClientAdapter adapter) {
203+
final dio = Dio(
204+
BaseOptions(
205+
baseUrl: 'https://example.com',
206+
receiveDataWhenStatusError: true,
207+
),
208+
)..httpClientAdapter = adapter;
209+
dio.interceptors.add(TokenInterceptor(dio));
210+
return dio;
211+
}
212+
181213
Future<void> _flushMicrotasks() async {
182214
for (var i = 0; i < 5; i++) {
183215
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)