Skip to content

Duplicate handler completion can crash silently (untraceable) or throw an unguarded StateError during flaky connectivity #2613

Description

@ryanaidilp

Package

dio

Version

5.11.1

Operating-System

Android, iOS

Adapter

Default Dio

Output of flutter doctor -v

[✓] Flutter (Channel stable, 3.47.4, on macOS 26.6.2 25G83 darwin-arm64, locale en-ID) [2.1s]
    • Flutter version 3.47.4 on channel stable at /Users/ryanaidilp/fvm/versions/3.47.0
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 9584c6713b (6 days ago), 2026-09-10 15:25:10 -0700
    • Engine revision 06a2e2a110
    • Dart version 3.13.3
    • DevTools version 2.60.0
    • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets,
      enable-record-use, enable-swift-package-manager, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration

[✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0) [1,082ms]
    • Android SDK at Library/Android/sdk
    • Emulator version 37.1.11.0 (build_id 15917651) (CL:N/A)
    • Platform android-36, build-tools 36.0.0
    • ANDROID_HOME = /Library/Android/sdk
    • Java binary at: /Users/ryanaidilp/.sdkman/candidates/java/current/bin/java
      This JDK is specified in your Flutter configuration.
      To change the current JDK, run: `flutter config --jdk-dir="path/to/jdk"`.
    • Java version OpenJDK Runtime Environment Temurin-17.0.4.1+1 (build 17.0.4.1+1)
    • All Android licenses accepted.

[!] Xcode - develop for iOS and macOS (Xcode 27.0) [1,277ms]
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Build 27A266a
    ! iOS 27.0 Simulator not installed; this may be necessary for iOS and macOS development.
      To download and install the platform, open Xcode, select Xcode > Settings > Components,
      and click the GET button for the required platform.

      For more information, please visit:
        https://developer.apple.com/documentation/xcode/installing-additional-simulator-runtimes
    • CocoaPods version 1.16.2

[✓] Chrome - develop for the web [7ms]
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Connected device (2 available) [6.6s]
    • macOS (desktop) • macos  • darwin-arm64   • macOS 26.6.2 25G83 darwin-arm64
    • Chrome (web)    • chrome • web-javascript • Google Chrome 152.0.7977.84

[✓] Network resources [800ms]
    • All expected network resources are available.

Dart Version

3.12.1

Steps to Reproduce

  1. Register an async interceptor whose onError/onRequest/onResponse completes the handler (handler.resolve/reject/next), then — after an await — a bug in the same callback (or a racing cancellation) completes the handler a second time. This is a realistic shape for retry-after-reconnect interceptors, which commonly race during flaky connectivity (airplane-mode toggles, cellular↔WiFi handoff) since multiple async completions can overlap.
  2. Run the following script with dart run (place under dio/test/):
import 'dart:async';
import 'dart:typed_data';
import 'package:dio/dio.dart';
class RetryAfterAuthInterceptor extends Interceptor {
  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    final fakeRetryResponse = Response(      requestOptions: err.requestOptions,
      statusCode: 200,
      data: 'retried-ok',
    );    handler.resolve(fakeRetryResponse);

    await Future<void>.delayed(const Duration(milliseconds: 10));
    handler.reject(err); // BUG in the interceptor: already resolved above.  }}class AlwaysFailAdapter implements HttpClientAdapter {  @override
  Future<ResponseBody> fetch(    RequestOptions options,
    Stream<Uint8List>? requestStream,    Future<void>? cancelFuture,
  ) async {    throw DioException(      requestOptions: options,      type: DioExceptionType.badResponse,      response: Response(requestOptions: options, statusCode: 401),    );  }  @override  void close({bool force = false}) {}}

Future<void> main() async {
  final errors = <Object>[];
  final zone = Zone.current.fork(
    specification: ZoneSpecification(
      handleUncaughtError: (self, parent, zone, error, stack) {
        errors.add(error);
        print('UNCAUGHT (zone, NOT delivered to caller): $error');
      },
    ),
  );

  await zone.run(() async {
    final dio = Dio()
      ..interceptors.add(RetryAfterAuthInterceptor())
      ..httpClientAdapter = AlwaysFailAdapter();

    try {
      final response = await dio.get<String>('/resource');
      print('Call site got a normal response: ${response.data}');
    } catch (e) {
      print('Caught at call site: $e');
    }

    await Future<void>.delayed(const Duration(milliseconds: 200));
  });

  print('zone-level uncaught errors: $errors');
}

We hit the real (non-synthetic) version of this in production, correlating reliably with flaky connectivity — a race between a connection-retry interceptor and cancellation.

Expected Result

  • _BaseHandler._throwIfCompleted() (lib/src/interceptor.dart) should throw a StateError that identifies which request it belongs to, so it's traceable even when delivered outside the normal DioException channel (e.g. via Zone.handleUncaughtError).
  • The dispatch stage in DioMixin.fetch (lib/src/dio_mixin.dart) should not attempt to complete a handler that a racing cancellation already completed — currently handler.resolve(value, true) / handler.reject(e, true) after _dispatchRequest run unconditionally, unlike the equivalent guarded path in _observeInterceptorCallback.

Actual Result

Call site got a normal response: retried-ok
UNCAUGHT (zone, NOT delivered to caller): Bad state: The `handler` has already been called, make sure each handler gets called only once.
zone-level uncaught errors: [Bad state: The `handler` has already been called, make sure each handler gets called only once.]

The caller's request appears to succeed; the actual failure is a detached, contextless, uncaught error with no way to correlate it to a request or interceptor. In production this surfaces as a bare StateError in crash reporting.

We have a tested fix (PR #2611) threading RequestOptions through _BaseHandler for message context, and adding isCompleted guards to the dispatch stage.

Once you file it, send me the new issue number and I'll update PR #2611's "Fixes #" reference and the commit message to point at it.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    h: need triageThis issue needs to be categorizeds: bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions