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
40 changes: 37 additions & 3 deletions dio/lib/src/dio_mixin.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
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 custom handleUncaughtError swallows async errors when handler.isCompleted is already true (it neither forwards to parent.handleUncaughtError nor rethrows). That can hide genuinely unhandled errors from interceptor code that happen after calling the handler (e.g., fire-and-forget futures). Consider delegating to parent.handleUncaughtError(...) when the handler is already completed so those errors are still reported, while only converting to handler.reject/next when it isn’t completed yet.

Suggested change
onUncaughtError(error);
onUncaughtError(error);
} else {
parent.handleUncaughtError(zone, error, stackTrace);

Copilot uses AI. Check for mistakes.
}
Comment on lines +399 to +402
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 +417,13 @@ 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;
}),
);
Expand All @@ -420,7 +445,13 @@ 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;
}),
);
Expand All @@ -440,7 +471,10 @@ abstract class DioMixin implements Dio {
: InterceptorState(assureDioException(error, requestOptions));
Future<InterceptorState> 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;
}

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>.delayed(const Duration(milliseconds: 10));
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>.delayed(const Duration(milliseconds: 10));
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>.delayed(const Duration(milliseconds: 10));
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