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 (isRetryRequest) {
|
||||||
if (extraInRequest.containsKey(FileUploader.uploadAttachmentExtraKey)) {
|
try {
|
||||||
log('AuthorizationInterceptors::onError: Retry upload request with TokenId = ${_token?.tokenIdHash}');
|
final response = await _retryRequest(requestOptions, extraInRequest);
|
||||||
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);
|
|
||||||
return handler.resolve(response);
|
return handler.resolve(response);
|
||||||
|
} catch (retryError) {
|
||||||
|
logError(
|
||||||
|
'AuthorizationInterceptors::onError: '
|
||||||
|
'Retry failed with error=$retryError',
|
||||||
|
);
|
||||||
|
return super.onError(
|
||||||
|
err.copyWith(error: retryError),
|
||||||
|
handler,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return super.onError(err, handler);
|
return super.onError(err, handler);
|
||||||
@@ -379,6 +361,44 @@ class AuthorizationInterceptors extends QueuedInterceptorsWrapper {
|
|||||||
return _invokeRefreshTokenFromServer();
|
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() {
|
void clear() {
|
||||||
_authorization = null;
|
_authorization = null;
|
||||||
_token = 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
|
// onError: token duplicate prevents infinite loop
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user