Skip to content

fix(dio): preserve shared Future errors across interceptors#2567

Merged
AlexV525 merged 6 commits into
mainfrom
fix/2565-interceptor-error-zones
Jul 19, 2026
Merged

fix(dio): preserve shared Future errors across interceptors#2567
AlexV525 merged 6 commits into
mainfrom
fix/2565-interceptor-error-zones

Conversation

@AlexV525

@AlexV525 AlexV525 commented Jul 18, 2026

Copy link
Copy Markdown
Member

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:

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:

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:

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:

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:

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 Concurrent requests can expose unhandled errors in custom deduplication interceptors #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.

Remove per-callback error zones while observing returned interceptor Futures in their invocation zone. Preserve handler-driven ordering, queued error stack traces, and completed-handler uncaught errors.

Co-Authored-By: Codex <noreply@openai.com>
AlexV525 added a commit that referenced this pull request Jul 18, 2026
## Motivation

`httpbun.com` is currently returning HTTP 502 responses, which makes the
integration tests fail independently of dio changes. The failure is
visible in the [PR #2567 stable
job](https://github.com/cfug/dio/actions/runs/29637101718/job/88061247325?pr=2567).
CI already starts `httpbun.local`, but HTTP/2 pinning and all browser
tests still used the public service.

> upstream sharat87/httpbun#35

## Changes

- Reuse the shared test-server URL in HTTP/2 pinning tests and generate
their certificate fingerprint from the configured host.
- Keep local HTTPS for VM and HTTP/2 tests.
- Start a second local HTTP container for Chrome, Firefox, and Flutter
tests, avoiding browser-specific temporary-CA trust configuration.
- Keep `httpbun.com` as the default outside the CI-local setup.
- Avoid assuming `onBadCertificate` and `validateCertificate` receive
different certificates; that varies with the certificate chain and trust
store.

This touches TLS and certificate-pinning test infrastructure only;
production TLS behavior is unchanged. Maintainers: please apply the
extra scrutiny required for this sensitive area.

## Verification

`melos run format` and `melos run analyze` pass. The local Chrome shared
suite passes 54 tests with one existing skip against the HTTP container,
including CORS, redirects, status codes, timeouts, and uploads. GitHub
Actions passes on the minimum, stable, and beta SDK jobs, covering VM,
Chrome, Firefox, and Flutter tests; the stable job also passes APK build
and coverage generation.

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

---------

Co-authored-by: Codex <noreply@openai.com>
@AlexV525
AlexV525 marked this pull request as ready for review July 18, 2026 12:08
@AlexV525
AlexV525 requested a review from a team as a code owner July 18, 2026 12:08
@CaiJingLong

Copy link
Copy Markdown
Contributor

Review conclusion: Approve

Change summary

Removes the per-callback forked error zones introduced by #2499 (which broke cross-request Future error delivery for shared Completers in deduplication interceptors, #2565) and replaces them with a non-awaiting _observeInterceptorCallback listener. The listener preserves #2499's hang-prevention for async callbacks (returned Future errors reject/advance an incomplete handler) while restoring same-zone Future error delivery so shared-failure deduplication works again. Adds private _invokeRequest/_invokeResponse/_invokeError dynamic-dispatch entry points to capture the runtime Future of async on* overrides without changing the public void API, and rewires QueuedInterceptor._handleQueue and the wrapper mixin through the same observer.

Review details

Dimension Result Notes
Correctness Pass The observer is registered synchronously in the callback's existing zone, never awaits the Future, and keeps handler completion as the only pipeline signal. Incomplete-handler errors route to reject/next; complete-handler late errors route to callbackZone.handleUncaughtError, preserving #2499's late-error visibility. QueuedInterceptor returns null at the top-level _handle* so observation happens exactly once inside _handleQueue; FIFO and queue-release-on-cancel are unchanged. The dispatch callback's return null + observation also adds a safety net for non-DioException escapes from _dispatchRequest.
Tests Pass 52 interceptor/queued tests pass locally (dart test test/interceptor_test.dart test/queued_interceptor_test.dart). New coverage includes: the deterministic #2565 reproduction (asserts 2 requests, 1 adapter fetch, 2 joined errors, 0 uncaught, 0 pending), async Interceptor subclass errors on all three paths, InterceptorsWrapper/QueuedInterceptorsWrapper subclass override dispatch + async subclass errors, async constructor callbacks, queued request/response/error failures with queue release for the following request, the #2167 non-awaiting guard, and late-error-after-handler-completion reaching the callback zone. dio_mixin_test, cancel_token_test, adapters_test, exception_test also pass. The 7 failures in the full VM suite (test_suite_test, stacktrace_test, options_timeout_integration_test) are environmental network failures (DNS/Socket/real-timeout), not related to this change.
Style Pass Matches existing patterns; no debug residue; dynamic dispatch is localized and commented. Conventional Commits with scope, lowercase subjects, Co-Authored-By: Codex trailer on every commit. Branch fix/2565-interceptor-error-zones follows §8.1.
Risk Pass No public signature/typedef change, no SDK lower-bound increase, no Dart 3-only feature. Behavior delta vs 5.10.0 is narrow: a Future started inside a callback that is neither returned nor handled is no longer caught by a Dio-forked zone and will not unblock an incomplete handler — documented as an explicit ownership boundary. This edge case was incidental to #2499 (which targeted the async callback's own returned Future, now covered by the observer). Sensitive-area note: this touches the interceptor pipeline ordering/error propagation; the change reverts the #2499 zone boundary while preserving its hang fix, and the #2167 microtask-ordering regression is explicitly guarded by a test.
Docs Pass CHANGELOG.md updated under ## Unreleased with one user-facing bullet. No public API doc changes needed (no API surface change).
Repo constraints Pass Motivation grounded in #2565; one PR/one concern; regression test included; AI attribution present in PR description and commit trailers; no drive-by dependency bumps; no public-API break.

Confirmed key points

Suggestions (non-blocking)

  • The ownership-boundary rationale for unowned detached Futures is described in the PR body but not in a code comment near _observeInterceptorCallback; a one-line pointer comment there would help future maintainers understand why only returned Futures are observed.
  • Consider noting in the PR description that the second CHANGELOG bullet from an earlier commit was intentionally removed (commit 6b42fa2), so reviewers don't expect a "preserve wrapper overrides" changelog entry — the net behavior vs 5.10.0 for wrapper subclass overrides is unchanged.

Co-Authored-By: Codex <noreply@openai.com>
@AlexV525

Copy link
Copy Markdown
Member Author

The ownership-boundary rationale for unowned detached Futures is described in the PR body but not in a code comment near _observeInterceptorCallback; a one-line pointer comment there would help future maintainers understand why only returned Futures are observed.

Addressed in 64189f6. I added a focused comment next to _observeInterceptorCallback explaining that only the returned Future can be associated with the handler, while detached asynchronous work remains owned by the callback's zone.

Consider noting in the PR description that the second CHANGELOG bullet from an earlier commit was intentionally removed (commit 6b42fa2), so reviewers don't expect a "preserve wrapper overrides" changelog entry — the net behavior vs 5.10.0 for wrapper subclass overrides is unchanged.

Addressed in the PR description under API and Compatibility Boundaries. It now states that the wrapper-subclass checks are compatibility guards rather than a new user-visible behavior, and that 6b42fa2 intentionally removed the temporary separate CHANGELOG entry.

Thank you for the detailed review.

@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.25% 🟢 94.49% 🟢 0.24%
dio/lib/src/interceptor.dart 🟢 99.34% 🟢 99.46% 🟢 0.12%
Overall Coverage 🟢 85.73% 🟢 85.95% 🟢 0.22%

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

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

Review conclusion: Approve

Change summary

Removes the per-callback forked error zones introduced by #2499 (which broke cross-request Future error delivery for shared Completers in deduplication interceptors, #2565) and replaces them with a non-awaiting _observeInterceptorCallback listener. The listener preserves #2499's hang-prevention for async callbacks (returned Future errors reject/advance an incomplete handler) while restoring same-zone Future error delivery so shared-failure deduplication works again. Adds private _invokeRequest/_invokeResponse/_invokeError dynamic-dispatch entry points to capture the runtime Future of async on* overrides without changing the public void API, and rewires QueuedInterceptor._handleQueue and the wrapper mixin through the same observer.

Review details

Dimension Result Notes
Correctness Pass The observer is registered synchronously in the callback's existing zone, never awaits the Future, and keeps handler completion as the only pipeline signal. Incomplete-handler errors route to reject/next; complete-handler late errors route to callbackZone.handleUncaughtError, preserving #2499's late-error visibility. QueuedInterceptor returns null at the top-level _handle* so observation happens exactly once inside _handleQueue; FIFO and queue-release-on-cancel are unchanged. The dispatch callback's return null + observation also adds a safety net for non-DioException escapes from _dispatchRequest.
Tests Pass 52 interceptor/queued tests pass locally (dart test test/interceptor_test.dart test/queued_interceptor_test.dart). New coverage includes: the deterministic #2565 reproduction (asserts 2 requests, 1 adapter fetch, 2 joined errors, 0 uncaught, 0 pending), async Interceptor subclass errors on all three paths, InterceptorsWrapper/QueuedInterceptorsWrapper subclass override dispatch + async subclass errors, async constructor callbacks, queued request/response/error failures with queue release for the following request, the #2167 non-awaiting guard, and late-error-after-handler-completion reaching the callback zone. dio_mixin_test, cancel_token_test, adapters_test, exception_test also pass. The 7 failures in the full VM suite (test_suite_test, stacktrace_test, options_timeout_integration_test) are environmental network failures (DNS/Socket/real-timeout), not related to this change.
Style Pass Matches existing patterns; no debug residue; dynamic dispatch is localized and commented. Conventional Commits with scope, lowercase subjects, Co-Authored-By: Codex trailer on every commit. Branch fix/2565-interceptor-error-zones follows §8.1.
Risk Pass No public signature/typedef change, no SDK lower-bound increase, no Dart 3-only feature. Behavior delta vs 5.10.0 is narrow: a Future started inside a callback that is neither returned nor handled is no longer caught by a Dio-forked zone and will not unblock an incomplete handler — documented as an explicit ownership boundary. This edge case was incidental to #2499 (which targeted the async callback's own returned Future, now covered by the observer). Sensitive-area note: this touches the interceptor pipeline ordering/error propagation; the change reverts the #2499 zone boundary while preserving its hang fix, and the #2167 microtask-ordering regression is explicitly guarded by a test.
Docs Pass CHANGELOG.md updated under ## Unreleased with one user-facing bullet. No public API doc changes needed (no API surface change).
Repo constraints Pass Motivation grounded in #2565; one PR/one concern; regression test included; AI attribution present in PR description and commit trailers; no drive-by dependency bumps; no public-API break.

Confirmed key points

  • Correctness: cross-request shared-Future error delivery restored; #2499 async-throw hang prevention preserved via the observer; QueuedInterceptor queue ordering and cancel-release unchanged; wrapper subclass on* overrides dispatch correctly (private _invoke* overrides removed from the mixin).
  • Tests: #2565 regression test asserts the exact issue invariants (single fetch, both requests terminate, no uncaught error, no pending handler); #2167 non-awaiting guard prevents reintroducing the #2139 microtask regression; all 52 interceptor/queued tests pass locally.
  • Risk: no public API or SDK-constraint change; the only behavior delta (unowned detached Futures no longer caught) is a documented ownership boundary, not a regression of #2499's targeted case.

Suggestions (non-blocking)

  • The ownership-boundary rationale for unowned detached Futures is described in the PR body but not in a code comment near _observeInterceptorCallback; a one-line pointer comment there would help future maintainers understand why only returned Futures are observed.
  • Consider noting in the PR description that the second CHANGELOG bullet from an earlier commit was intentionally removed (commit 6b42fa2), so reviewers don't expect a "preserve wrapper overrides" changelog entry — the net behavior vs 5.10.0 for wrapper subclass overrides is unchanged.

Verification performed

  • dart analyze --fatal-infos lib/src/interceptor.dart lib/src/dio_mixin.dart: no issues.
  • dart test test/interceptor_test.dart test/queued_interceptor_test.dart: all 52 tests passed.
  • dart test test/dio_mixin_test.dart test/cancel_token_test.dart test/adapters_test.dart test/exception_test.dart: all passed (one skipped TLS test).
  • Full dart test suite: 241 passed, 6 skipped, 7 failed — all 7 failures are network-dependent environmental tests (DNS resolution, real socket connections, live timeouts) unrelated to this change.

@AlexV525
AlexV525 merged commit 7e48c85 into main Jul 19, 2026
5 checks passed
@AlexV525
AlexV525 deleted the fix/2565-interceptor-error-zones branch July 19, 2026 04:19
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.

Concurrent requests can expose unhandled errors in custom deduplication interceptors

2 participants