Skip to content

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

Merged
AlexV525 merged 4 commits into
cfug:mainfrom
ersanKolay:fix/async-interceptor-hang
May 20, 2026
Merged

AlexV525 merged 4 commits into
cfug:mainfrom
ersanKolay:fix/async-interceptor-hang

Conversation

@ersanKolay

Copy link
Copy Markdown
Contributor

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...

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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);
}

Copilot AI Mar 24, 2026

Copy link

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.
Comment thread dio/lib/src/dio_mixin.dart Outdated
specification: ZoneSpecification(
handleUncaughtError: (self, parent, zone, error, stackTrace) {
if (!handler.isCompleted) {
onUncaughtError(error);

Copilot AI Mar 24, 2026

Copy link

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);
},

Copilot AI Mar 24, 2026

Copy link

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);
},

Copilot AI Mar 24, 2026

Copy link

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);
},

Copilot AI Mar 24, 2026

Copy link

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.
@AlexV525

Copy link
Copy Markdown
Member

@ersanKolay Hi, are you still going to work on this?

- 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<void>.value(), which still forces an async gap without adding
  wall-clock delay to the suite
@ersanKolay

Copy link
Copy Markdown
Contributor Author

Yep, just pushed an update covering the review notes — stackTrace is threaded into the DioException now so you get the real throw site, errors get forwarded to parent.handleUncaughtError when the handler is already completed, and the 10ms delays in the tests are swapped for Future<void>.value(). Analyzer clean, interceptor suite green.

@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.12% 🔴 -0.02%
dio/lib/src/interceptor.dart 🟢 98.6% 🟢 99.3% 🟢 0.7%
Overall Coverage 🟢 88.44% 🟢 88.54% 🟢 0.1%

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

@AlexV525 AlexV525 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM. Thanks for the fix!

@kuhnroyal

Copy link
Copy Markdown
Member

I remember the zones having quite an overhead performance wise so we removed some usage in the past. Shouldn't we try to type the interceptors as FutureOr instead and then catch the error?

@AlexV525

Copy link
Copy Markdown
Member

I remember the zones having quite an overhead performance wise so we removed some usage in the past. Shouldn't we try to type the interceptors as FutureOr instead and then catch the error?

@kuhnroyal From my experience this should have zero impact to the performance (although we don't have corresponding benchmark), unless we heavily use ZoneSpecification to override queues like scheduleMicrotask.

@AlexV525
AlexV525 merged commit 5f24541 into cfug:main May 20, 2026
3 checks passed
realmeylisdev added a commit to realmeylisdev/dio that referenced this pull request May 29, 2026
After merging main, the cfug#2499 async-interceptor error zone routes
uncaught async throws through assureDioException, whose DioException
short-circuit preserves the propagateInnerError marker. Lock in that an
async `throw DioException.custom(...)` (which hung before the merge)
still unwraps to the inner type at the funnel.

Signed-off-by: Meylis <[email protected]>
AlexV525 added a commit that referenced this pull request Jul 19, 2026
Closes #2565

## Problem

Dio 5.10.0 introduced a regression in the interceptor execution model. A
custom interceptor that deduplicates concurrent requests by sharing a
`Completer` can expose an uncaught `DioException`, leave the duplicate
request pending, or dispatch the same request more than once when the
adapter fails immediately.

The failure is not caused by request deduplication itself. It is caused
by the interaction between deduplication and the per-callback error
zones introduced by #2499.

## Root Cause

Interceptor callbacks intentionally have public `void` return types:

```dart
void onRequest(RequestOptions options, RequestInterceptorHandler handler)
```

Dart still allows an async implementation or callback to produce a
runtime `Future<void>`, but the public static type does not expose that
Future. Before #2499, an async exception thrown before `next`,
`resolve`, or `reject` could therefore leave the handler's completer
pending forever.

#2499 addressed that hang by invoking every callback inside a newly
forked `Zone` with a custom `handleUncaughtError`. If the handler was
incomplete, the zone converted the uncaught error into a handler error;
if the handler was already complete, it forwarded the error to the
parent zone. This preserved the public `void` API and did not await
callbacks.

The new zone boundary changed Dart's Future error-delivery semantics.
Dart Future errors do not cross between distinct error zones in the same
way as ordinary values. The deterministic #2565 sequence is:

```text
Request A: callback runs in error zone A
           creates the shared Completer
           calls handler.next()
           proceeds to the adapter

Request B: callback runs in error zone B
           finds A's Completer
           registers an onError listener on its Future

Adapter:   A fails immediately
           A's onError completes the shared Completer with completeError()

Result:    the Future belongs to error zone A, but B's listener belongs to B
           B's listener is not delivered the shared error
           A's handler is already complete, so Dio forwards the error as uncaught
           B's handler never completes
```

The regression is the per-callback error-zone isolation; the immediate
adapter failure is the deterministic timing trigger that exposes it in
this reproduction. This fix removes the callback-specific zones created
by Dio; it does not make a shared Future safe when application code
deliberately creates and listens to it across unrelated external error
zones, which remain governed by Dart's normal error-zone rules. Before
the implementation, a test-only probe reproduced two `onRequest`
callbacks, one adapter fetch, zero joined-error deliveries, one uncaught
error, and a timed-out pending request on the base implementation. The
committed regression test asserts the repaired behavior after the fix.

## Implementation

### 1. Capture runtime callback Futures without changing the API

A private library typedef, `_InterceptorCallback<T, V>`, returns
`Object?`. `Interceptor` now has private virtual `_invokeRequest`,
`_invokeResponse`, and `_invokeError` entry points that dynamically
invoke the public `on*` method:

```dart
final dynamic callback = onRequest;
return callback(options, handler);
```

This is deliberate:

- A normal `Interceptor` subclass can override `Future<void>
onRequest/onResponse/onError`; dynamic invocation retains the runtime
Future even though the base API is `void`.
- The public method signatures and public callback typedefs remain
unchanged.
- The private entry points are used only by the internal request
pipeline.

The wrapper compatibility path is kept explicit. `InterceptorsWrapper`
and `QueuedInterceptorsWrapper` still expose their existing public `void
on*` methods. The wrapper mixin dynamically invokes the
constructor-supplied callback and observes its runtime result there. It
no longer uses private mixin overrides that bypass a subclass's public
`onRequest`, `onResponse`, or `onError` override. This preserves
downstream wrapper-subclass behavior while retaining support for async
constructor callbacks.

### 2. Observe, do not await, the returned Future

`_observeInterceptorCallback` is registered synchronously immediately
after callback invocation, in the callback's existing zone:

```text
invoke callback
  -> obtain runtime result
  -> if result is a Future, attach a success no-op and an error listener
  -> return handler.future to the interceptor pipeline
```

The callback Future is never awaited and is never used as the pipeline
completion signal. Handler completion remains the only normal signal
that advances the request, response, or error chain.

When the observed Future fails:

| Callback stage | Handler is incomplete | Handler is already complete |
| --- | --- | --- |
| Request | `handler.reject(assureDioException(error, ..., stackTrace),
true)` | Report through the callback's existing zone |
| Response | `handler.reject(assureDioException(error, ..., stackTrace),
true)` | Report through the callback's existing zone |
| Error | `handler.next(assureDioException(error, ..., stackTrace))` |
Report through the callback's existing zone |

The original `StackTrace` is preserved in all conversions. The error
listener consumes the original Future error and handles it through the
interceptor handler, so the derived listener Future does not create a
second uncaught error.

If the callback has already completed its handler and then its returned
Future fails, the error is deliberately sent to the zone captured when
the listener was registered. This preserves #2499's late-error
visibility for fire-and-forget behavior without allowing Dio to complete
a handler a second time. A detached Future that the callback neither
returns nor handles remains outside Dio's ownership and is not
reclassified as a handler failure. If the callback also never completes
its handler, Dio cannot use that detached Future to unblock the request,
so the request can still remain pending. This is an explicit ownership
boundary rather than an attempt to catch every asynchronous task started
by user code.

### 3. Preserve normal and queued pipeline timing

For normal interceptors, `DioMixin.fetch` invokes the private `_invoke*`
method inside the existing pipeline Future, observes the runtime result,
and returns the handler Future. It no longer forks a callback-specific
error zone.

For queued interceptors, `QueuedInterceptor._handleQueue` receives the
matching private `_invoke*` method. The callback is still called inside
the existing synchronous try block, its returned Future is observed
immediately, and queue advancement still occurs only through handler
completion or cancellation. FIFO behavior and the separate
request/response/error queues are unchanged.

The effective timing is therefore:

```text
Before:
  pipeline -> fork a new error zone -> invoke callback -> wait for handler
             callback Future errors are routed through the new zone

After:
  pipeline -> invoke callback in the existing zone
           -> attach an error listener synchronously
           -> wait for handler
             callback Future is observed but never awaited
```

This removes the cross-request error-zone boundary while preserving
handler-driven ordering. Awaiting the callback Future was intentionally
avoided because the await-based approach in #2139 changed microtask
ordering and broke following interceptor behavior reported in #2167;
#2169 reverted #2139.

## API and Compatibility Boundaries

- No public method return type changed.
- `InterceptorSendCallback`, `InterceptorSuccessCallback`, and
`InterceptorErrorCallback` remain public `void Function(...)` typedefs.
- No public symbol was removed or renamed.
- No Dart SDK lower-bound increase or Dart 3-only language feature was
introduced.
- Existing synchronous callbacks and handler methods retain their
behavior.
- Async callbacks remain unawaited; only their returned errors are now
observed explicitly.
- Subclasses of `Interceptor`, `InterceptorsWrapper`,
`QueuedInterceptor`, and `QueuedInterceptorsWrapper` are all covered.
- Wrapper subclasses continue to dispatch through their public `on*`
methods instead of being bypassed by an internal closure path.

The wrapper-subclass checks are compatibility guards, not a new
user-visible behavior. A temporary separate CHANGELOG bullet for
preserving those overrides was intentionally removed in `6b42fa2`; the
final changelog contains only the #2565 user-facing fix.

## Tests

The regression and compatibility coverage includes:

- A deterministic #2565 test with an immediate failing adapter and a
shared deduplication Future. It asserts both requests terminate, only
one adapter fetch occurs, no error escapes the test's surrounding zone
in the same error-zone setup, and no pending handler remains.
- Direct `Interceptor` subclasses with async `onRequest`, `onResponse`,
and `onError` overrides that throw after an await.
- `InterceptorsWrapper` and `QueuedInterceptorsWrapper` subclasses
overriding all three public `on*` methods synchronously, with async
wrapper-subclass coverage on the request path.
- Async constructor callbacks covering request, response, and error
paths.
- Queued request, response, and error failures, including queue release
for a following request.
- Existing behavior for callbacks that call their handler and later
fail, callbacks that must not be awaited, duplicate handler calls, and
following error interceptors.

The focused VM and Chrome suites each pass all 52 tests.

## Verification

Locally, `melos run format`, `melos run analyze`, and `dart analyze
--fatal-infos` passed. CI run 29642622254 passed on min, beta, and
stable SDK matrices, including VM, Chrome, Firefox, Flutter, APK build,
and coverage.

The final coverage report is:

| File | Base | New | Difference |
| --- | ---: | ---: | ---: |
| `dio/lib/src/dio_mixin.dart` | 94.25% | 94.49% | +0.24% |
| `dio/lib/src/interceptor.dart` | 99.34% | 99.46% | +0.12% |
| Overall | 85.73% | 85.95% | +0.22% |

A full local `melos run test` was attempted, but network-dependent VM
and browser tests encountered external HTTP 502, CORS, and timeout
failures. The CI workflow starts local httpbun containers on each GitHub
Actions runner and completed successfully.

## Unverified

The minimum Dart 2.18 SDK was not installed locally; the remote min SDK
workflow passed. Local Firefox and Flutter targets were unavailable or
blocked by local configuration, but both passed in the remote CI
matrices.

## AI Attribution

Implementation, tests, commit, and local review by Codex.

---------

Co-authored-by: Codex <[email protected]>
AlexV525 added a commit that referenced this pull request Aug 17, 2026
Closes #2590

## Problem

`NoSuchMethodError: Class 'X' has no instance method '_invokeError'` at
runtime when a class uses `implements Interceptor` instead of `extends
Interceptor`.

PR #2567 added private instance methods (`_invokeRequest`,
`_invokeResponse`, `_invokeError`) on `Interceptor` and dispatched the
interceptor pipeline through them. These methods exist only via
inheritance — a class that `implements Interceptor` provides the public
`onRequest`/`onResponse`/`onError` methods but does not inherit the
private ones, so the pipeline crashes at runtime.

This is a behavioral regression introduced in 5.11.0 (the release that
shipped #2567).

## Fix

Move the dynamic-dispatch mechanism from private instance methods on
`Interceptor` to a top-level helper function
`_invokeCallbackDynamically`, and call it via closures at each dispatch
site (`DioMixin.fetch` and `QueuedInterceptor._handle*`). The pipeline
now references exclusively the **public**
`onRequest`/`onResponse`/`onError` methods. Both `extends Interceptor`
and `implements Interceptor` work.

The dynamic invocation is preserved: a `Future<void>` returned by an
`async` override of a `void` method is still captured at runtime so
`_observeInterceptorCallback` can observe asynchronous errors — the
original motivation from #2567.

### Why a top-level function instead of private instance methods?

Private instance methods are not part of the interface contract. Any
class using `implements Interceptor` would not inherit them. A top-level
function that takes the public method as a parameter keeps dispatch
through the public API surface only. The `Interceptor` class doc comment
now documents this dispatch contract.

## Compatibility

- No public API change: no signature change, no symbol added/removed, no
typedef change.
- No SDK lower-bound increase.
- No behavior change for `extends Interceptor`, `InterceptorsWrapper`,
`QueuedInterceptor`, or `QueuedInterceptorsWrapper`.
- The fix restores 5.10 behavior for `implements Interceptor` users.

## Verification

Added 3 regression tests covering `implements Interceptor`:
- Synchronous callbacks through request → response stages.
- Error stage reached on a failing response (404).
- Async `onRequest` override that throws — dynamic dispatch captures the
`Future`, observer rejects the handler.

`dart analyze --fatal-infos` clean. 55 tests pass across
`interceptor_test.dart` and `queued_interceptor_test.dart` (including
the existing #2565 deduplication regression test, all #2499 async-hang
tests, and wrapper-subclass override tests). Broader non-network suite
(`dio_mixin_test`, `cancel_token_test`, `exception_test`): 68 passed, 1
skipped (TLS).

**Unverified:** Web platform (Chrome/Firefox) not run locally — dynamic
dispatch is a Dart language-spec guarantee, not a VM optimization, and
the same pattern has been used in `_InterceptorWrapperMixin` since
#2567.

## AI Attribution

Implementation, tests, and analysis by GLM-5.2.

Signed-off-by: Alex Li <[email protected]>
Co-authored-by: GLM-5.2 <[email protected]>
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.

4 participants