From 2f265afd6b1a48a39693fc695dbd26d2cd3888a8 Mon Sep 17 00:00:00 2001 From: ersanKolay Date: Wed, 11 Mar 2026 11:57:38 +0300 Subject: [PATCH 1/3] 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/3] 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/3] 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; }),