Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dio/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
53 changes: 48 additions & 5 deletions dio/lib/src/dio_mixin.dart
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,30 @@ 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);
}
Comment on lines +402 to +407
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleUncaughtError receives a stackTrace but it’s currently ignored, so the DioException created via assureDioException will use requestOptions.sourceStackTrace/StackTrace.current instead of the interceptor’s actual throw site. Please thread the provided stackTrace into the error you pass to the handler (e.g., by constructing a DioException with stackTrace: stackTrace, or by extending assureDioException to accept a stack trace) so users get actionable traces.

Copilot uses AI. Check for mistakes.
},
),
);
}

// Convert the request interceptor to a functional callback in which
// we can handle the return value of interceptor callback.
FutureOr Function(dynamic) requestInterceptorWrapper(
Expand All @@ -398,7 +422,13 @@ abstract class DioMixin implements Dio {
requestOptions.cancelToken,
Future(() async {
final handler = RequestInterceptorHandler();
cb(state.data as RequestOptions, handler);
createInterceptorZone(
handler,
(error, stackTrace) => handler.reject(
assureDioException(error, requestOptions, stackTrace),
true,
),
).run(() => cb(state.data as RequestOptions, handler));
return handler.future;
}),
);
Expand All @@ -420,7 +450,13 @@ abstract class DioMixin implements Dio {
requestOptions.cancelToken,
Future(() async {
final handler = ResponseInterceptorHandler();
cb(state.data as Response, handler);
createInterceptorZone(
handler,
(error, stackTrace) => handler.reject(
assureDioException(error, requestOptions, stackTrace),
true,
),
).run(() => cb(state.data as Response, handler));
return handler.future;
}),
);
Expand All @@ -440,7 +476,12 @@ abstract class DioMixin implements Dio {
: InterceptorState(assureDioException(error, requestOptions));
Future<InterceptorState> handleError() async {
final handler = ErrorInterceptorHandler();
cb(state.data, handler);
createInterceptorZone(
handler,
(error, stackTrace) => handler.next(
assureDioException(error, requestOptions, stackTrace),
),
).run(() => cb(state.data, handler));
return handler.future;
}

Expand Down Expand Up @@ -716,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,
);
}

Expand Down
81 changes: 81 additions & 0 deletions dio/test/interceptor_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,87 @@ 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<void>.value();
throw UnsupportedError(errorMsg);
},
Comment on lines +437 to +440
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Future.delayed(const Duration(milliseconds: 10)) is only used to force an async gap; using Duration.zero (or Future<void>.value()/Future<void>.microtask) would keep the test intent while avoiding unnecessary wall-clock delay in the suite.

Copilot uses AI. Check for mistakes.
),
);
await expectLater(
dio.get('/test'),
throwsA(
isA<DioException>().having(
(e) => e.error,
'error',
isA<UnsupportedError>()
.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<void>.value();
throw UnsupportedError(errorMsg);
},
Comment on lines +464 to +467
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Future.delayed(const Duration(milliseconds: 10)) is only used to force an async gap; using Duration.zero (or Future<void>.value()/Future<void>.microtask) would keep the test intent while avoiding unnecessary wall-clock delay in the suite.

Copilot uses AI. Check for mistakes.
),
);
await expectLater(
dio.get('/test'),
throwsA(
isA<DioException>().having(
(e) => e.error,
'error',
isA<UnsupportedError>()
.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<void>.value();
throw UnsupportedError(errorMsg);
},
Comment on lines +491 to +494
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Future.delayed(const Duration(milliseconds: 10)) is only used to force an async gap; using Duration.zero (or Future<void>.value()/Future<void>.microtask) would keep the test intent while avoiding unnecessary wall-clock delay in the suite.

Copilot uses AI. Check for mistakes.
),
);
await expectLater(
dio.get('/test-not-found'),
throwsA(
isA<DioException>().having(
(e) => e.error,
'error',
isA<UnsupportedError>()
.having((e) => e.message, 'message', errorMsg),
),
),
);
});

group(ImplyContentTypeInterceptor, () {
Dio createDio() {
final dio = Dio();
Expand Down
Loading