Skip to content

Commit daa623a

Browse files
authored
Merge pull request #398 from DevKor-github/feat/implement-issue-390
[codex] Harden token refresh retry handling
2 parents b850d85 + 8bd3fd7 commit daa623a

2 files changed

Lines changed: 234 additions & 69 deletions

File tree

lib/core/dio/interceptors/token_interceptor.dart

Lines changed: 94 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@ import 'package:dio/dio.dart';
22
import 'package:flutter/material.dart';
33
import 'package:on_time_front/data/data_sources/token_local_data_source.dart';
44
import 'package:on_time_front/core/di/di_setup.dart';
5+
import 'package:on_time_front/domain/entities/token_entity.dart';
56
import 'package:on_time_front/domain/repositories/user_repository.dart';
67

78
class TokenInterceptor implements InterceptorsWrapper {
9+
static const _refreshTokenPath = '/refresh-token';
10+
static const _retryAfterRefreshKey = 'tokenInterceptor.retryAfterRefresh';
11+
812
final Dio dio;
913
TokenInterceptor(this.dio);
1014
final TokenLocalDataSource tokenLocalDataSource =
@@ -35,96 +39,94 @@ class TokenInterceptor implements InterceptorsWrapper {
3539

3640
@override
3741
void onError(DioException err, ErrorInterceptorHandler handler) async {
38-
final response = err.response;
39-
if (response != null &&
40-
// status code for unauthorized usually 401
41-
response.statusCode == 401 &&
42-
// refresh token call maybe fail by it self
43-
// eg: when refreshToken also is expired -> can't get new accessToken
44-
// usually server also return 401 unauthorized for this case
45-
// need to exlude it to prevent loop infinite call
46-
response.requestOptions.path != "/refresh-token") {
42+
if (_shouldRefreshToken(err)) {
43+
final response = err.response;
4744
// if hasn't not refreshing yet, let's start it
45+
_requestsNeedRetry.add((options: err.requestOptions, handler: handler));
46+
4847
if (!_isRefreshing) {
4948
_isRefreshing = true;
5049

51-
// add request (requestOptions and handler) to queue and wait to retry later
52-
_requestsNeedRetry
53-
.add((options: response.requestOptions, handler: handler));
54-
5550
// call api refresh token
5651
final isRefreshSuccess = await _refreshToken();
5752

58-
if (isRefreshSuccess) {
59-
// refresh success, loop requests need retry
60-
for (var requestNeedRetry in _requestsNeedRetry) {
61-
// don't need set new accessToken to header here, because these retry
62-
// will go through onRequest callback above (where new accessToken will be set to header)
63-
64-
// won't use await because this loop will take longer -> maybe throw: Unhandled Exception: Concurrent modification during iteration
65-
// because method _requestsNeedRetry.add() is called at the same time
66-
// final response = await dio.fetch(requestNeedRetry.options);
67-
// requestNeedRetry.handler.resolve(response);
68-
69-
dio.fetch(requestNeedRetry.options).then((response) {
70-
requestNeedRetry.handler.resolve(response);
71-
}).catchError((_) {});
72-
}
73-
74-
_requestsNeedRetry.clear();
75-
_isRefreshing = false;
76-
} else {
77-
for (final requestNeedRetry in _requestsNeedRetry) {
78-
requestNeedRetry.handler.reject(
79-
DioException(
80-
requestOptions: requestNeedRetry.options,
81-
response: response,
82-
type: err.type,
83-
error: err.error,
84-
message: err.message,
85-
),
86-
);
87-
}
88-
_requestsNeedRetry.clear();
89-
// Force a local logout when the refresh token is rejected. The full
90-
// sign-out use case may make authenticated cleanup calls, which can
91-
// deadlock while the interceptor is already refreshing.
92-
try {
93-
await getIt.get<UserRepository>().signOut();
94-
} catch (_) {
95-
await tokenLocalDataSource.deleteToken();
53+
try {
54+
if (isRefreshSuccess) {
55+
while (_requestsNeedRetry.isNotEmpty) {
56+
final requestsNeedRetry = List.of(_requestsNeedRetry);
57+
_requestsNeedRetry.clear();
58+
await _retryRequests(requestsNeedRetry);
59+
}
60+
} else {
61+
for (final requestNeedRetry in _requestsNeedRetry) {
62+
requestNeedRetry.handler.reject(
63+
DioException(
64+
requestOptions: requestNeedRetry.options,
65+
response: response,
66+
type: err.type,
67+
error: err.error,
68+
message: err.message,
69+
),
70+
);
71+
}
72+
_requestsNeedRetry.clear();
73+
// Force a local logout when the refresh token is rejected. The full
74+
// sign-out use case may make authenticated cleanup calls, which can
75+
// deadlock while the interceptor is already refreshing.
76+
await _signOutLocally();
9677
}
78+
} finally {
9779
_isRefreshing = false;
9880
}
99-
} else {
100-
// if refresh flow is processing, add this request to queue and wait to retry later
101-
_requestsNeedRetry
102-
.add((options: response.requestOptions, handler: handler));
10381
}
10482
} else {
10583
// ignore other error is not unauthorized
10684
return handler.next(err);
10785
}
10886
}
10987

88+
bool _shouldRefreshToken(DioException err) {
89+
final response = err.response;
90+
if (response?.statusCode != 401) {
91+
return false;
92+
}
93+
94+
final requestOptions = err.requestOptions;
95+
return requestOptions.path != _refreshTokenPath &&
96+
requestOptions.extra[_retryAfterRefreshKey] != true;
97+
}
98+
11099
Future<bool> _refreshToken() async {
111100
try {
112101
final tokenEntity = await tokenLocalDataSource.getToken();
113102
final refreshToken = tokenEntity.refreshToken;
114103

115104
final res = await dio.get(
116-
'/refresh-token',
105+
_refreshTokenPath,
117106
options: Options(
107+
extra: {
108+
_retryAfterRefreshKey: true,
109+
},
118110
headers: {
119111
'Authorization-refresh': 'Bearer $refreshToken',
120112
},
121113
),
122114
);
123115
if (res.statusCode == 200) {
124116
debugPrint("token refreshing success");
125-
final authToken = res.headers['authorization']![0];
126-
// save new access + refresh token to your local storage for using later
127-
await tokenLocalDataSource.storeAuthToken(authToken);
117+
final accessToken = res.headers.value('authorization');
118+
final refreshedRefreshToken =
119+
res.headers.value('authorization-refresh');
120+
if (accessToken == null || refreshedRefreshToken == null) {
121+
throw StateError(
122+
'Refresh response must include authorization and authorization-refresh headers');
123+
}
124+
await tokenLocalDataSource.storeTokens(
125+
TokenEntity(
126+
accessToken: accessToken,
127+
refreshToken: refreshedRefreshToken,
128+
),
129+
);
128130
return true;
129131
} else {
130132
debugPrint("refresh token fail ${res.statusMessage ?? res.toString()}");
@@ -136,6 +138,39 @@ class TokenInterceptor implements InterceptorsWrapper {
136138
}
137139
}
138140

141+
Future<void> _retryRequests(
142+
List<({RequestOptions options, ErrorInterceptorHandler handler})> requests,
143+
) async {
144+
await Future.wait(
145+
requests.map((requestNeedRetry) async {
146+
final options = requestNeedRetry.options;
147+
options.extra[_retryAfterRefreshKey] = true;
148+
149+
try {
150+
final response = await dio.fetch(options);
151+
requestNeedRetry.handler.resolve(response);
152+
} on DioException catch (error) {
153+
requestNeedRetry.handler.reject(error);
154+
} catch (error) {
155+
requestNeedRetry.handler.reject(
156+
DioException(
157+
requestOptions: options,
158+
error: error,
159+
),
160+
);
161+
}
162+
}),
163+
);
164+
}
165+
166+
Future<void> _signOutLocally() async {
167+
try {
168+
await getIt.get<UserRepository>().signOut();
169+
} catch (_) {
170+
await tokenLocalDataSource.deleteToken();
171+
}
172+
}
173+
139174
@override
140175
void onResponse(Response response, ResponseInterceptorHandler handler) {
141176
handler.next(response);

0 commit comments

Comments
 (0)