Retry with separated Dio to avoid Deadlock in error queue
This commit is contained in:
@@ -179,36 +179,18 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper {
|
||||
}
|
||||
|
||||
if (isRetryRequest) {
|
||||
if (extraInRequest.containsKey(FileUploader.uploadAttachmentExtraKey)) {
|
||||
log('AuthorizationInterceptors::onError: Retry upload request with TokenId = ${_token?.tokenIdHash}');
|
||||
final uploadExtra = extraInRequest[FileUploader.uploadAttachmentExtraKey];
|
||||
|
||||
requestOptions.headers[HttpHeaders.authorizationHeader] = _getTokenAsBearerHeader(_token!.token);
|
||||
// Mark as attempted to prevent infinite retry loops
|
||||
requestOptions.extra[_refreshAttemptedKey] = true;
|
||||
|
||||
final newOptions = Options(
|
||||
method: requestOptions.method,
|
||||
headers: requestOptions.headers,
|
||||
extra: requestOptions.extra,
|
||||
);
|
||||
|
||||
final response = await _dio.request(
|
||||
requestOptions.path,
|
||||
data: _getDataUploadRequest(uploadExtra),
|
||||
queryParameters: requestOptions.queryParameters,
|
||||
options: newOptions,
|
||||
);
|
||||
|
||||
return handler.resolve(response);
|
||||
} else {
|
||||
log('AuthorizationInterceptors::onError: Retry request with TokenId = ${_token?.tokenIdHash}');
|
||||
requestOptions.headers[HttpHeaders.authorizationHeader] = _getTokenAsBearerHeader(_token!.token);
|
||||
// Mark as attempted to prevent infinite retry loops
|
||||
requestOptions.extra[_refreshAttemptedKey] = true;
|
||||
|
||||
final response = await _dio.fetch(requestOptions);
|
||||
try {
|
||||
final response = await _retryRequest(requestOptions, extraInRequest);
|
||||
return handler.resolve(response);
|
||||
} catch (retryError) {
|
||||
logError(
|
||||
'AuthorizationInterceptors::onError: '
|
||||
'Retry failed with error=$retryError',
|
||||
);
|
||||
return super.onError(
|
||||
err.copyWith(error: retryError),
|
||||
handler,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return super.onError(err, handler);
|
||||
@@ -379,6 +361,44 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper {
|
||||
return _invokeRefreshTokenFromServer();
|
||||
}
|
||||
|
||||
Future<Response> _retryRequest(
|
||||
RequestOptions requestOptions,
|
||||
Map<String, dynamic> extraInRequest,
|
||||
) {
|
||||
requestOptions.headers[HttpHeaders.authorizationHeader] =
|
||||
_getTokenAsBearerHeader(_token!.token);
|
||||
requestOptions.extra[_refreshAttemptedKey] = true;
|
||||
|
||||
final retryDio = _createRetryDio();
|
||||
|
||||
if (extraInRequest.containsKey(FileUploader.uploadAttachmentExtraKey)) {
|
||||
log('AuthorizationInterceptors::_retryRequest: '
|
||||
'Retry upload request with TokenId = ${_token?.tokenIdHash}');
|
||||
final uploadExtra =
|
||||
extraInRequest[FileUploader.uploadAttachmentExtraKey];
|
||||
|
||||
return retryDio.request(
|
||||
requestOptions.path,
|
||||
data: _getDataUploadRequest(uploadExtra),
|
||||
queryParameters: requestOptions.queryParameters,
|
||||
options: Options(
|
||||
method: requestOptions.method,
|
||||
headers: requestOptions.headers,
|
||||
extra: requestOptions.extra,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
log('AuthorizationInterceptors::_retryRequest: '
|
||||
'Retry request with TokenId = ${_token?.tokenIdHash}');
|
||||
return retryDio.fetch(requestOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a separate Dio instance without interceptors for retry requests.
|
||||
/// This avoids deadlock when retrying inside [onError] of [QueuedInterceptorsWrapper].
|
||||
Dio _createRetryDio() => Dio(_dio.options)
|
||||
..httpClientAdapter = _dio.httpClientAdapter;
|
||||
|
||||
void clear() {
|
||||
_authorization = null;
|
||||
_token = null;
|
||||
|
||||
@@ -1335,6 +1335,218 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// onError: retry fails (separate Dio error handling)
|
||||
// ============================================================
|
||||
group('onError: retry fails (separate Dio error handling)', () {
|
||||
test(
|
||||
'WHEN refresh succeeds but retry with new token gets 401\n'
|
||||
'THEN error is propagated via retry catch block\n'
|
||||
'AND no deadlock occurs',
|
||||
() async {
|
||||
authorizationInterceptors.setTokenAndAuthorityOidc(
|
||||
newToken: OIDCFixtures.tokenOidcExpiredTime,
|
||||
newConfig: OIDCFixtures.oidcConfiguration,
|
||||
);
|
||||
|
||||
// Old token → 401
|
||||
dioAdapter.onPost(
|
||||
baseUrl,
|
||||
(server) => server.throws(responseStatusCode401, makeDioError401()),
|
||||
headers: {
|
||||
HttpHeaders.authorizationHeader:
|
||||
'Bearer ${OIDCFixtures.tokenOidcExpiredTime.token}',
|
||||
},
|
||||
);
|
||||
// Retry with new token → also 401
|
||||
dioAdapter.onPost(
|
||||
baseUrl,
|
||||
(server) => server.throws(
|
||||
responseStatusCode401,
|
||||
makeDioError401(),
|
||||
),
|
||||
headers: {
|
||||
HttpHeaders.authorizationHeader:
|
||||
'Bearer ${OIDCFixtures.newTokenOidc.token}',
|
||||
},
|
||||
);
|
||||
|
||||
when(authenticationClient.refreshingTokensOIDC(
|
||||
OIDCFixtures.oidcConfiguration.clientId,
|
||||
OIDCFixtures.oidcConfiguration.redirectUrl,
|
||||
OIDCFixtures.oidcConfiguration.discoveryUrl,
|
||||
OIDCFixtures.oidcConfiguration.scopes,
|
||||
OIDCFixtures.tokenOidcExpiredTime.refreshToken,
|
||||
)).thenAnswer((_) async => OIDCFixtures.newTokenOidc);
|
||||
stubAccountCache();
|
||||
|
||||
await expectLater(
|
||||
() => dio.post(baseUrl),
|
||||
throwsA(isA<DioException>()),
|
||||
);
|
||||
|
||||
// Refresh was called once
|
||||
verify(authenticationClient.refreshingTokensOIDC(
|
||||
OIDCFixtures.oidcConfiguration.clientId,
|
||||
OIDCFixtures.oidcConfiguration.redirectUrl,
|
||||
OIDCFixtures.oidcConfiguration.discoveryUrl,
|
||||
OIDCFixtures.oidcConfiguration.scopes,
|
||||
OIDCFixtures.tokenOidcExpiredTime.refreshToken,
|
||||
)).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'WHEN refresh succeeds but retry gets 500\n'
|
||||
'THEN error is propagated\n'
|
||||
'AND OIDC state is preserved',
|
||||
() async {
|
||||
authorizationInterceptors.setTokenAndAuthorityOidc(
|
||||
newToken: OIDCFixtures.tokenOidcExpiredTime,
|
||||
newConfig: OIDCFixtures.oidcConfiguration,
|
||||
);
|
||||
|
||||
final dioError500 = DioException(
|
||||
error: {'message': 'Internal Server Error'},
|
||||
requestOptions: RequestOptions(path: baseUrl, method: 'POST'),
|
||||
response: Response(
|
||||
statusCode: responseStatusCode500,
|
||||
requestOptions: RequestOptions(path: baseUrl),
|
||||
),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
|
||||
// Old token → 401
|
||||
dioAdapter.onPost(
|
||||
baseUrl,
|
||||
(server) => server.throws(responseStatusCode401, makeDioError401()),
|
||||
headers: {
|
||||
HttpHeaders.authorizationHeader:
|
||||
'Bearer ${OIDCFixtures.tokenOidcExpiredTime.token}',
|
||||
},
|
||||
);
|
||||
// Retry with new token → 500
|
||||
dioAdapter.onPost(
|
||||
baseUrl,
|
||||
(server) => server.throws(responseStatusCode500, dioError500),
|
||||
headers: {
|
||||
HttpHeaders.authorizationHeader:
|
||||
'Bearer ${OIDCFixtures.newTokenOidc.token}',
|
||||
},
|
||||
);
|
||||
|
||||
when(authenticationClient.refreshingTokensOIDC(
|
||||
OIDCFixtures.oidcConfiguration.clientId,
|
||||
OIDCFixtures.oidcConfiguration.redirectUrl,
|
||||
OIDCFixtures.oidcConfiguration.discoveryUrl,
|
||||
OIDCFixtures.oidcConfiguration.scopes,
|
||||
OIDCFixtures.tokenOidcExpiredTime.refreshToken,
|
||||
)).thenAnswer((_) async => OIDCFixtures.newTokenOidc);
|
||||
stubAccountCache();
|
||||
|
||||
await expectLater(
|
||||
() => dio.post(baseUrl),
|
||||
throwsA(isA<DioException>()),
|
||||
);
|
||||
|
||||
// OIDC state should NOT be cleared (only 400 clears state)
|
||||
expect(
|
||||
authorizationInterceptors.authenticationType,
|
||||
AuthenticationType.oidc,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'GIVEN 2 concurrent requests\n'
|
||||
'WHEN Request 1 refreshes and retries successfully\n'
|
||||
'AND Request 2 retries with new token but gets 500\n'
|
||||
'THEN Request 1 succeeds\n'
|
||||
'AND Request 2 error is propagated',
|
||||
() async {
|
||||
authorizationInterceptors.setTokenAndAuthorityOidc(
|
||||
newToken: OIDCFixtures.tokenOidcExpiredTime,
|
||||
newConfig: OIDCFixtures.oidcConfiguration,
|
||||
);
|
||||
|
||||
final dioError500 = DioException(
|
||||
error: {'message': 'Internal Server Error'},
|
||||
requestOptions: RequestOptions(path: '$baseUrl/2', method: 'POST'),
|
||||
response: Response(
|
||||
statusCode: responseStatusCode500,
|
||||
requestOptions: RequestOptions(path: '$baseUrl/2'),
|
||||
),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
|
||||
// Request 1: old token → 401
|
||||
dioAdapter.onPost(
|
||||
'$baseUrl/1',
|
||||
(server) => server.reply(
|
||||
responseStatusCode401,
|
||||
{'error': 'Unauthorized'},
|
||||
),
|
||||
headers: {
|
||||
HttpHeaders.authorizationHeader:
|
||||
'Bearer ${OIDCFixtures.tokenOidcExpiredTime.token}',
|
||||
},
|
||||
);
|
||||
// Request 1 retry: new token → 200
|
||||
dioAdapter.onPost(
|
||||
'$baseUrl/1',
|
||||
(server) =>
|
||||
server.reply(responseStatusCode200, dataRequestSuccessfully),
|
||||
headers: {
|
||||
HttpHeaders.authorizationHeader:
|
||||
'Bearer ${OIDCFixtures.newTokenOidc.token}',
|
||||
},
|
||||
);
|
||||
|
||||
// Request 2: old token → 401
|
||||
dioAdapter.onPost(
|
||||
'$baseUrl/2',
|
||||
(server) => server.reply(
|
||||
responseStatusCode401,
|
||||
{'error': 'Unauthorized'},
|
||||
),
|
||||
headers: {
|
||||
HttpHeaders.authorizationHeader:
|
||||
'Bearer ${OIDCFixtures.tokenOidcExpiredTime.token}',
|
||||
},
|
||||
);
|
||||
// Request 2 retry: new token → 500
|
||||
dioAdapter.onPost(
|
||||
'$baseUrl/2',
|
||||
(server) => server.throws(responseStatusCode500, dioError500),
|
||||
headers: {
|
||||
HttpHeaders.authorizationHeader:
|
||||
'Bearer ${OIDCFixtures.newTokenOidc.token}',
|
||||
},
|
||||
);
|
||||
|
||||
when(authenticationClient.refreshingTokensOIDC(
|
||||
OIDCFixtures.oidcConfiguration.clientId,
|
||||
OIDCFixtures.oidcConfiguration.redirectUrl,
|
||||
OIDCFixtures.oidcConfiguration.discoveryUrl,
|
||||
OIDCFixtures.oidcConfiguration.scopes,
|
||||
OIDCFixtures.tokenOidcExpiredTime.refreshToken,
|
||||
)).thenAnswer((_) async => OIDCFixtures.newTokenOidc);
|
||||
stubAccountCache();
|
||||
|
||||
final future1 = dio.post('$baseUrl/1');
|
||||
final future2 = dio.post('$baseUrl/2');
|
||||
|
||||
final response1 = await future1;
|
||||
expect(response1.statusCode, responseStatusCode200);
|
||||
|
||||
await expectLater(
|
||||
() => future2,
|
||||
throwsA(isA<DioException>()),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// onError: token duplicate prevents infinite loop
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user