diff --git a/dio/CHANGELOG.md b/dio/CHANGELOG.md index aeed0b989..c5c86bd32 100644 --- a/dio/CHANGELOG.md +++ b/dio/CHANGELOG.md @@ -11,6 +11,8 @@ See the [Migration Guide][] for the complete breaking changes list.** on an empty response body when a custom `responseDecoder` is set. It now returns an empty result, consistent with `SyncTransformer` and `BackgroundTransformer`. +- Fix concurrent requests hanging or reporting uncaught errors when an + interceptor shares a failing Future, such as in request deduplication. ## 5.10.0 diff --git a/dio/lib/src/dio_mixin.dart b/dio/lib/src/dio_mixin.dart index 2ff82a357..d3cd07e0f 100644 --- a/dio/lib/src/dio_mixin.dart +++ b/dio/lib/src/dio_mixin.dart @@ -426,34 +426,10 @@ abstract class DioMixin implements Dio { } } - // Create an error zone that catches uncaught async errors from - // interceptor callbacks. This prevents requests from hanging when - // an async interceptor throws without calling the handler. - // Synchronous exceptions are not caught here and propagate normally. - // If the handler was already completed (e.g. a fire-and-forget future - // inside the callback failed), forward the error to the parent zone - // so it isn't silently swallowed. - Zone createInterceptorZone( - _BaseHandler handler, - void Function(Object error, StackTrace stackTrace) onUncaughtError, - ) { - return Zone.current.fork( - specification: ZoneSpecification( - handleUncaughtError: (self, parent, zone, error, stackTrace) { - if (!handler.isCompleted) { - onUncaughtError(error, stackTrace); - } else { - parent.handleUncaughtError(zone, error, stackTrace); - } - }, - ), - ); - } - // Convert the request interceptor to a functional callback in which // we can handle the return value of interceptor callback. FutureOr Function(dynamic) requestInterceptorWrapper( - InterceptorSendCallback cb, + _InterceptorCallback cb, ) { return (dynamic incomingState) { final state = incomingState as InterceptorState; @@ -462,13 +438,15 @@ abstract class DioMixin implements Dio { requestOptions.cancelToken, Future(() async { final handler = RequestInterceptorHandler(); - createInterceptorZone( + final result = cb(state.data as RequestOptions, handler); + _observeInterceptorCallback( + result, handler, (error, stackTrace) => handler.reject( assureDioException(error, requestOptions, stackTrace), true, ), - ).run(() => cb(state.data as RequestOptions, handler)); + ); return handler.future; }), ); @@ -480,7 +458,7 @@ abstract class DioMixin implements Dio { // Convert the response interceptor to a functional callback in which // we can handle the return value of interceptor callback. FutureOr Function(dynamic) responseInterceptorWrapper( - InterceptorSuccessCallback cb, + _InterceptorCallback, ResponseInterceptorHandler> cb, ) { return (dynamic incomingState) { final state = incomingState as InterceptorState; @@ -490,13 +468,15 @@ abstract class DioMixin implements Dio { requestOptions.cancelToken, Future(() async { final handler = ResponseInterceptorHandler(); - createInterceptorZone( + final result = cb(state.data as Response, handler); + _observeInterceptorCallback( + result, handler, (error, stackTrace) => handler.reject( assureDioException(error, requestOptions, stackTrace), true, ), - ).run(() => cb(state.data as Response, handler)); + ); return handler.future; }), ); @@ -508,7 +488,7 @@ abstract class DioMixin implements Dio { // Convert the error interceptor to a functional callback in which // we can handle the return value of interceptor callback. FutureOr Function(Object) errorInterceptorWrapper( - InterceptorErrorCallback cb, + _InterceptorCallback cb, ) { return (dynamic error) { final state = error is InterceptorState @@ -516,12 +496,14 @@ abstract class DioMixin implements Dio { : InterceptorState(assureDioException(error, requestOptions)); Future handleError() async { final handler = ErrorInterceptorHandler(); - createInterceptorZone( + final result = cb(state.data, handler); + _observeInterceptorCallback( + result, handler, (error, stackTrace) => handler.next( assureDioException(error, requestOptions, stackTrace), ), - ).run(() => cb(state.data, handler)); + ); return handler.future; } @@ -552,7 +534,7 @@ abstract class DioMixin implements Dio { for (final interceptor in interceptors) { final fun = interceptor is QueuedInterceptor ? interceptor._handleRequest - : interceptor.onRequest; + : interceptor._invokeRequest; future = future.then(requestInterceptorWrapper(fun)); } @@ -569,6 +551,7 @@ abstract class DioMixin implements Dio { } on DioException catch (e) { handler.reject(e, true); } + return null; }), ); @@ -576,7 +559,7 @@ abstract class DioMixin implements Dio { for (final interceptor in interceptors) { final fun = interceptor is QueuedInterceptor ? interceptor._handleResponse - : interceptor.onResponse; + : interceptor._invokeResponse; future = future.then(responseInterceptorWrapper(fun)); } @@ -584,7 +567,7 @@ abstract class DioMixin implements Dio { for (final interceptor in interceptors) { final fun = interceptor is QueuedInterceptor ? interceptor._handleError - : interceptor.onError; + : interceptor._invokeError; future = future.catchError(errorInterceptorWrapper(fun)); } // Normalize errors, converts errors to [DioException]. diff --git a/dio/lib/src/interceptor.dart b/dio/lib/src/interceptor.dart index 4e12dc8b8..6ad483a9a 100644 --- a/dio/lib/src/interceptor.dart +++ b/dio/lib/src/interceptor.dart @@ -39,6 +39,35 @@ abstract class _BaseHandler { } } +typedef _InterceptorCallback = Object? Function( + T data, + V handler, +); + +void _observeInterceptorCallback( + Object? result, + _BaseHandler handler, + void Function(Object error, StackTrace stackTrace) onError, +) { + // Only a returned Future can be associated with this handler. Detached + // asynchronous work remains owned by the callback's zone. + if (result is! Future) { + return; + } + + final callbackZone = Zone.current; + result.then( + (_) {}, + onError: (Object error, StackTrace stackTrace) { + if (!handler.isCompleted) { + onError(error, stackTrace); + } else { + callbackZone.handleUncaughtError(error, stackTrace); + } + }, + ); +} + /// The handler for interceptors to handle before the request has been sent. class RequestInterceptorHandler extends _BaseHandler { /// Deliver the [requestOptions] to the next interceptor. @@ -242,6 +271,30 @@ class Interceptor { ) { handler.next(err); } + + Object? _invokeRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) { + final dynamic callback = onRequest; + return callback(options, handler); + } + + Object? _invokeResponse( + Response response, + ResponseInterceptorHandler handler, + ) { + final dynamic callback = onResponse; + return callback(response, handler); + } + + Object? _invokeError( + DioException error, + ErrorInterceptorHandler handler, + ) { + final dynamic callback = onError; + return callback(error, handler); + } } /// The signature of [Interceptor.onRequest]. @@ -267,16 +320,29 @@ mixin _InterceptorWrapperMixin on Interceptor { InterceptorSuccessCallback? _onResponse; InterceptorErrorCallback? _onError; + // The public callback typedefs remain void for compatibility. Invoke them + // dynamically so an async callback still exposes its runtime Future. + @override void onRequest( RequestOptions options, RequestInterceptorHandler handler, ) { - if (_onRequest != null) { - _onRequest!(options, handler); - } else { + final callback = _onRequest; + if (callback == null) { handler.next(options); + return; } + final dynamic dynamicCallback = callback; + final result = dynamicCallback(options, handler); + _observeInterceptorCallback( + result, + handler, + (error, stackTrace) => handler.reject( + DioMixin.assureDioException(error, options, stackTrace), + true, + ), + ); } @override @@ -284,11 +350,25 @@ mixin _InterceptorWrapperMixin on Interceptor { Response response, ResponseInterceptorHandler handler, ) { - if (_onResponse != null) { - _onResponse!(response, handler); - } else { + final callback = _onResponse; + if (callback == null) { handler.next(response); + return; } + final dynamic dynamicCallback = callback; + final result = dynamicCallback(response, handler); + _observeInterceptorCallback( + result, + handler, + (error, stackTrace) => handler.reject( + DioMixin.assureDioException( + error, + response.requestOptions, + stackTrace, + ), + true, + ), + ); } @override @@ -296,11 +376,24 @@ mixin _InterceptorWrapperMixin on Interceptor { DioException err, ErrorInterceptorHandler handler, ) { - if (_onError != null) { - _onError!(err, handler); - } else { + final callback = _onError; + if (callback == null) { handler.next(err); + return; } + final dynamic dynamicCallback = callback; + final result = dynamicCallback(err, handler); + _observeInterceptorCallback( + result, + handler, + (error, stackTrace) => handler.next( + DioMixin.assureDioException( + error, + err.requestOptions, + stackTrace, + ), + ), + ); } } @@ -402,7 +495,7 @@ class QueuedInterceptor extends Interceptor { final _responseQueue = _TaskQueue(); final _errorQueue = _TaskQueue(); - void _handleRequest( + Object? _handleRequest( RequestOptions options, RequestInterceptorHandler handler, ) { @@ -410,15 +503,16 @@ class QueuedInterceptor extends Interceptor { _requestQueue, options, handler, - onRequest, - (e, handler) { - final error = DioMixin.assureDioException(e, options); + _invokeRequest, + (e, stackTrace, handler) { + final error = DioMixin.assureDioException(e, options, stackTrace); handler.reject(error, true); }, ); + return null; } - void _handleResponse( + Object? _handleResponse( Response response, ResponseInterceptorHandler handler, ) { @@ -426,15 +520,20 @@ class QueuedInterceptor extends Interceptor { _responseQueue, response, handler, - onResponse, - (e, handler) { - final error = DioMixin.assureDioException(e, response.requestOptions); + _invokeResponse, + (e, stackTrace, handler) { + final error = DioMixin.assureDioException( + e, + response.requestOptions, + stackTrace, + ); handler.reject(error, true); }, ); + return null; } - void _handleError( + Object? _handleError( DioException error, ErrorInterceptorHandler handler, ) { @@ -442,20 +541,25 @@ class QueuedInterceptor extends Interceptor { _errorQueue, error, handler, - onError, - (e, handler) { - final err = DioMixin.assureDioException(e, error.requestOptions); + _invokeError, + (e, stackTrace, handler) { + final err = DioMixin.assureDioException( + e, + error.requestOptions, + stackTrace, + ); handler.next(err); }, ); + return null; } void _handleQueue( _TaskQueue taskQueue, T data, V handler, - void Function(T, V) callback, - void Function(Object, V) onError, + _InterceptorCallback callback, + void Function(Object, StackTrace, V) onError, ) { // Runs [task] as the active task and wires up how the queue advances to // the next task once this one is done. @@ -519,12 +623,17 @@ class QueuedInterceptor extends Interceptor { }); try { - callback(task.data, task.handler); - } catch (e) { + final result = callback(task.data, task.handler); + _observeInterceptorCallback( + result, + task.handler, + (error, stackTrace) => onError(error, stackTrace, task.handler), + ); + } catch (e, stackTrace) { // Handle synchronous exceptions thrown by interceptor callbacks. // Without this, the request would hang indefinitely because the // handler's completer would never be completed. - onError(e, task.handler); + onError(e, stackTrace, task.handler); } } diff --git a/dio/test/interceptor_test.dart b/dio/test/interceptor_test.dart index 8ffc88b38..9ad523d0f 100644 --- a/dio/test/interceptor_test.dart +++ b/dio/test/interceptor_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:dio/src/dio_mixin.dart'; @@ -453,6 +454,75 @@ void main() { ); }); + test('Async interceptor subclass error does not hang the request', + () async { + final dio = Dio() + ..options.baseUrl = MockAdapter.mockBase + ..httpClientAdapter = MockAdapter() + ..interceptors.add(_AsyncRequestErrorInterceptor()); + + await expectLater( + dio.get('/test'), + throwsA( + isA().having( + (error) => error.error, + 'error', + isA().having( + (error) => error.message, + 'message', + 'Async interceptor subclass error', + ), + ), + ), + ); + }); + + test('Async response interceptor subclass error does not hang the request', + () async { + final dio = Dio() + ..options.baseUrl = MockAdapter.mockBase + ..httpClientAdapter = MockAdapter() + ..interceptors.add(_AsyncResponseErrorInterceptor()); + + await expectLater( + dio.get('/test'), + throwsA( + isA().having( + (error) => error.error, + 'error', + isA().having( + (error) => error.message, + 'message', + 'Async response interceptor subclass error', + ), + ), + ), + ); + }); + + test('Async error interceptor subclass error does not hang the request', + () async { + final dio = Dio() + ..options.baseUrl = MockAdapter.mockBase + ..httpClientAdapter = MockAdapter() + ..interceptors.add(_AsyncErrorInterceptor()); + + await expectLater( + dio.get('/test-not-found'), + throwsA( + isA().having( + (error) => error.error, + 'error', + isA().having( + (error) => error.message, + 'message', + 'Async error interceptor subclass error', + ), + ), + ), + ); + }); + test('Async onResponse error does not hang the request', () async { final dio = Dio() ..options.baseUrl = MockAdapter.mockBase @@ -507,6 +577,226 @@ void main() { ); }); + test('Async error interceptor is not awaited after calling next', () async { + final callbackRelease = Completer(); + final dio = Dio() + ..options.baseUrl = MockAdapter.mockBase + ..httpClientAdapter = MockAdapter() + ..interceptors.add( + InterceptorsWrapper( + onError: (error, handler) async { + await Future.value(); + handler.next(error); + await callbackRelease.future; + }, + ), + ) + ..interceptors.add( + InterceptorsWrapper( + onError: (error, handler) { + handler.resolve( + Response( + requestOptions: error.requestOptions, + data: 'recovered', + ), + ); + }, + ), + ); + + final response = await dio.get('/test-not-found').timeout( + const Duration(seconds: 1), + onTimeout: () { + callbackRelease.complete(); + throw StateError('The interceptor callback was awaited.'); + }, + ); + callbackRelease.complete(); + + expect(response.data, 'recovered'); + }); + + test('Async error after handler completion reaches the callback zone', + () async { + final lateError = StateError('Late interceptor error'); + final uncaughtErrors = []; + final errorObserved = Completer(); + final zoneCompleted = Completer(); + Response? response; + var timedOut = false; + final dio = Dio() + ..options.baseUrl = MockAdapter.mockBase + ..httpClientAdapter = MockAdapter() + ..interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) async { + handler.next(options); + await Future.value(); + throw lateError; + }, + ), + ); + + runZonedGuarded( + () async { + try { + response = await dio.get('/test'); + await errorObserved.future.timeout(const Duration(seconds: 1)); + } on TimeoutException { + timedOut = true; + } finally { + zoneCompleted.complete(); + } + }, + (error, stackTrace) { + uncaughtErrors.add(error); + if (!errorObserved.isCompleted) { + errorObserved.complete(); + } + }, + ); + + await zoneCompleted.future; + + expect(timedOut, isFalse); + expect(response?.statusCode, 200); + expect(uncaughtErrors, [same(lateError)]); + }); + + test('InterceptorsWrapper subclass invokes on* overrides', () async { + final events = []; + final dio = Dio() + ..options.baseUrl = MockAdapter.mockBase + ..httpClientAdapter = MockAdapter() + ..interceptors.add(_SubclassedInterceptorsWrapper(events)); + + final response = await dio.get('/test'); + expect(response.statusCode, 200); + await expectLater( + dio.get('/test-not-found'), + throwsA(isA()), + ); + + expect(events, ['request', 'response', 'request', 'error']); + }); + + test('InterceptorsWrapper subclass observes async callback errors', + () async { + final dio = Dio() + ..options.baseUrl = MockAdapter.mockBase + ..httpClientAdapter = MockAdapter() + ..interceptors.add(_AsyncSubclassedInterceptorsWrapper()); + + await expectLater( + dio.get('/test'), + throwsA( + isA().having( + (error) => error.error, + 'error', + isA().having( + (error) => error.message, + 'message', + 'Async wrapper subclass error', + ), + ), + ), + ); + }); + + test('QueuedInterceptorsWrapper subclass invokes on* overrides', () async { + final events = []; + final dio = Dio() + ..options.baseUrl = MockAdapter.mockBase + ..httpClientAdapter = MockAdapter() + ..interceptors.add(_SubclassedQueuedInterceptorsWrapper(events)); + + final response = await dio.get('/test'); + expect(response.statusCode, 200); + await expectLater( + dio.get('/test-not-found'), + throwsA(isA()), + ); + + expect(events, ['request', 'response', 'request', 'error']); + }); + + test('QueuedInterceptorsWrapper subclass observes async callback errors', + () async { + final dio = Dio() + ..options.baseUrl = MockAdapter.mockBase + ..httpClientAdapter = MockAdapter() + ..interceptors.add(_AsyncSubclassedQueuedInterceptorsWrapper()); + + await expectLater( + dio.get('/test'), + throwsA( + isA().having( + (error) => error.error, + 'error', + isA().having( + (error) => error.message, + 'message', + 'Async queued wrapper subclass error', + ), + ), + ), + ); + }); + + test('#2565 shares adapter errors between concurrent requests', () async { + final adapter = _ImmediateErrorAdapter(); + final interceptor = _DeduplicatingInterceptor(); + final dio = Dio() + ..httpClientAdapter = adapter + ..interceptors.add(interceptor); + final errors = []; + final uncaughtErrors = []; + final zoneCompleted = Completer(); + var timedOut = false; + + runZonedGuarded( + () async { + try { + errors.addAll( + await Future.wait([ + dio.get('https://example.com/shared').then( + (_) => throw StateError('The adapter should fail.'), + onError: (Object error, StackTrace stackTrace) => + error as DioException, + ), + dio.get('https://example.com/shared').then( + (_) => throw StateError('The adapter should fail.'), + onError: (Object error, StackTrace stackTrace) => + error as DioException, + ), + ]).timeout(const Duration(seconds: 1)), + ); + } on TimeoutException { + timedOut = true; + } finally { + zoneCompleted.complete(); + } + }, + (error, stackTrace) => uncaughtErrors.add(error), + ); + + await zoneCompleted.future; + + expect( + timedOut, + isFalse, + reason: 'requests=${interceptor.requestCount}, ' + 'fetches=${adapter.fetchCount}, errors=${errors.length}, ' + 'uncaught=${uncaughtErrors.length}, pending=${interceptor.pending.length}', + ); + expect(interceptor.requestCount, 2); + expect(adapter.fetchCount, 1); + expect(errors, hasLength(2)); + expect(errors, everyElement(same(adapter.error))); + expect(uncaughtErrors, isEmpty); + expect(interceptor.pending, isEmpty); + }); + group(ImplyContentTypeInterceptor, () { Dio createDio() { final dio = Dio(); @@ -1113,3 +1403,181 @@ void main() { expect(interceptors2.last, isA()); }); } + +class _DeduplicatingInterceptor extends Interceptor { + final pending = >>{}; + int requestCount = 0; + + @override + void onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) { + requestCount++; + final completer = pending[options.uri]; + if (completer == null) { + pending[options.uri] = Completer>(); + handler.next(options); + return; + } + completer.future.then( + handler.resolve, + onError: (Object error, StackTrace stackTrace) { + handler.reject(error as DioException); + }, + ); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + final completer = pending.remove(err.requestOptions.uri); + if (completer != null) { + completer.completeError(err, err.stackTrace); + } + handler.next(err); + } +} + +class _ImmediateErrorAdapter implements HttpClientAdapter { + int fetchCount = 0; + DioException? error; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) { + fetchCount++; + error = DioException( + requestOptions: options, + message: 'Immediate adapter failure', + ); + throw error!; + } + + @override + void close({bool force = false}) {} +} + +class _AsyncRequestErrorInterceptor extends Interceptor { + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + await Future.value(); + throw StateError('Async interceptor subclass error'); + } +} + +class _AsyncResponseErrorInterceptor extends Interceptor { + @override + Future onResponse( + Response response, + ResponseInterceptorHandler handler, + ) async { + await Future.value(); + throw StateError('Async response interceptor subclass error'); + } +} + +class _AsyncErrorInterceptor extends Interceptor { + @override + Future onError( + DioException error, + ErrorInterceptorHandler handler, + ) async { + await Future.value(); + throw StateError('Async error interceptor subclass error'); + } +} + +class _SubclassedInterceptorsWrapper extends InterceptorsWrapper { + _SubclassedInterceptorsWrapper(this.events); + + final List events; + + @override + void onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) { + events.add('request'); + handler.next(options); + } + + @override + void onResponse( + Response response, + ResponseInterceptorHandler handler, + ) { + events.add('response'); + handler.next(response); + } + + @override + void onError( + DioException error, + ErrorInterceptorHandler handler, + ) { + events.add('error'); + handler.next(error); + } +} + +class _AsyncSubclassedInterceptorsWrapper extends InterceptorsWrapper { + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + await Future.value(); + throw StateError('Async wrapper subclass error'); + } +} + +class _SubclassedQueuedInterceptorsWrapper extends QueuedInterceptorsWrapper { + _SubclassedQueuedInterceptorsWrapper(this.events); + + final List events; + + @override + void onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) { + events.add('request'); + handler.next(options); + } + + @override + void onResponse( + Response response, + ResponseInterceptorHandler handler, + ) { + events.add('response'); + handler.next(response); + } + + @override + void onError( + DioException error, + ErrorInterceptorHandler handler, + ) { + events.add('error'); + handler.next(error); + } +} + +class _AsyncSubclassedQueuedInterceptorsWrapper + extends QueuedInterceptorsWrapper { + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + await Future.value(); + throw StateError('Async queued wrapper subclass error'); + } +} diff --git a/dio/test/queued_interceptor_test.dart b/dio/test/queued_interceptor_test.dart index 5edb0ca2f..39bd643e7 100644 --- a/dio/test/queued_interceptor_test.dart +++ b/dio/test/queued_interceptor_test.dart @@ -75,6 +75,129 @@ void main() { } }); + test( + 'QueuedInterceptor should catch asynchronous errors in onRequest and release the queue', + () async { + final dio = Dio() + ..httpClientAdapter = _MockAdapter() + ..interceptors.add(_AsyncRequestErrorQueuedInterceptor()); + + final firstRequest = dio.get('https://example.com/first'); + final firstFailure = expectLater( + firstRequest, + throwsA( + isA().having( + (error) => error.error, + 'error', + isA().having( + (error) => error.message, + 'message', + 'Async request error', + ), + ), + ), + ); + final secondResponse = await dio + .get('https://example.com/second') + .timeout(const Duration(seconds: 1)); + + expect(secondResponse.statusCode, 200); + await firstFailure; + }); + + test( + 'QueuedInterceptor should catch asynchronous errors in onResponse and release the queue', + () async { + var responseCount = 0; + final dio = Dio() + ..httpClientAdapter = _MockAdapter() + ..interceptors.add( + QueuedInterceptorsWrapper( + onResponse: (response, handler) async { + responseCount++; + await Future.value(); + if (responseCount == 1) { + throw StateError('Async response error'); + } + handler.next(response); + }, + ), + ); + + final firstRequest = dio.get('https://example.com/first'); + final firstFailure = expectLater( + firstRequest, + throwsA( + isA().having( + (error) => error.error, + 'error', + isA().having( + (error) => error.message, + 'message', + 'Async response error', + ), + ), + ), + ); + final secondResponse = await dio + .get('https://example.com/second') + .timeout(const Duration(seconds: 1)); + + expect(secondResponse.statusCode, 200); + expect(responseCount, 2); + await firstFailure; + }); + + test( + 'QueuedInterceptor should catch asynchronous errors in onError and release the queue', + () async { + var errorCount = 0; + final dio = Dio() + ..httpClientAdapter = _FailingMockAdapter() + ..interceptors.add( + QueuedInterceptorsWrapper( + onError: (error, handler) async { + errorCount++; + await Future.value(); + if (errorCount == 1) { + throw StateError('Async error interceptor error'); + } + handler.next(error); + }, + ), + ); + + final firstRequest = dio.get('https://example.com/first'); + final firstFailure = expectLater( + firstRequest, + throwsA( + isA().having( + (error) => error.error, + 'error', + isA().having( + (error) => error.message, + 'message', + 'Async error interceptor error', + ), + ), + ), + ); + final secondRequest = dio.get('https://example.com/second'); + final secondFailure = expectLater( + secondRequest.timeout(const Duration(seconds: 1)), + throwsA( + isA().having( + (error) => error.message, + 'message', + 'Mock request failed', + ), + ), + ); + + await Future.wait([firstFailure, secondFailure]); + expect(errorCount, 2); + }); + test('Reproduces issue #2138 scenario with QueuedInterceptor', () async { final dio = Dio(); @@ -270,3 +393,20 @@ class _QueuedInterceptorWithError extends QueuedInterceptor { handler.next(err); } } + +class _AsyncRequestErrorQueuedInterceptor extends QueuedInterceptor { + int requestCount = 0; + + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + requestCount++; + await Future.value(); + if (requestCount == 1) { + throw StateError('Async request error'); + } + handler.next(options); + } +}