From 2f265afd6b1a48a39693fc695dbd26d2cd3888a8 Mon Sep 17 00:00:00 2001 From: ersanKolay Date: Wed, 11 Mar 2026 11:57:38 +0300 Subject: [PATCH 1/4] fix(dio): prevent request hang when async interceptor throws When an interceptor overrides onRequest/onResponse/onError with an async implementation that throws after an await, the handler's Completer is never completed and the request hangs indefinitely. Wrap interceptor callback invocations in a forked Zone with a custom handleUncaughtError that rejects/advances the handler when an uncaught async error occurs. Unlike the reverted PR #2139, this approach does not await the callback and preserves the original microtask ordering, avoiding the regression from #2167. Fixes #2138 --- dio/CHANGELOG.md | 2 +- dio/lib/src/dio_mixin.dart | 36 +++++++++++++++-- dio/test/interceptor_test.dart | 74 ++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 4 deletions(-) diff --git a/dio/CHANGELOG.md b/dio/CHANGELOG.md index 134800694..a68c78ca1 100644 --- a/dio/CHANGELOG.md +++ b/dio/CHANGELOG.md @@ -5,7 +5,7 @@ See the [Migration Guide][] for the complete breaking changes list.** ## Unreleased -*None.* +- Fix request hanging indefinitely when async interceptor callbacks throw without calling the handler. ## 5.9.2 diff --git a/dio/lib/src/dio_mixin.dart b/dio/lib/src/dio_mixin.dart index 7145cf0cd..c7095219b 100644 --- a/dio/lib/src/dio_mixin.dart +++ b/dio/lib/src/dio_mixin.dart @@ -386,6 +386,25 @@ 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. + Zone createInterceptorZone( + _BaseHandler handler, + void Function(Object error) onUncaughtError, + ) { + return Zone.current.fork( + specification: ZoneSpecification( + handleUncaughtError: (self, parent, zone, error, stackTrace) { + if (!handler.isCompleted) { + onUncaughtError(error); + } + }, + ), + ); + } + // Convert the request interceptor to a functional callback in which // we can handle the return value of interceptor callback. FutureOr Function(dynamic) requestInterceptorWrapper( @@ -398,7 +417,11 @@ abstract class DioMixin implements Dio { requestOptions.cancelToken, Future(() async { final handler = RequestInterceptorHandler(); - cb(state.data as RequestOptions, handler); + createInterceptorZone( + handler, + (error) => handler.reject( + assureDioException(error, requestOptions), true), + ).run(() => cb(state.data as RequestOptions, handler)); return handler.future; }), ); @@ -420,7 +443,11 @@ abstract class DioMixin implements Dio { requestOptions.cancelToken, Future(() async { final handler = ResponseInterceptorHandler(); - cb(state.data as Response, handler); + createInterceptorZone( + handler, + (error) => handler.reject( + assureDioException(error, requestOptions), true), + ).run(() => cb(state.data as Response, handler)); return handler.future; }), ); @@ -440,7 +467,10 @@ abstract class DioMixin implements Dio { : InterceptorState(assureDioException(error, requestOptions)); Future handleError() async { final handler = ErrorInterceptorHandler(); - cb(state.data, handler); + createInterceptorZone( + handler, + (error) => handler.next(assureDioException(error, requestOptions)), + ).run(() => cb(state.data, handler)); return handler.future; } diff --git a/dio/test/interceptor_test.dart b/dio/test/interceptor_test.dart index 305c588f3..5596d6893 100644 --- a/dio/test/interceptor_test.dart +++ b/dio/test/interceptor_test.dart @@ -426,6 +426,80 @@ void main() { ); }); + test('Async interceptor error does not hang the request', () async { + final dio = Dio() + ..options.baseUrl = MockAdapter.mockBase + ..httpClientAdapter = MockAdapter(); + const errorMsg = 'async interceptor error'; + dio.interceptors.add( + InterceptorsWrapper( + // ignore: void_checks + onRequest: (options, handler) async { + await Future.delayed(const Duration(milliseconds: 10)); + throw UnsupportedError(errorMsg); + }, + ), + ); + await expectLater( + dio.get('/test'), + throwsA( + isA().having( + (e) => e.error, + 'error', + isA() + .having((e) => e.message, 'message', errorMsg), + ), + ), + ); + }); + + test('Async onResponse error does not hang the request', () async { + final dio = Dio() + ..options.baseUrl = MockAdapter.mockBase + ..httpClientAdapter = MockAdapter(); + const errorMsg = 'async response error'; + dio.interceptors.add( + InterceptorsWrapper( + // ignore: void_checks + onResponse: (response, handler) async { + await Future.delayed(const Duration(milliseconds: 10)); + throw UnsupportedError(errorMsg); + }, + ), + ); + await expectLater( + dio.get('/test'), + throwsA( + isA().having( + (e) => e.error, + 'error', + isA() + .having((e) => e.message, 'message', errorMsg), + ), + ), + ); + }); + + test('Async onError interceptor error does not hang', () async { + final dio = Dio() + ..options.baseUrl = MockAdapter.mockBase + ..httpClientAdapter = MockAdapter(); + const errorMsg = 'async error interceptor error'; + dio.interceptors.add( + InterceptorsWrapper( + // ignore: void_checks + onError: (err, handler) async { + await Future.delayed(const Duration(milliseconds: 10)); + throw UnsupportedError(errorMsg); + }, + ), + ); + await expectLater( + dio.get('/test-not-found'), + throwsA(isA()), + ); + }); + group(ImplyContentTypeInterceptor, () { Dio createDio() { final dio = Dio(); From 5b49970781c1ed9775d4a75c942739348a848913 Mon Sep 17 00:00:00 2001 From: ersanKolay Date: Wed, 11 Mar 2026 12:04:16 +0300 Subject: [PATCH 2/4] test: strengthen onError async test assertion --- dio/test/interceptor_test.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/dio/test/interceptor_test.dart b/dio/test/interceptor_test.dart index 5596d6893..20ec2dd29 100644 --- a/dio/test/interceptor_test.dart +++ b/dio/test/interceptor_test.dart @@ -496,7 +496,14 @@ void main() { ); await expectLater( dio.get('/test-not-found'), - throwsA(isA()), + throwsA( + isA().having( + (e) => e.error, + 'error', + isA() + .having((e) => e.message, 'message', errorMsg), + ), + ), ); }); From 7507d5d19ce62bf69de5780c2e9b076ffe17f09f Mon Sep 17 00:00:00 2001 From: ersanKolay Date: Wed, 11 Mar 2026 22:30:33 +0300 Subject: [PATCH 3/4] style(dio): add required trailing commas in interceptor zone callbacks --- dio/lib/src/dio_mixin.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/dio/lib/src/dio_mixin.dart b/dio/lib/src/dio_mixin.dart index c7095219b..cd9a79e29 100644 --- a/dio/lib/src/dio_mixin.dart +++ b/dio/lib/src/dio_mixin.dart @@ -420,7 +420,9 @@ abstract class DioMixin implements Dio { createInterceptorZone( handler, (error) => handler.reject( - assureDioException(error, requestOptions), true), + assureDioException(error, requestOptions), + true, + ), ).run(() => cb(state.data as RequestOptions, handler)); return handler.future; }), @@ -446,7 +448,9 @@ abstract class DioMixin implements Dio { createInterceptorZone( handler, (error) => handler.reject( - assureDioException(error, requestOptions), true), + assureDioException(error, requestOptions), + true, + ), ).run(() => cb(state.data as Response, handler)); return handler.future; }), From a14c0cbb3728fa782bae1897c12e1cb387315d43 Mon Sep 17 00:00:00 2001 From: ersanKolay Date: Sun, 19 Apr 2026 18:47:01 +0300 Subject: [PATCH 4/4] refactor(dio): address review feedback on async interceptor fix - Thread the async throw site's stackTrace through to the DioException so consumers see the actual failure location instead of sourceStackTrace/StackTrace.current - Forward errors to the parent zone when the handler is already completed, so fire-and-forget future errors inside the callback are not silently swallowed - Replace 10ms Future.delayed in the three new tests with Future.value(), which still forces an async gap without adding wall-clock delay to the suite --- dio/lib/src/dio_mixin.dart | 27 ++++++++++++++++++--------- dio/test/interceptor_test.dart | 6 +++--- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/dio/lib/src/dio_mixin.dart b/dio/lib/src/dio_mixin.dart index cd9a79e29..405be15d1 100644 --- a/dio/lib/src/dio_mixin.dart +++ b/dio/lib/src/dio_mixin.dart @@ -390,15 +390,20 @@ abstract class DioMixin implements Dio { // 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) onUncaughtError, + void Function(Object error, StackTrace stackTrace) onUncaughtError, ) { return Zone.current.fork( specification: ZoneSpecification( handleUncaughtError: (self, parent, zone, error, stackTrace) { if (!handler.isCompleted) { - onUncaughtError(error); + onUncaughtError(error, stackTrace); + } else { + parent.handleUncaughtError(zone, error, stackTrace); } }, ), @@ -419,8 +424,8 @@ abstract class DioMixin implements Dio { final handler = RequestInterceptorHandler(); createInterceptorZone( handler, - (error) => handler.reject( - assureDioException(error, requestOptions), + (error, stackTrace) => handler.reject( + assureDioException(error, requestOptions, stackTrace), true, ), ).run(() => cb(state.data as RequestOptions, handler)); @@ -447,8 +452,8 @@ abstract class DioMixin implements Dio { final handler = ResponseInterceptorHandler(); createInterceptorZone( handler, - (error) => handler.reject( - assureDioException(error, requestOptions), + (error, stackTrace) => handler.reject( + assureDioException(error, requestOptions, stackTrace), true, ), ).run(() => cb(state.data as Response, handler)); @@ -473,7 +478,9 @@ abstract class DioMixin implements Dio { final handler = ErrorInterceptorHandler(); createInterceptorZone( handler, - (error) => handler.next(assureDioException(error, requestOptions)), + (error, stackTrace) => handler.next( + assureDioException(error, requestOptions, stackTrace), + ), ).run(() => cb(state.data, handler)); return handler.future; } @@ -750,14 +757,16 @@ abstract class DioMixin implements Dio { @internal static DioException assureDioException( Object error, - RequestOptions requestOptions, - ) { + RequestOptions requestOptions, [ + StackTrace? stackTrace, + ]) { if (error is DioException) { return error; } return DioException( requestOptions: requestOptions, error: error, + stackTrace: stackTrace, ); } diff --git a/dio/test/interceptor_test.dart b/dio/test/interceptor_test.dart index 20ec2dd29..6237975dd 100644 --- a/dio/test/interceptor_test.dart +++ b/dio/test/interceptor_test.dart @@ -435,7 +435,7 @@ void main() { InterceptorsWrapper( // ignore: void_checks onRequest: (options, handler) async { - await Future.delayed(const Duration(milliseconds: 10)); + await Future.value(); throw UnsupportedError(errorMsg); }, ), @@ -462,7 +462,7 @@ void main() { InterceptorsWrapper( // ignore: void_checks onResponse: (response, handler) async { - await Future.delayed(const Duration(milliseconds: 10)); + await Future.value(); throw UnsupportedError(errorMsg); }, ), @@ -489,7 +489,7 @@ void main() { InterceptorsWrapper( // ignore: void_checks onError: (err, handler) async { - await Future.delayed(const Duration(milliseconds: 10)); + await Future.value(); throw UnsupportedError(errorMsg); }, ),