Repository navigation
fix(dio): prevent request hang when async interceptor throws - #2499
Conversation
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
|
Sorry I accidently hit the close button. This looks cool! I shall try to play it locally somehow... |
There was a problem hiding this comment.
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
Zonethat converts uncaught async errors intohandler.reject()/handler.next()to unblock the request. - Add new interceptor tests covering async-throw scenarios for
onRequest,onResponse, andonError. - Update the
CHANGELOG.mdwith 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.
| handleUncaughtError: (self, parent, zone, error, stackTrace) { | ||
| if (!handler.isCompleted) { | ||
| onUncaughtError(error); | ||
| } |
There was a problem hiding this comment.
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.
| specification: ZoneSpecification( | ||
| handleUncaughtError: (self, parent, zone, error, stackTrace) { | ||
| if (!handler.isCompleted) { | ||
| onUncaughtError(error); |
There was a problem hiding this comment.
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.
| onUncaughtError(error); | |
| onUncaughtError(error); | |
| } else { | |
| parent.handleUncaughtError(zone, error, stackTrace); |
| onRequest: (options, handler) async { | ||
| await Future<void>.delayed(const Duration(milliseconds: 10)); | ||
| throw UnsupportedError(errorMsg); | ||
| }, |
There was a problem hiding this comment.
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.
| onResponse: (response, handler) async { | ||
| await Future<void>.delayed(const Duration(milliseconds: 10)); | ||
| throw UnsupportedError(errorMsg); | ||
| }, |
There was a problem hiding this comment.
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.
| onError: (err, handler) async { | ||
| await Future<void>.delayed(const Duration(milliseconds: 10)); | ||
| throw UnsupportedError(errorMsg); | ||
| }, |
There was a problem hiding this comment.
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.
|
@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
|
Yep, just pushed an update covering the review notes — |
Code Coverage Report: Only Changed Files listed
Minimum allowed coverage is |
|
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 |
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]>
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]>
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]>
Summary
Fixes #2138.
When an interceptor overrides
onRequest/onResponse/onErrorwith anasyncimplementation and throws after anawaitwithout calling the handler, the request hangs indefinitely. The handler'sCompleteris never completed because:void, so the returnedFutureis discardedhandler.next()/reject()/resolve()Approach
Each interceptor callback is now invoked inside a forked
Zonewith a customhandleUncaughtError. When an uncaught async error occurs and the handler hasn't been completed yet, the zone handler callshandler.reject()(orhandler.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
Test plan
dart formatanddart analyzeclean