Repository navigation
Description
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:
-
runZonedGuardedis 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 thezoneValuesentry that letsGoRouter.ofresolve mid-redirect. -
Rethrowing from the error handler makes it uncaught. The handler runs on the async error path, long after
applyTopLegacyRedirect'stry/catchhas returned, so theGoExceptionit throws is caught by nothing. It escapes to the parent zone as an unhandled error. Note theif (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
mbcorona commentedon Aug 31, 2026
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.
[go_router] Fix redirect zone async error handling (flutter#12836)
github-actions commentedon Oct 28, 2026
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 -vand a minimal reproduction of the issue.