[go_router] Adds onException to GoRouter constructor. (#4216)

fixes https://github.com/flutter/flutter/issues/108144
diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md
index b530a9a..3dc0e2f 100644
--- a/packages/go_router/CHANGELOG.md
+++ b/packages/go_router/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 8.2.0
+
+- Adds onException to GoRouter constructor.
+
 ## 8.1.0
 
 - Adds parent navigator key to ShellRoute and StatefulShellRoute.
@@ -8,7 +12,7 @@
 
 ## 8.0.4
 
-- Updates documentations around `GoRouter.of`, `GoRouter.maybeOf`, and `BuildContext` extension. 
+- Updates documentations around `GoRouter.of`, `GoRouter.maybeOf`, and `BuildContext` extension.
 
 ## 8.0.3
 
diff --git a/packages/go_router/doc/error-handling.md b/packages/go_router/doc/error-handling.md
index 1021189..0dd1de8 100644
--- a/packages/go_router/doc/error-handling.md
+++ b/packages/go_router/doc/error-handling.md
@@ -1,12 +1,34 @@
-By default, go_router comes with default error screens for both `MaterialApp`
-and `CupertinoApp` as well as a default error screen in the case that none is
-used. You can also replace the default error screen by using the
-[errorBuilder](https://pub.dev/documentation/go_router/latest/go_router/GoRouter/GoRouter.html)
-parameter:
+There are several kinds of errors or exceptions in go_router.
 
+* GoError and AssertionError 
+
+This kind of errors are thrown when go_router is used incorrectly, for example, if the root
+[GoRoute.path](https://pub.dev/documentation/go_router/latest/go_router/GoRoute/path.html) does
+not start with `/` or a builder in GoRoute is not provided. These errors should not be caught and
+must be fixed in code in order to use go_router.
+
+* GoException
+
+This kind of exception are thrown when the configuration of go_router cannot handle incoming requests
+from users or other part of the code. For example, an GoException is thrown when user enter url that
+can't be parsed according to pattern specified in the `GoRouter.routes`. These exceptions can be
+handled in various callbacks.
+
+Once can provide a callback to `GoRouter.onException` to handle this exception. In this callback,
+one can choose to ignore, redirect, or push different pages depending on the situation.
+See [Exception Handling](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/exception_handling.dart)
+on a runnable example.
+
+The `GoRouter.errorBuilder` and `GoRouter.errorPageBuilder` can also be used to handle exceptions.
 ```dart
 GoRouter(
   /* ... */
   errorBuilder: (context, state) => ErrorScreen(state.error),
 );
 ```
+
+By default, go_router comes with default error screens for both `MaterialApp`
+and `CupertinoApp` as well as a default error screen in the case that none is
+used.
+
+**Note** the `GoRouter.onException` supersedes other exception handling APIs.
\ No newline at end of file
diff --git a/packages/go_router/example/README.md b/packages/go_router/example/README.md
index f4c1c0f..55f75bc 100644
--- a/packages/go_router/example/README.md
+++ b/packages/go_router/example/README.md
@@ -36,6 +36,11 @@
 An example to demonstrate how to use a `StatefulShellRoute` to create stateful nested navigation, with a
 `BottomNavigationBar`.
 
+## [Exception Handling](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/exception_handling.dart)
+`flutter run lib/exception_handling.dart`
+
+An example to demonstrate how to handle exception in go_router.
+
 ## [Books app](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/books)
 `flutter run lib/books/main.dart`
 
diff --git a/packages/go_router/example/lib/exception_handling.dart b/packages/go_router/example/lib/exception_handling.dart
new file mode 100644
index 0000000..583e35e
--- /dev/null
+++ b/packages/go_router/example/lib/exception_handling.dart
@@ -0,0 +1,87 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+
+/// This sample app shows how to use `GoRouter.onException` to redirect on
+/// exception.
+///
+/// The first route '/' is mapped to [HomeScreen], and the second route
+/// '/404' is mapped to [NotFoundScreen].
+///
+/// Any other unknown route or exception is redirected to `/404`.
+void main() => runApp(const MyApp());
+
+/// The route configuration.
+final GoRouter _router = GoRouter(
+  onException: (_, GoRouterState state, GoRouter router) {
+    router.go('/404', extra: state.location);
+  },
+  routes: <RouteBase>[
+    GoRoute(
+      path: '/',
+      builder: (BuildContext context, GoRouterState state) {
+        return const HomeScreen();
+      },
+    ),
+    GoRoute(
+      path: '/404',
+      builder: (BuildContext context, GoRouterState state) {
+        return NotFoundScreen(uri: state.extra as String? ?? '');
+      },
+    ),
+  ],
+);
+
+/// The main app.
+class MyApp extends StatelessWidget {
+  /// Constructs a [MyApp]
+  const MyApp({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp.router(
+      routerConfig: _router,
+    );
+  }
+}
+
+/// The home screen
+class HomeScreen extends StatelessWidget {
+  /// Constructs a [HomeScreen]
+  const HomeScreen({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(title: const Text('Home Screen')),
+      body: Center(
+        child: ElevatedButton(
+          onPressed: () => context.go('/some-unknown-route'),
+          child: const Text('Simulates user entering unknown url'),
+        ),
+      ),
+    );
+  }
+}
+
+/// The not found screen
+class NotFoundScreen extends StatelessWidget {
+  /// Constructs a [HomeScreen]
+  const NotFoundScreen({super.key, required this.uri});
+
+  /// The uri that can not be found.
+  final String uri;
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(title: const Text('Page Not Found')),
+      body: Center(
+        child: Text("Can't find a page for: $uri"),
+      ),
+    );
+  }
+}
diff --git a/packages/go_router/example/lib/main.dart b/packages/go_router/example/lib/main.dart
index e452471..d159648 100644
--- a/packages/go_router/example/lib/main.dart
+++ b/packages/go_router/example/lib/main.dart
@@ -58,14 +58,9 @@
     return Scaffold(
       appBar: AppBar(title: const Text('Home Screen')),
       body: Center(
-        child: Column(
-          mainAxisAlignment: MainAxisAlignment.center,
-          children: <Widget>[
-            ElevatedButton(
-              onPressed: () => context.go('/details'),
-              child: const Text('Go to the Details screen'),
-            ),
-          ],
+        child: ElevatedButton(
+          onPressed: () => context.go('/details'),
+          child: const Text('Go to the Details screen'),
         ),
       ),
     );
@@ -82,14 +77,9 @@
     return Scaffold(
       appBar: AppBar(title: const Text('Details Screen')),
       body: Center(
-        child: Column(
-          mainAxisAlignment: MainAxisAlignment.center,
-          children: <ElevatedButton>[
-            ElevatedButton(
-              onPressed: () => context.go('/'),
-              child: const Text('Go back to the Home screen'),
-            ),
-          ],
+        child: ElevatedButton(
+          onPressed: () => context.go('/'),
+          child: const Text('Go back to the Home screen'),
         ),
       ),
     );
diff --git a/packages/go_router/example/lib/redirection.dart b/packages/go_router/example/lib/redirection.dart
index 868944c..aded4b7 100644
--- a/packages/go_router/example/lib/redirection.dart
+++ b/packages/go_router/example/lib/redirection.dart
@@ -104,20 +104,15 @@
   Widget build(BuildContext context) => Scaffold(
         appBar: AppBar(title: const Text(App.title)),
         body: Center(
-          child: Column(
-            mainAxisAlignment: MainAxisAlignment.center,
-            children: <Widget>[
-              ElevatedButton(
-                onPressed: () {
-                  // log a user in, letting all the listeners know
-                  context.read<LoginInfo>().login('test-user');
+          child: ElevatedButton(
+            onPressed: () {
+              // log a user in, letting all the listeners know
+              context.read<LoginInfo>().login('test-user');
 
-                  // router will automatically redirect from /login to / using
-                  // refreshListenable
-                },
-                child: const Text('Login'),
-              ),
-            ],
+              // router will automatically redirect from /login to / using
+              // refreshListenable
+            },
+            child: const Text('Login'),
           ),
         ),
       );
diff --git a/packages/go_router/example/test/exception_handling_test.dart b/packages/go_router/example/test/exception_handling_test.dart
new file mode 100644
index 0000000..68a7310
--- /dev/null
+++ b/packages/go_router/example/test/exception_handling_test.dart
@@ -0,0 +1,18 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:go_router_examples/exception_handling.dart' as example;
+
+void main() {
+  testWidgets('example works', (WidgetTester tester) async {
+    await tester.pumpWidget(const example.MyApp());
+    expect(find.text('Simulates user entering unknown url'), findsOneWidget);
+
+    await tester.tap(find.text('Simulates user entering unknown url'));
+    await tester.pumpAndSettle();
+    expect(find.text("Can't find a page for: /some-unknown-route"),
+        findsOneWidget);
+  });
+}
diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart
index 865007d..a1969d6 100644
--- a/packages/go_router/lib/src/configuration.dart
+++ b/packages/go_router/lib/src/configuration.dart
@@ -184,16 +184,33 @@
   }
 
   /// The match used when there is an error during parsing.
-  static RouteMatchList _errorRouteMatchList(Uri uri, String errorMessage) {
-    final Exception error = Exception(errorMessage);
+  static RouteMatchList _errorRouteMatchList(Uri uri, GoException exception) {
     return RouteMatchList(
       matches: const <RouteMatch>[],
-      error: error,
+      error: exception,
       uri: uri,
       pathParameters: const <String, String>{},
     );
   }
 
+  /// Builds a [GoRouterState] suitable for top level callback such as
+  /// `GoRouter.redirect` or `GoRouter.onException`.
+  GoRouterState buildTopLevelGoRouterState(RouteMatchList matchList) {
+    return GoRouterState(
+      this,
+      location: matchList.uri.toString(),
+      // No name available at the top level trim the query params off the
+      // sub-location to match route.redirect
+      fullPath: matchList.fullPath,
+      pathParameters: matchList.pathParameters,
+      matchedLocation: matchList.uri.path,
+      queryParameters: matchList.uri.queryParameters,
+      queryParametersAll: matchList.uri.queryParametersAll,
+      extra: matchList.extra,
+      pageKey: const ValueKey<String>('topLevel'),
+    );
+  }
+
   /// The list of top level routes used by [GoRouterDelegate].
   final List<RouteBase> routes;
 
@@ -257,7 +274,8 @@
     final List<RouteMatch>? matches = _getLocRouteMatches(uri, pathParameters);
 
     if (matches == null) {
-      return _errorRouteMatchList(uri, 'no routes for location: $uri');
+      return _errorRouteMatchList(
+          uri, GoException('no routes for location: $uri'));
     }
     return RouteMatchList(
         matches: matches,
@@ -411,19 +429,7 @@
       // Check for top-level redirect
       final FutureOr<String?> topRedirectResult = topRedirect(
         context,
-        GoRouterState(
-          this,
-          location: prevLocation,
-          // No name available at the top level trim the query params off the
-          // sub-location to match route.redirect
-          fullPath: prevMatchList.fullPath,
-          pathParameters: prevMatchList.pathParameters,
-          matchedLocation: prevMatchList.uri.path,
-          queryParameters: prevMatchList.uri.queryParameters,
-          queryParametersAll: prevMatchList.uri.queryParametersAll,
-          extra: prevMatchList.extra,
-          pageKey: const ValueKey<String>('topLevel'),
-        ),
+        buildTopLevelGoRouterState(prevMatchList),
       );
 
       if (topRedirectResult is String?) {
@@ -485,9 +491,9 @@
       final RouteMatchList newMatch = findMatch(newLocation);
       _addRedirect(redirectHistory, newMatch, previousLocation);
       return newMatch;
-    } on RedirectionError catch (e) {
-      log.info('Redirection error: ${e.message}');
-      return _errorRouteMatchList(e.location, e.message);
+    } on GoException catch (e) {
+      log.info('Redirection exception: ${e.message}');
+      return _errorRouteMatchList(previousLocation, e);
     }
   }
 
@@ -500,12 +506,18 @@
     Uri prevLocation,
   ) {
     if (redirects.contains(newMatch)) {
-      throw RedirectionError('redirect loop detected',
-          <RouteMatchList>[...redirects, newMatch], prevLocation);
+      throw GoException(
+          'redirect loop detected ${_formatRedirectionHistory(<RouteMatchList>[
+            ...redirects,
+            newMatch
+          ])}');
     }
     if (redirects.length > redirectLimit) {
-      throw RedirectionError('too many redirects',
-          <RouteMatchList>[...redirects, newMatch], prevLocation);
+      throw GoException(
+          'too many redirects ${_formatRedirectionHistory(<RouteMatchList>[
+            ...redirects,
+            newMatch
+          ])}');
     }
 
     redirects.add(newMatch);
@@ -513,6 +525,13 @@
     log.info('redirecting to $newMatch');
   }
 
+  String _formatRedirectionHistory(List<RouteMatchList> redirections) {
+    return redirections
+        .map<String>(
+            (RouteMatchList routeMatches) => routeMatches.uri.toString())
+        .join(' => ');
+  }
+
   /// Get the location for the provided route.
   ///
   /// Builds the absolute path for the route, by concatenating the paths of the
diff --git a/packages/go_router/lib/src/delegate.dart b/packages/go_router/lib/src/delegate.dart
index 3798671..1d382db 100644
--- a/packages/go_router/lib/src/delegate.dart
+++ b/packages/go_router/lib/src/delegate.dart
@@ -7,6 +7,7 @@
 import 'package:flutter/foundation.dart';
 import 'package:flutter/widgets.dart';
 
+import '../go_router.dart';
 import 'builder.dart';
 import 'configuration.dart';
 import 'match.dart';
@@ -25,6 +26,7 @@
     required GoRouterWidgetBuilder? errorBuilder,
     required List<NavigatorObserver> observers,
     required this.routerNeglect,
+    required this.onException,
     String? restorationScopeId,
   }) : _configuration = configuration {
     builder = RouteBuilder(
@@ -45,6 +47,13 @@
   /// Set to true to disable creating history entries on the web.
   final bool routerNeglect;
 
+  /// The exception handler that is called when parser can't handle the incoming
+  /// uri.
+  ///
+  /// If this is null, the exception is handled in the
+  /// [RouteBuilder.errorPageBuilder] or [RouteBuilder.errorBuilder].
+  final GoExceptionHandler? onException;
+
   final RouteConfiguration _configuration;
 
   _NavigatorStateIterator _createNavigatorStateIterator() =>
@@ -131,9 +140,11 @@
   /// For use by the Router architecture as part of the RouterDelegate.
   @override
   Future<void> setNewRoutePath(RouteMatchList configuration) {
-    currentConfiguration = configuration;
+    if (currentConfiguration != configuration) {
+      currentConfiguration = configuration;
+      notifyListeners();
+    }
     assert(currentConfiguration.isNotEmpty || currentConfiguration.isError);
-    notifyListeners();
     // Use [SynchronousFuture] so that the initial url is processed
     // synchronously and remove unwanted initial animations on deep-linking
     return SynchronousFuture<void>(null);
diff --git a/packages/go_router/lib/src/match.dart b/packages/go_router/lib/src/match.dart
index e048331..865ab0b 100644
--- a/packages/go_router/lib/src/match.dart
+++ b/packages/go_router/lib/src/match.dart
@@ -10,6 +10,7 @@
 import 'package:flutter/widgets.dart';
 
 import 'configuration.dart';
+import 'misc/errors.dart';
 import 'path_utils.dart';
 
 /// An matched result by matching a [RouteBase] against a location.
@@ -183,7 +184,7 @@
   final Object? extra;
 
   /// An exception if there was an error during matching.
-  final Exception? error;
+  final GoException? error;
 
   /// the full path pattern that matches the uri.
   ///
diff --git a/packages/go_router/lib/src/misc/errors.dart b/packages/go_router/lib/src/misc/errors.dart
index 045e2ea..7f16378 100644
--- a/packages/go_router/lib/src/misc/errors.dart
+++ b/packages/go_router/lib/src/misc/errors.dart
@@ -2,8 +2,6 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import '../match.dart';
-
 /// Thrown when [GoRouter] is used incorrectly.
 class GoError extends Error {
   /// Constructs a [GoError]
@@ -16,23 +14,14 @@
   String toString() => 'GoError: $message';
 }
 
-/// A configuration error detected while processing redirects.
-class RedirectionError extends Error implements UnsupportedError {
-  /// RedirectionError constructor.
-  RedirectionError(this.message, this.matches, this.location);
+/// Thrown when [GoRouter] can not handle a user request.
+class GoException implements Exception {
+  /// Creates an exception with message describing the reason.
+  GoException(this.message);
 
-  /// The matches that were found while processing redirects.
-  final List<RouteMatchList> matches;
-
-  @override
+  /// The reason that causes this exception.
   final String message;
 
-  /// The location that was originally navigated to, before redirection began.
-  final Uri location;
-
   @override
-  String toString() => '${super.toString()} ${<String>[
-        ...matches
-            .map((RouteMatchList routeMatches) => routeMatches.uri.toString()),
-      ].join(' => ')}';
+  String toString() => 'GoException: $message';
 }
diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart
index 1790241..1a9dcca 100644
--- a/packages/go_router/lib/src/parser.dart
+++ b/packages/go_router/lib/src/parser.dart
@@ -14,17 +14,36 @@
 import 'logging.dart';
 import 'match.dart';
 
+/// The function signature of [GoRouteInformationParser.onParserException].
+///
+/// The `routeMatchList` parameter contains the exception explains the issue
+/// occurred.
+///
+/// The returned [RouteMatchList] is used as parsed result for the
+/// [GoRouterDelegate].
+typedef ParserExceptionHandler = RouteMatchList Function(
+  BuildContext context,
+  RouteMatchList routeMatchList,
+);
+
 /// Converts between incoming URLs and a [RouteMatchList] using [RouteMatcher].
 /// Also performs redirection using [RouteRedirector].
 class GoRouteInformationParser extends RouteInformationParser<RouteMatchList> {
   /// Creates a [GoRouteInformationParser].
   GoRouteInformationParser({
     required this.configuration,
+    required this.onParserException,
   }) : _routeMatchListCodec = RouteMatchListCodec(configuration);
 
   /// The route configuration used for parsing [RouteInformation]s.
   final RouteConfiguration configuration;
 
+  /// The exception handler that is called when parser can't handle the incoming
+  /// uri.
+  ///
+  /// This method must return a [RouteMatchList] for the parsed result.
+  final ParserExceptionHandler? onParserException;
+
   final RouteMatchListCodec _routeMatchListCodec;
 
   final Random _random = Random();
@@ -50,7 +69,13 @@
       // the state.
       final RouteMatchList matchList =
           _routeMatchListCodec.decode(state as Map<Object?, Object?>);
-      return debugParserFuture = _redirect(context, matchList);
+      return debugParserFuture = _redirect(context, matchList)
+          .then<RouteMatchList>((RouteMatchList value) {
+        if (value.isError && onParserException != null) {
+          return onParserException!(context, value);
+        }
+        return value;
+      });
     }
 
     late final RouteMatchList initialMatches;
@@ -70,6 +95,9 @@
       context,
       initialMatches,
     ).then<RouteMatchList>((RouteMatchList matchList) {
+      if (matchList.isError && onParserException != null) {
+        return onParserException!(context, matchList);
+      }
       return _updateRouteMatchList(
         matchList,
         baseRouteMatchList: state.baseRouteMatchList,
diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart
index 15ceea6..745fc3a 100644
--- a/packages/go_router/lib/src/router.dart
+++ b/packages/go_router/lib/src/router.dart
@@ -14,6 +14,15 @@
 import 'parser.dart';
 import 'typedefs.dart';
 
+/// The function signature of [GoRouter.onException].
+///
+/// Use `state.error` to access the exception.
+typedef GoExceptionHandler = void Function(
+  BuildContext context,
+  GoRouterState state,
+  GoRouter router,
+);
+
 /// The route configuration for the app.
 ///
 /// The `routes` list specifies the top-level routes for the app. It must not be
@@ -30,6 +39,15 @@
 /// implemented), a re-evaluation will be triggered when the [InheritedWidget]
 /// changes.
 ///
+/// To handle exceptions, use one of `onException`, `errorBuilder`, or
+/// `errorPageBuilder`. The `onException` is called when an exception is thrown.
+/// If `onException` is not provided, the exception is passed to
+/// `errorPageBuilder` to build a page for the Router if it is not null;
+/// otherwise, it is passed to `errorBuilder` instead. If none of them are
+/// provided, go_router builds a default error screen to show the exception.
+/// See [Error handling](https://pub.dev/documentation/go_router/latest/topics/error-handling.html)
+/// for more details.
+///
 /// See also:
 /// * [Configuration](https://pub.dev/documentation/go_router/latest/topics/Configuration-topic.html)
 /// * [GoRoute], which provides APIs to define the routing table.
@@ -51,8 +69,7 @@
   /// The `routes` must not be null and must contain an [GoRouter] to match `/`.
   GoRouter({
     required List<RouteBase> routes,
-    // TODO(johnpryan): Change to a route, improve error API
-    // See https://github.com/flutter/flutter/issues/108144
+    GoExceptionHandler? onException,
     GoRouterPageBuilder? errorPageBuilder,
     GoRouterWidgetBuilder? errorBuilder,
     GoRouterRedirect? redirect,
@@ -70,6 +87,12 @@
           initialExtra == null || initialLocation != null,
           'initialLocation must be set in order to use initialExtra',
         ),
+        assert(
+            (onException == null ? 0 : 1) +
+                    (errorPageBuilder == null ? 0 : 1) +
+                    (errorBuilder == null ? 0 : 1) <
+                2,
+            'Only one of onException, errorPageBuilder, or errorBuilder can be provided.'),
         assert(_debugCheckPath(routes, true)),
         assert(
             _debugVerifyNoDuplicatePathParameter(routes, <String, GoRoute>{})),
@@ -90,7 +113,21 @@
       navigatorKey: navigatorKey,
     );
 
+    final ParserExceptionHandler? parserExceptionHandler;
+    if (onException != null) {
+      parserExceptionHandler =
+          (BuildContext context, RouteMatchList routeMatchList) {
+        onException(context,
+            configuration.buildTopLevelGoRouterState(routeMatchList), this);
+        // Avoid updating GoRouterDelegate if onException is provided.
+        return routerDelegate.currentConfiguration;
+      };
+    } else {
+      parserExceptionHandler = null;
+    }
+
     routeInformationParser = GoRouteInformationParser(
+      onParserException: parserExceptionHandler,
       configuration: configuration,
     );
 
@@ -102,6 +139,7 @@
 
     routerDelegate = GoRouterDelegate(
       configuration: configuration,
+      onException: onException,
       errorPageBuilder: errorPageBuilder,
       errorBuilder: errorBuilder,
       routerNeglect: routerNeglect,
diff --git a/packages/go_router/lib/src/state.dart b/packages/go_router/lib/src/state.dart
index 13ee9ad..89400e3 100644
--- a/packages/go_router/lib/src/state.dart
+++ b/packages/go_router/lib/src/state.dart
@@ -72,8 +72,8 @@
   /// An extra object to pass along with the navigation.
   final Object? extra;
 
-  /// The error associated with this match.
-  final Exception? error;
+  /// The error associated with this sub-route.
+  final GoException? error;
 
   /// A unique string key for this sub-route.
   /// E.g.
diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml
index ded370a..7737087 100644
--- a/packages/go_router/pubspec.yaml
+++ b/packages/go_router/pubspec.yaml
@@ -1,7 +1,7 @@
 name: go_router
 description: A declarative router for Flutter based on Navigation 2 supporting
   deep linking, data-driven routes and more
-version: 8.1.0
+version: 8.2.0
 repository: https://github.com/flutter/packages/tree/main/packages/go_router
 issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router%22
 
diff --git a/packages/go_router/test/exception_handling_test.dart b/packages/go_router/test/exception_handling_test.dart
new file mode 100644
index 0000000..5afd103
--- /dev/null
+++ b/packages/go_router/test/exception_handling_test.dart
@@ -0,0 +1,97 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:go_router/go_router.dart';
+
+import 'test_helpers.dart';
+
+void main() {
+  testWidgets('throws if more than one exception handlers are provided.',
+      (WidgetTester tester) async {
+    bool thrown = false;
+    try {
+      GoRouter(
+        routes: <RouteBase>[
+          GoRoute(
+              path: '/',
+              builder: (_, GoRouterState state) => const Text('home')),
+        ],
+        errorBuilder: (_, __) => const Text(''),
+        onException: (_, __, ___) {},
+      );
+    } on Error {
+      thrown = true;
+    }
+    expect(thrown, true);
+
+    thrown = false;
+    try {
+      GoRouter(
+        routes: <RouteBase>[
+          GoRoute(
+              path: '/',
+              builder: (_, GoRouterState state) => const Text('home')),
+        ],
+        errorBuilder: (_, __) => const Text(''),
+        errorPageBuilder: (_, __) => const MaterialPage<void>(child: Text('')),
+      );
+    } on Error {
+      thrown = true;
+    }
+    expect(thrown, true);
+
+    thrown = false;
+    try {
+      GoRouter(
+        routes: <RouteBase>[
+          GoRoute(
+              path: '/',
+              builder: (_, GoRouterState state) => const Text('home')),
+        ],
+        onException: (_, __, ___) {},
+        errorPageBuilder: (_, __) => const MaterialPage<void>(child: Text('')),
+      );
+    } on Error {
+      thrown = true;
+    }
+    expect(thrown, true);
+  });
+
+  group('onException', () {
+    testWidgets('can redirect.', (WidgetTester tester) async {
+      final GoRouter router = await createRouter(<RouteBase>[
+        GoRoute(
+            path: '/error',
+            builder: (_, GoRouterState state) =>
+                Text('redirected ${state.extra}')),
+      ], tester,
+          onException: (_, GoRouterState state, GoRouter router) =>
+              router.go('/error', extra: state.location));
+      expect(find.text('redirected /'), findsOneWidget);
+
+      router.go('/some-other-location');
+      await tester.pumpAndSettle();
+      expect(find.text('redirected /some-other-location'), findsOneWidget);
+    });
+
+    testWidgets('stays on the same page if noop.', (WidgetTester tester) async {
+      final GoRouter router = await createRouter(
+        <RouteBase>[
+          GoRoute(
+              path: '/',
+              builder: (_, GoRouterState state) => const Text('home')),
+        ],
+        tester,
+        onException: (_, __, ___) {},
+      );
+      expect(find.text('home'), findsOneWidget);
+
+      router.go('/some-other-location');
+      await tester.pumpAndSettle();
+      expect(find.text('home'), findsOneWidget);
+    });
+  });
+}
diff --git a/packages/go_router/test/go_router_test.dart b/packages/go_router/test/go_router_test.dart
index 2ebb4af..1d4b059 100644
--- a/packages/go_router/test/go_router_test.dart
+++ b/packages/go_router/test/go_router_test.dart
@@ -127,7 +127,12 @@
         GoRoute(path: '/', builder: dummy),
       ];
 
-      final GoRouter router = await createRouter(routes, tester);
+      final GoRouter router = await createRouter(
+        routes,
+        tester,
+        errorBuilder: (BuildContext context, GoRouterState state) =>
+            TestErrorScreen(state.error!),
+      );
       router.go('/foo');
       await tester.pumpAndSettle();
       final List<RouteMatch> matches =
@@ -1889,14 +1894,18 @@
     });
 
     testWidgets('top-level redirect loop', (WidgetTester tester) async {
-      final GoRouter router = await createRouter(<GoRoute>[], tester,
-          redirect: (BuildContext context, GoRouterState state) =>
-              state.matchedLocation == '/'
-                  ? '/login'
-                  : state.matchedLocation == '/login'
-                      ? '/'
-                      : null);
-
+      final GoRouter router = await createRouter(
+        <GoRoute>[],
+        tester,
+        redirect: (BuildContext context, GoRouterState state) =>
+            state.matchedLocation == '/'
+                ? '/login'
+                : state.matchedLocation == '/login'
+                    ? '/'
+                    : null,
+        errorBuilder: (BuildContext context, GoRouterState state) =>
+            TestErrorScreen(state.error!),
+      );
       final List<RouteMatch> matches =
           router.routerDelegate.currentConfiguration.matches;
       expect(matches, hasLength(0));
@@ -1921,6 +1930,8 @@
           ),
         ],
         tester,
+        errorBuilder: (BuildContext context, GoRouterState state) =>
+            TestErrorScreen(state.error!),
       );
 
       final List<RouteMatch> matches =
@@ -1944,6 +1955,8 @@
         tester,
         redirect: (BuildContext context, GoRouterState state) =>
             state.matchedLocation == '/' ? '/login' : null,
+        errorBuilder: (BuildContext context, GoRouterState state) =>
+            TestErrorScreen(state.error!),
       );
 
       final List<RouteMatch> matches =
@@ -1966,6 +1979,8 @@
                 : state.matchedLocation == '/login'
                     ? '/'
                     : null,
+        errorBuilder: (BuildContext context, GoRouterState state) =>
+            TestErrorScreen(state.error!),
       );
 
       final List<RouteMatch> matches =
@@ -2153,6 +2168,8 @@
         tester,
         redirect: (BuildContext context, GoRouterState state) =>
             '/${state.location}+',
+        errorBuilder: (BuildContext context, GoRouterState state) =>
+            TestErrorScreen(state.error!),
         redirectLimit: 10,
       );
 
diff --git a/packages/go_router/test/parser_test.dart b/packages/go_router/test/parser_test.dart
index 253790f..ad2573f 100644
--- a/packages/go_router/test/parser_test.dart
+++ b/packages/go_router/test/parser_test.dart
@@ -198,7 +198,7 @@
     expect(matchesObj.uri.toString(), '/def');
     expect(matchesObj.extra, isNull);
     expect(matchesObj.error!.toString(),
-        'Exception: no routes for location: /def');
+        'GoException: no routes for location: /def');
   });
 
   testWidgets(
diff --git a/packages/go_router/test/test_helpers.dart b/packages/go_router/test/test_helpers.dart
index 5383838..229452c 100644
--- a/packages/go_router/test/test_helpers.dart
+++ b/packages/go_router/test/test_helpers.dart
@@ -149,16 +149,16 @@
   GlobalKey<NavigatorState>? navigatorKey,
   GoRouterWidgetBuilder? errorBuilder,
   String? restorationScopeId,
+  GoExceptionHandler? onException,
 }) async {
   final GoRouter goRouter = GoRouter(
     routes: routes,
     redirect: redirect,
     initialLocation: initialLocation,
+    onException: onException,
     initialExtra: initialExtra,
     redirectLimit: redirectLimit,
-    errorBuilder: errorBuilder ??
-        (BuildContext context, GoRouterState state) =>
-            TestErrorScreen(state.error!),
+    errorBuilder: errorBuilder,
     navigatorKey: navigatorKey,
     restorationScopeId: restorationScopeId,
   );