Skip to content

fix(dio): prevent request hang when async interceptor throws#2499

Open
ersanKolay wants to merge 3 commits intocfug:mainfrom
ersanKolay:fix/async-interceptor-hang
Open

fix(dio): prevent request hang when async interceptor throws#2499
ersanKolay wants to merge 3 commits intocfug:mainfrom
ersanKolay:fix/async-interceptor-hang

Conversation

@ersanKolay
Copy link
Copy Markdown

Summary

Fixes #2138.

When an interceptor overrides onRequest/onResponse/onError with an async implementation and throws after an await without calling the handler, the request hangs indefinitely. The handler's Completer is never completed because:

  1. The callback typedef is void, so the returned Future is discarded
  2. The unhandled async error goes to the zone's uncaught error handler
  3. Nobody ever calls handler.next()/reject()/resolve()
// This hangs forever:
class MyInterceptor extends Interceptor {
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
    await refreshToken(); // throws
    handler.next(options); // never reached
  }
}

Approach

Each interceptor callback is now invoked inside a forked Zone with a custom handleUncaughtError. When an uncaught async error occurs and the handler hasn't been completed yet, the zone handler calls handler.reject() (or handler.next() for error interceptors) to unblock the request.

This approach differs from the reverted PR #2139 which changed the callback typedefs to FutureOr<void> and awaited the return value. That changed the microtask ordering and broke multi-interceptor chains (#2167). Zone.fork() does not await the callback -- it only catches async errors that would otherwise be lost, preserving the original execution order.

Why this works

Scenario Before After
Sync interceptor, calls handler Works Works (no change)
Sync interceptor, throws Works (propagates via Future) Works (sync errors pass through Zone.run)
Async interceptor, calls handler Works Works (zone has no effect)
Async interceptor, throws Hangs forever Zone catches error, rejects handler
Duplicate handler.next() calls Throws StateError Throws StateError (sync, passes through)

Test plan

  • Added 3 new tests for async throw scenarios (onRequest, onResponse, onError)
  • All 218 existing tests pass
  • All 32 interceptor-specific tests pass (including "duplicate handler calls" and the existing "Caught exceptions before handler called" test)
  • dart format and dart analyze clean

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 cfug#2139, this
approach does not await the callback and preserves the original
microtask ordering, avoiding the regression from cfug#2167.

Fixes cfug#2138
@ersanKolay ersanKolay requested a review from a team as a code owner March 11, 2026 08:58
@AlexV525 AlexV525 closed this Mar 11, 2026
@AlexV525 AlexV525 reopened this Mar 11, 2026
@AlexV525
Copy link
Copy Markdown
Member

Sorry I accidently hit the close button.

This looks cool! I shall try to play it locally somehow...

@github-actions
Copy link
Copy Markdown
Contributor

Code Coverage Report: Only Changed Files listed

Package Base Coverage New Coverage Difference
dio/lib/src/dio_mixin.dart 🟢 94.14% 🟢 94.47% 🟢 0.33%
dio/lib/src/interceptor.dart 🟢 98.6% 🟢 99.3% 🟢 0.7%
Overall Coverage 🟢 88.44% 🟢 88.58% 🟢 0.14%

Minimum allowed coverage is 0%, this run produced 88.58%

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a Dio request hang that occurs when an interceptor callback is implemented as async and throws after an await without completing its handler, by ensuring the handler is completed on uncaught async errors.

Changes:

  • Run each interceptor callback inside a forked Zone that converts uncaught async errors into handler.reject()/handler.next() to unblock the request.
  • Add new interceptor tests covering async-throw scenarios for onRequest, onResponse, and onError.
  • Update the CHANGELOG.md with the fix description.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
dio/lib/src/dio_mixin.dart Wraps interceptor callback execution in a forked Zone to catch uncaught async errors and complete the handler to prevent hangs.
dio/test/interceptor_test.dart Adds regression tests ensuring async interceptor throws do not hang requests.
dio/CHANGELOG.md Notes the fix in the Unreleased section.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +399 to +402
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.

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.
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 +437 to +440
onRequest: (options, handler) async {
await Future<void>.delayed(const Duration(milliseconds: 10));
throw UnsupportedError(errorMsg);
},
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.
Comment on lines +464 to +467
onResponse: (response, handler) async {
await Future<void>.delayed(const Duration(milliseconds: 10));
throw UnsupportedError(errorMsg);
},
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.
Comment on lines +491 to +494
onError: (err, handler) async {
await Future<void>.delayed(const Duration(milliseconds: 10));
throw UnsupportedError(errorMsg);
},
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A request with an interceptor, which produces an error, makes the code stuck.

3 participants