From 417487c470b96121653fc82ac403e587709f09ed Mon Sep 17 00:00:00 2001 From: Alex Li Date: Sat, 18 Jul 2026 16:12:31 +0800 Subject: [PATCH 1/5] fix(dio): preserve shared Future errors across interceptors Remove per-callback error zones while observing returned interceptor Futures in their invocation zone. Preserve handler-driven ordering, queued error stack traces, and completed-handler uncaught errors. Co-Authored-By: Codex --- dio/CHANGELOG.md | 2 + dio/lib/src/dio_mixin.dart | 55 +++--- dio/lib/src/interceptor.dart | 140 ++++++++++++++-- dio/test/interceptor_test.dart | 231 ++++++++++++++++++++++++++ dio/test/queued_interceptor_test.dart | 140 ++++++++++++++++ 5 files changed, 515 insertions(+), 53 deletions(-) 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..3f4480528 100644 --- a/dio/lib/src/interceptor.dart +++ b/dio/lib/src/interceptor.dart @@ -39,6 +39,33 @@ abstract class _BaseHandler { } } +typedef _InterceptorCallback = Object? Function( + T data, + V handler, +); + +void _observeInterceptorCallback( + Object? result, + _BaseHandler handler, + void Function(Object error, StackTrace stackTrace) onError, +) { + 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 +269,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]. @@ -302,6 +353,45 @@ mixin _InterceptorWrapperMixin on Interceptor { handler.next(err); } } + + @override + Object? _invokeRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) { + if (_onRequest == null) { + handler.next(options); + return null; + } + final dynamic callback = _onRequest; + return callback(options, handler); + } + + @override + Object? _invokeResponse( + Response response, + ResponseInterceptorHandler handler, + ) { + if (_onResponse == null) { + handler.next(response); + return null; + } + final dynamic callback = _onResponse; + return callback(response, handler); + } + + @override + Object? _invokeError( + DioException error, + ErrorInterceptorHandler handler, + ) { + if (_onError == null) { + handler.next(error); + return null; + } + final dynamic callback = _onError; + return callback(error, handler); + } } /// A helper class to create interceptors in ease. @@ -402,7 +492,7 @@ class QueuedInterceptor extends Interceptor { final _responseQueue = _TaskQueue(); final _errorQueue = _TaskQueue(); - void _handleRequest( + Object? _handleRequest( RequestOptions options, RequestInterceptorHandler handler, ) { @@ -410,15 +500,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 +517,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 +538,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 +620,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..9e7b34002 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,29 @@ 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 onResponse error does not hang the request', () async { final dio = Dio() ..options.baseUrl = MockAdapter.mockBase @@ -507,6 +531,146 @@ 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('#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 +1277,70 @@ 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'); + } +} 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); + } +} From ecf2e22010ead99b4a9e1f15224c48435ba5f684 Mon Sep 17 00:00:00 2001 From: Alex Li Date: Sat, 18 Jul 2026 19:05:49 +0800 Subject: [PATCH 2/5] fix(dio): preserve wrapper interceptor overrides Co-Authored-By: Codex --- dio/CHANGELOG.md | 2 + dio/lib/src/interceptor.dart | 97 +++++++++---------- dio/test/interceptor_test.dart | 169 +++++++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+), 48 deletions(-) diff --git a/dio/CHANGELOG.md b/dio/CHANGELOG.md index c5c86bd32..1a1c0ed03 100644 --- a/dio/CHANGELOG.md +++ b/dio/CHANGELOG.md @@ -13,6 +13,8 @@ See the [Migration Guide][] for the complete breaking changes list.** `BackgroundTransformer`. - Fix concurrent requests hanging or reporting uncaught errors when an interceptor shares a failing Future, such as in request deduplication. +- Preserve `onRequest`, `onResponse`, and `onError` overrides in subclasses of + `InterceptorsWrapper` and `QueuedInterceptorsWrapper`. ## 5.10.0 diff --git a/dio/lib/src/interceptor.dart b/dio/lib/src/interceptor.dart index 3f4480528..d76f69987 100644 --- a/dio/lib/src/interceptor.dart +++ b/dio/lib/src/interceptor.dart @@ -318,16 +318,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 @@ -335,11 +348,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 @@ -347,50 +374,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; } - } - - @override - Object? _invokeRequest( - RequestOptions options, - RequestInterceptorHandler handler, - ) { - if (_onRequest == null) { - handler.next(options); - return null; - } - final dynamic callback = _onRequest; - return callback(options, handler); - } - - @override - Object? _invokeResponse( - Response response, - ResponseInterceptorHandler handler, - ) { - if (_onResponse == null) { - handler.next(response); - return null; - } - final dynamic callback = _onResponse; - return callback(response, handler); - } - - @override - Object? _invokeError( - DioException error, - ErrorInterceptorHandler handler, - ) { - if (_onError == null) { - handler.next(error); - return null; - } - final dynamic callback = _onError; - return callback(error, handler); + final dynamic dynamicCallback = callback; + final result = dynamicCallback(err, handler); + _observeInterceptorCallback( + result, + handler, + (error, stackTrace) => handler.next( + DioMixin.assureDioException( + error, + err.requestOptions, + stackTrace, + ), + ), + ); } } diff --git a/dio/test/interceptor_test.dart b/dio/test/interceptor_test.dart index 9e7b34002..598d2b318 100644 --- a/dio/test/interceptor_test.dart +++ b/dio/test/interceptor_test.dart @@ -617,6 +617,86 @@ void main() { 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(); @@ -1344,3 +1424,92 @@ class _AsyncRequestErrorInterceptor extends Interceptor { throw StateError('Async 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'); + } +} From 942d5a021af401066b03fa0b3dd32be136d1c16d Mon Sep 17 00:00:00 2001 From: Alex Li Date: Sat, 18 Jul 2026 19:28:19 +0800 Subject: [PATCH 3/5] test(dio): cover interceptor response and error paths Co-Authored-By: Codex --- dio/test/interceptor_test.dart | 68 ++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/dio/test/interceptor_test.dart b/dio/test/interceptor_test.dart index 598d2b318..9ad523d0f 100644 --- a/dio/test/interceptor_test.dart +++ b/dio/test/interceptor_test.dart @@ -477,6 +477,52 @@ void main() { ); }); + 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 @@ -1425,6 +1471,28 @@ class _AsyncRequestErrorInterceptor extends Interceptor { } } +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); From 6b42fa2c17034155daa5ba0db695bd9432d8c5c7 Mon Sep 17 00:00:00 2001 From: Alex Li Date: Sat, 18 Jul 2026 20:06:15 +0800 Subject: [PATCH 4/5] docs(dio): remove internal wrapper changelog entry Co-Authored-By: Codex --- dio/CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/dio/CHANGELOG.md b/dio/CHANGELOG.md index 1a1c0ed03..c5c86bd32 100644 --- a/dio/CHANGELOG.md +++ b/dio/CHANGELOG.md @@ -13,8 +13,6 @@ See the [Migration Guide][] for the complete breaking changes list.** `BackgroundTransformer`. - Fix concurrent requests hanging or reporting uncaught errors when an interceptor shares a failing Future, such as in request deduplication. -- Preserve `onRequest`, `onResponse`, and `onError` overrides in subclasses of - `InterceptorsWrapper` and `QueuedInterceptorsWrapper`. ## 5.10.0 From 64189f680824329482df66129cc1ebb78fe3f022 Mon Sep 17 00:00:00 2001 From: Alex Li Date: Sun, 19 Jul 2026 00:01:22 +0800 Subject: [PATCH 5/5] docs(dio): clarify interceptor Future ownership Co-Authored-By: Codex --- dio/lib/src/interceptor.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dio/lib/src/interceptor.dart b/dio/lib/src/interceptor.dart index d76f69987..6ad483a9a 100644 --- a/dio/lib/src/interceptor.dart +++ b/dio/lib/src/interceptor.dart @@ -49,6 +49,8 @@ void _observeInterceptorCallback( _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; }