Skip to content

[go_router] An unrelated async failure that merely starts during a redirect is rethrown as a fatal GoException #192066

Closed
flutter/packages
#12836

Description

@hplawrence

Summary

RouteConfiguration._runInRouterZone wraps every redirect in runZonedGuarded, and its error handler rethrows whatever it catches as a GoException. But runZonedGuarded catches any uncaught async error whose future, timer, or stream subscription was merely created inside that zone — not just errors from the redirect itself.

So any async work a redirect kicks off stays parented to that zone for its entire lifetime. When it later fails — a background token refresh, a lazily-initialised client, a DI container warming a provider — go_router converts an unrelated, often harmless background failure into an uncaught GoException at fatal severity, arbitrarily long after the redirect returned successfully.

Expected results

An async failure that has nothing to do with routing should surface to the app's own zone as itself, exactly as it would if the same work had been started anywhere else.

Actual results

It surfaces as GoException: Exception during redirect: <original error>, uncaught, with no frame from the redirect anywhere in the stack.

Code sample

Self-contained reproduction (3 tests, all pass — i.e. all three behaviours confirmed)
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';

void main() {
  testWidgets(
    'an unrelated async failure started during a redirect is rethrown as a '
    'fatal GoException',
    (tester) async {
      final caught = <Object>[];

      await runZonedGuarded(() async {
        final router = GoRouter(
          routes: <RouteBase>[
            GoRoute(path: '/', builder: (_, _) => const Placeholder()),
          ],
          redirect: (context, state) {
            // Fire-and-forget async work kicked off during the redirect --
            // exactly what reading a provider or touching a client that
            // lazily refreshes a token does. Nothing is returned to
            // go_router; the redirect itself is synchronous and succeeds.
            Future<void>.error(StateError('background token refresh failed'));
            return null;
          },
        );

        await tester.pumpWidget(MaterialApp.router(routerConfig: router));
        await tester.pump();
      }, (error, stack) {
        caught.add(error);
      });

      await tester.pump();

      expect(caught, isNotEmpty, reason: 'the app zone should see an error');
      expect(caught.first, isA<GoException>());
      expect(caught.first.toString(), contains('Exception during redirect'));
      expect(caught.first.toString(), contains('background token refresh'));
    },
  );

  testWidgets(
    'CONTROL: the same failure outside a redirect stays a plain StateError',
    (tester) async {
      final caught = <Object>[];

      await runZonedGuarded(() async {
        final router = GoRouter(
          routes: <RouteBase>[
            GoRoute(path: '/', builder: (_, _) => const Placeholder()),
          ],
        );
        await tester.pumpWidget(MaterialApp.router(routerConfig: router));
        Future<void>.error(StateError('background token refresh failed'));
        await tester.pump();
      }, (error, stack) {
        caught.add(error);
      });

      await tester.pump();

      expect(caught, isNotEmpty);
      expect(caught.first, isA<StateError>());
      expect(caught.first, isNot(isA<GoException>()));
    },
  );

  testWidgets(
    'the zone keeps capturing long after the redirect returned (timer)',
    (tester) async {
      final caught = <Object>[];

      await runZonedGuarded(() async {
        final router = GoRouter(
          routes: <RouteBase>[
            GoRoute(path: '/', builder: (_, _) => const Placeholder()),
          ],
          redirect: (context, state) {
            Timer(const Duration(minutes: 5), () {
              throw StateError('a 504 five minutes later');
            });
            return null;
          },
        );
        await tester.pumpWidget(MaterialApp.router(routerConfig: router));
        await tester.pump(const Duration(minutes: 6));
      }, (error, stack) {
        caught.add(error);
      });

      expect(caught, isNotEmpty);
      expect(caught.first, isA<GoException>());
      expect(caught.first.toString(), contains('a 504 five minutes later'));
    },
  );
}

The control test is the important one: the identical failure, started one line outside the redirect, arrives as a plain StateError. go_router is what transforms it.

Root cause

packages/go_router/lib/src/configuration.dart — _runInRouterZone, byte-identical in 17.5.0 and 18.0.0:

runZonedGuarded<void>(
  () {
    result = callback();
  },
  (Object error, StackTrace stack) {
    errorOccurred = true;
    // Convert any exception during redirect to a GoException and rethrow
    final GoException goException = error is GoException
        ? error
        : GoException('Exception during redirect: $error');
    throw goException;
  },
  zoneValues: <Object?, Object?>{currentRouterKey: router},
);

Two things compound here:

  1. runZonedGuarded is the wrong tool for the stated goal. The comment says "any exception during redirect", but the zone's lifetime is not the redirect's — it is the lifetime of everything the redirect ever spawned. The only thing the zone is actually needed for is the zoneValues entry that lets GoRouter.of resolve mid-redirect.

  2. Rethrowing from the error handler makes it uncaught. The handler runs on the async error path, long after applyTopLegacyRedirect's try/catch has returned, so the GoException it throws is caught by nothing. It escapes to the parent zone as an unhandled error. Note the if (errorOccurred) branch below the call is dead for async errors, and its own comment ("This should not be reached") suggests only the synchronous path was considered.

Synchronous throws from a redirect are already handled correctly by the surrounding try/catch at both call sites, so the zone's error handler is only ever reached by async errors — which are exactly the ones it should not be claiming.

Suggested fix

Use a plain Zone.fork with only zoneValues, and no handleUncaughtError override:

T _runInRouterZone<T>(T Function() callback) {
  if (router == null) {
    return callback();
  }
  return Zone.current
      .fork(zoneValues: <Object?, Object?>{currentRouterKey: router})
      .run(callback);
}

This preserves GoRouter.of inside redirects and lets synchronous throws propagate to the existing try/catch unchanged, while leaving unrelated async errors to reach the app's own zone as themselves.

Real-world impact

Found in production. A Flutter web app using Riverpod + Supabase: the top-level redirect reads a provider, which lazily starts a Supabase profile fetch. A browser tab left open ~12 days took a 504 upstream request timeout on /auth/v1/token. gotrue's AuthRetryableFetchException — a retryable class the SDK handles and retries on its own — reached Sentry as:

GoException: Exception during redirect: AuthRetryableFetchException(message: upstream request timeout, statusCode: 504)

at level: fatal, mechanism: runZonedGuarded, handled: no, with no redirect frame in the stack. The app itself carried on fine; the routing had completed successfully minutes-to-days earlier. The practical damage is that transient backend blips are reported as fatal crashes attributed to routing, which buries real ones.

Any app whose redirect touches DI, auth, or a lazily-initialised network client is exposed to this, and the resulting reports point at the wrong subsystem.

Flutter Doctor / versions

Reproduced on go_router 17.5.0. _runInRouterZone is unchanged in 18.0.0 (not run directly only because it requires Dart ^3.12.0); originally hit in production on 17.2.3.

Flutter 3.41.9 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 00b0c91f06 (4 months ago) • 2026-04-29 10:03:19 -0700
Engine • hash 9161402dc0e134b3fb5adee5046b6e84b1a5e1c1 (revision 42d3d75a56)
Tools • Dart 3.11.5 • DevTools 2.54.2

Activity

  1. added
    packageflutter/packages repository. See also p: labels.
    in triagePresently being triaged by the triage team
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    and removed
    in triagePresently being triaged by the triage team
    on Aug 31, 2026
  2. mbcorona commented on Aug 31, 2026

    @mbcorona
    Member

    Hi @hplawrence, thanks for the analysis and the tests. Routing to the ecosystem team.

    Confirmed on main: _runInRouterZone still wraps redirects in runZonedGuarded and rethrows any caught error as a GoException, so async work a redirect kicks off stays parented to that zone and an unrelated later failure surfaces as a fatal, uncaught GoException attributed to routing.

  3. added
    P2Important issues not at the top of the work list
    on Sep 1, 2026
  4. added a commit that references this issue on Oct 14, 2026
    18a9c45
  5. github-actions commented on Oct 28, 2026

    @github-actions

    This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of flutter doctor -v and a minimal reproduction of the issue.

  6. locked as resolved and limited conversation to collaborators on Oct 28, 2026
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

    P2Important issues not at the top of the work listhas reproducible stepsThe issue has been confirmed reproducible and is ready to work onp: go_routerThe go_router packagepackageflutter/packages repository. See also p: labels.team-ecosystemOwned by Ecosystem teamtriaged-ecosystemTriaged by Ecosystem team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions