Skip to content

Commit d02676d

Browse files
committed
Retry with separated Dio to avoid Deadlock in error queue
1 parent 16131e0 commit d02676d

File tree

2 files changed

+260
-28
lines changed

2 files changed

+260
-28
lines changed

lib/features/login/data/network/interceptors/authorization_interceptors.dart

Lines changed: 48 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -179,36 +179,18 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper {
179179
}
180180

181181
if (isRetryRequest) {
182-
if (extraInRequest.containsKey(FileUploader.uploadAttachmentExtraKey)) {
183-
log('AuthorizationInterceptors::onError: Retry upload request with TokenId = ${_token?.tokenIdHash}');
184-
final uploadExtra = extraInRequest[FileUploader.uploadAttachmentExtraKey];
185-
186-
requestOptions.headers[HttpHeaders.authorizationHeader] = _getTokenAsBearerHeader(_token!.token);
187-
// Mark as attempted to prevent infinite retry loops
188-
requestOptions.extra[_refreshAttemptedKey] = true;
189-
190-
final newOptions = Options(
191-
method: requestOptions.method,
192-
headers: requestOptions.headers,
193-
extra: requestOptions.extra,
182+
try {
183+
final response = await _retryRequest(requestOptions, extraInRequest);
184+
return handler.resolve(response);
185+
} catch (retryError) {
186+
logError(
187+
'AuthorizationInterceptors::onError: '
188+
'Retry failed with error=$retryError',
194189
);
195-
196-
final response = await _dio.request(
197-
requestOptions.path,
198-
data: _getDataUploadRequest(uploadExtra),
199-
queryParameters: requestOptions.queryParameters,
200-
options: newOptions,
190+
return super.onError(
191+
err.copyWith(error: retryError),
192+
handler,
201193
);
202-
203-
return handler.resolve(response);
204-
} else {
205-
log('AuthorizationInterceptors::onError: Retry request with TokenId = ${_token?.tokenIdHash}');
206-
requestOptions.headers[HttpHeaders.authorizationHeader] = _getTokenAsBearerHeader(_token!.token);
207-
// Mark as attempted to prevent infinite retry loops
208-
requestOptions.extra[_refreshAttemptedKey] = true;
209-
210-
final response = await _dio.fetch(requestOptions);
211-
return handler.resolve(response);
212194
}
213195
} else {
214196
return super.onError(err, handler);
@@ -379,6 +361,44 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper {
379361
return _invokeRefreshTokenFromServer();
380362
}
381363

364+
Future<Response> _retryRequest(
365+
RequestOptions requestOptions,
366+
Map<String, dynamic> extraInRequest,
367+
) {
368+
requestOptions.headers[HttpHeaders.authorizationHeader] =
369+
_getTokenAsBearerHeader(_token!.token);
370+
requestOptions.extra[_refreshAttemptedKey] = true;
371+
372+
final retryDio = _createRetryDio();
373+
374+
if (extraInRequest.containsKey(FileUploader.uploadAttachmentExtraKey)) {
375+
log('AuthorizationInterceptors::_retryRequest: '
376+
'Retry upload request with TokenId = ${_token?.tokenIdHash}');
377+
final uploadExtra =
378+
extraInRequest[FileUploader.uploadAttachmentExtraKey];
379+
380+
return retryDio.request(
381+
requestOptions.path,
382+
data: _getDataUploadRequest(uploadExtra),
383+
queryParameters: requestOptions.queryParameters,
384+
options: Options(
385+
method: requestOptions.method,
386+
headers: requestOptions.headers,
387+
extra: requestOptions.extra,
388+
),
389+
);
390+
} else {
391+
log('AuthorizationInterceptors::_retryRequest: '
392+
'Retry request with TokenId = ${_token?.tokenIdHash}');
393+
return retryDio.fetch(requestOptions);
394+
}
395+
}
396+
397+
/// Creates a separate Dio instance without interceptors for retry requests.
398+
/// This avoids deadlock when retrying inside [onError] of [QueuedInterceptorsWrapper].
399+
Dio _createRetryDio() => Dio(_dio.options)
400+
..httpClientAdapter = _dio.httpClientAdapter;
401+
382402
void clear() {
383403
_authorization = null;
384404
_token = null;

test/features/interceptor/authorization_interceptor_test.dart

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1335,6 +1335,218 @@ void main() {
13351335
);
13361336
});
13371337

1338+
// ============================================================
1339+
// onError: retry fails (separate Dio error handling)
1340+
// ============================================================
1341+
group('onError: retry fails (separate Dio error handling)', () {
1342+
test(
1343+
'WHEN refresh succeeds but retry with new token gets 401\n'
1344+
'THEN error is propagated via retry catch block\n'
1345+
'AND no deadlock occurs',
1346+
() async {
1347+
authorizationInterceptors.setTokenAndAuthorityOidc(
1348+
newToken: OIDCFixtures.tokenOidcExpiredTime,
1349+
newConfig: OIDCFixtures.oidcConfiguration,
1350+
);
1351+
1352+
// Old token → 401
1353+
dioAdapter.onPost(
1354+
baseUrl,
1355+
(server) => server.throws(responseStatusCode401, makeDioError401()),
1356+
headers: {
1357+
HttpHeaders.authorizationHeader:
1358+
'Bearer ${OIDCFixtures.tokenOidcExpiredTime.token}',
1359+
},
1360+
);
1361+
// Retry with new token → also 401
1362+
dioAdapter.onPost(
1363+
baseUrl,
1364+
(server) => server.throws(
1365+
responseStatusCode401,
1366+
makeDioError401(),
1367+
),
1368+
headers: {
1369+
HttpHeaders.authorizationHeader:
1370+
'Bearer ${OIDCFixtures.newTokenOidc.token}',
1371+
},
1372+
);
1373+
1374+
when(authenticationClient.refreshingTokensOIDC(
1375+
OIDCFixtures.oidcConfiguration.clientId,
1376+
OIDCFixtures.oidcConfiguration.redirectUrl,
1377+
OIDCFixtures.oidcConfiguration.discoveryUrl,
1378+
OIDCFixtures.oidcConfiguration.scopes,
1379+
OIDCFixtures.tokenOidcExpiredTime.refreshToken,
1380+
)).thenAnswer((_) async => OIDCFixtures.newTokenOidc);
1381+
stubAccountCache();
1382+
1383+
await expectLater(
1384+
() => dio.post(baseUrl),
1385+
throwsA(isA<DioException>()),
1386+
);
1387+
1388+
// Refresh was called once
1389+
verify(authenticationClient.refreshingTokensOIDC(
1390+
OIDCFixtures.oidcConfiguration.clientId,
1391+
OIDCFixtures.oidcConfiguration.redirectUrl,
1392+
OIDCFixtures.oidcConfiguration.discoveryUrl,
1393+
OIDCFixtures.oidcConfiguration.scopes,
1394+
OIDCFixtures.tokenOidcExpiredTime.refreshToken,
1395+
)).called(1);
1396+
},
1397+
);
1398+
1399+
test(
1400+
'WHEN refresh succeeds but retry gets 500\n'
1401+
'THEN error is propagated\n'
1402+
'AND OIDC state is preserved',
1403+
() async {
1404+
authorizationInterceptors.setTokenAndAuthorityOidc(
1405+
newToken: OIDCFixtures.tokenOidcExpiredTime,
1406+
newConfig: OIDCFixtures.oidcConfiguration,
1407+
);
1408+
1409+
final dioError500 = DioException(
1410+
error: {'message': 'Internal Server Error'},
1411+
requestOptions: RequestOptions(path: baseUrl, method: 'POST'),
1412+
response: Response(
1413+
statusCode: responseStatusCode500,
1414+
requestOptions: RequestOptions(path: baseUrl),
1415+
),
1416+
type: DioExceptionType.badResponse,
1417+
);
1418+
1419+
// Old token → 401
1420+
dioAdapter.onPost(
1421+
baseUrl,
1422+
(server) => server.throws(responseStatusCode401, makeDioError401()),
1423+
headers: {
1424+
HttpHeaders.authorizationHeader:
1425+
'Bearer ${OIDCFixtures.tokenOidcExpiredTime.token}',
1426+
},
1427+
);
1428+
// Retry with new token → 500
1429+
dioAdapter.onPost(
1430+
baseUrl,
1431+
(server) => server.throws(responseStatusCode500, dioError500),
1432+
headers: {
1433+
HttpHeaders.authorizationHeader:
1434+
'Bearer ${OIDCFixtures.newTokenOidc.token}',
1435+
},
1436+
);
1437+
1438+
when(authenticationClient.refreshingTokensOIDC(
1439+
OIDCFixtures.oidcConfiguration.clientId,
1440+
OIDCFixtures.oidcConfiguration.redirectUrl,
1441+
OIDCFixtures.oidcConfiguration.discoveryUrl,
1442+
OIDCFixtures.oidcConfiguration.scopes,
1443+
OIDCFixtures.tokenOidcExpiredTime.refreshToken,
1444+
)).thenAnswer((_) async => OIDCFixtures.newTokenOidc);
1445+
stubAccountCache();
1446+
1447+
await expectLater(
1448+
() => dio.post(baseUrl),
1449+
throwsA(isA<DioException>()),
1450+
);
1451+
1452+
// OIDC state should NOT be cleared (only 400 clears state)
1453+
expect(
1454+
authorizationInterceptors.authenticationType,
1455+
AuthenticationType.oidc,
1456+
);
1457+
},
1458+
);
1459+
1460+
test(
1461+
'GIVEN 2 concurrent requests\n'
1462+
'WHEN Request 1 refreshes and retries successfully\n'
1463+
'AND Request 2 retries with new token but gets 500\n'
1464+
'THEN Request 1 succeeds\n'
1465+
'AND Request 2 error is propagated',
1466+
() async {
1467+
authorizationInterceptors.setTokenAndAuthorityOidc(
1468+
newToken: OIDCFixtures.tokenOidcExpiredTime,
1469+
newConfig: OIDCFixtures.oidcConfiguration,
1470+
);
1471+
1472+
final dioError500 = DioException(
1473+
error: {'message': 'Internal Server Error'},
1474+
requestOptions: RequestOptions(path: '$baseUrl/2', method: 'POST'),
1475+
response: Response(
1476+
statusCode: responseStatusCode500,
1477+
requestOptions: RequestOptions(path: '$baseUrl/2'),
1478+
),
1479+
type: DioExceptionType.badResponse,
1480+
);
1481+
1482+
// Request 1: old token → 401
1483+
dioAdapter.onPost(
1484+
'$baseUrl/1',
1485+
(server) => server.reply(
1486+
responseStatusCode401,
1487+
{'error': 'Unauthorized'},
1488+
),
1489+
headers: {
1490+
HttpHeaders.authorizationHeader:
1491+
'Bearer ${OIDCFixtures.tokenOidcExpiredTime.token}',
1492+
},
1493+
);
1494+
// Request 1 retry: new token → 200
1495+
dioAdapter.onPost(
1496+
'$baseUrl/1',
1497+
(server) =>
1498+
server.reply(responseStatusCode200, dataRequestSuccessfully),
1499+
headers: {
1500+
HttpHeaders.authorizationHeader:
1501+
'Bearer ${OIDCFixtures.newTokenOidc.token}',
1502+
},
1503+
);
1504+
1505+
// Request 2: old token → 401
1506+
dioAdapter.onPost(
1507+
'$baseUrl/2',
1508+
(server) => server.reply(
1509+
responseStatusCode401,
1510+
{'error': 'Unauthorized'},
1511+
),
1512+
headers: {
1513+
HttpHeaders.authorizationHeader:
1514+
'Bearer ${OIDCFixtures.tokenOidcExpiredTime.token}',
1515+
},
1516+
);
1517+
// Request 2 retry: new token → 500
1518+
dioAdapter.onPost(
1519+
'$baseUrl/2',
1520+
(server) => server.throws(responseStatusCode500, dioError500),
1521+
headers: {
1522+
HttpHeaders.authorizationHeader:
1523+
'Bearer ${OIDCFixtures.newTokenOidc.token}',
1524+
},
1525+
);
1526+
1527+
when(authenticationClient.refreshingTokensOIDC(
1528+
OIDCFixtures.oidcConfiguration.clientId,
1529+
OIDCFixtures.oidcConfiguration.redirectUrl,
1530+
OIDCFixtures.oidcConfiguration.discoveryUrl,
1531+
OIDCFixtures.oidcConfiguration.scopes,
1532+
OIDCFixtures.tokenOidcExpiredTime.refreshToken,
1533+
)).thenAnswer((_) async => OIDCFixtures.newTokenOidc);
1534+
stubAccountCache();
1535+
1536+
final future1 = dio.post('$baseUrl/1');
1537+
final future2 = dio.post('$baseUrl/2');
1538+
1539+
final response1 = await future1;
1540+
expect(response1.statusCode, responseStatusCode200);
1541+
1542+
await expectLater(
1543+
() => future2,
1544+
throwsA(isA<DioException>()),
1545+
);
1546+
},
1547+
);
1548+
});
1549+
13381550
// ============================================================
13391551
// onError: token duplicate prevents infinite loop
13401552
// ============================================================

0 commit comments

Comments
 (0)