Better error messages round 2 (#3223)

* Refactor notices/hints for solver conflicts.

Untangled a lot of type assertions and digging into details of the
incompatibility when printing the incompatibility. Notably, we introduce
a concept of notices and hints.

Notices are messages that an `IncompatibilityCause` can have, they are
human readable messages printed before the incompatibility explanation.
This allows `SdkCause` to point out the current version of an SDK that
is causing a conflict (if the SDK is available).

Hints are messages that an `IncompatibilityCause` can have, they are
human readable message with actionable hints to be printed after the
incompatibility explanation. This allows `PackageNotFoundCause` to hint
that trying without `--offline` might work.

Hints are printed after the incompatibility explanation because they
actionable, they don't serve to explain what went wrong. They are
intended to be actionable, hence, it's preferable the user sees these as
one of the first things.
diff --git a/lib/src/authentication/client.dart b/lib/src/authentication/client.dart
index 1f37149..a6001ec 100644
--- a/lib/src/authentication/client.dart
+++ b/lib/src/authentication/client.dart
@@ -8,7 +8,6 @@
 import 'package:http/http.dart' as http;
 import 'package:http_parser/http_parser.dart';
 
-import '../exceptions.dart';
 import '../http.dart';
 import '../log.dart' as log;
 import '../system_cache.dart';
@@ -22,14 +21,17 @@
   /// Constructs Http client wrapper that injects `authorization` header to
   /// requests and handles authentication errors.
   ///
-  /// [credential] might be `null`. In that case `authorization` header will not
+  /// [_credential] might be `null`. In that case `authorization` header will not
   /// be injected to requests.
-  _AuthenticatedClient(this._inner, this.credential);
+  _AuthenticatedClient(this._inner, this._credential);
 
   final http.BaseClient _inner;
 
   /// Authentication scheme that could be used for authenticating requests.
-  final Credential? credential;
+  final Credential? _credential;
+
+  /// Detected that [_credential] are invalid, happens when server responds 401.
+  bool _detectInvalidCredentials = false;
 
   @override
   Future<http.StreamedResponse> send(http.BaseRequest request) async {
@@ -40,15 +42,16 @@
     // to given serverBaseUrl. Otherwise credential leaks might ocurr when
     // archive_url hosted on 3rd party server that should not receive
     // credentials of the first party.
-    if (credential != null &&
-        credential!.canAuthenticate(request.url.toString())) {
+    if (_credential != null &&
+        _credential!.canAuthenticate(request.url.toString())) {
       request.headers[HttpHeaders.authorizationHeader] =
-          await credential!.getAuthorizationHeaderValue();
+          await _credential!.getAuthorizationHeaderValue();
     }
 
     try {
       final response = await _inner.send(request);
       if (response.statusCode == 401) {
+        _detectInvalidCredentials = true;
         _throwAuthException(response);
       }
       return response;
@@ -124,31 +127,17 @@
   Future<T> Function(http.Client) fn,
 ) async {
   final credential = systemCache.tokenStore.findCredential(hostedUrl);
-  final http.Client client = _AuthenticatedClient(httpClient, credential);
+  final client = _AuthenticatedClient(httpClient, credential);
 
   try {
     return await fn(client);
-  } on AuthenticationException catch (error) {
-    var message = '';
-
-    if (error.statusCode == 401) {
-      if (systemCache.tokenStore.removeCredential(hostedUrl)) {
+  } finally {
+    if (client._detectInvalidCredentials) {
+      // try to remove the credential, if we detected that it is invalid!
+      final removed = systemCache.tokenStore.removeCredential(hostedUrl);
+      if (removed) {
         log.warning('Invalid token for $hostedUrl deleted.');
       }
-      message = '$hostedUrl package repository requested authentication! '
-          'You can provide credential using:\n'
-          '    pub token add $hostedUrl';
     }
-    if (error.statusCode == 403) {
-      message = 'Insufficient permissions to the resource in $hostedUrl '
-          'package repository. You can modify credential using:\n'
-          '    pub token add $hostedUrl';
-    }
-
-    if (error.serverMessage?.isNotEmpty == true) {
-      message += '\n${error.serverMessage}';
-    }
-
-    throw DataException(message);
   }
 }
diff --git a/lib/src/command.dart b/lib/src/command.dart
index 53b301a..3f06db1 100644
--- a/lib/src/command.dart
+++ b/lib/src/command.dart
@@ -212,13 +212,13 @@
 
   /// Returns the appropriate exit code for [exception], falling back on 1 if no
   /// appropriate exit code could be found.
-  int _chooseExitCode(exception) {
+  int _chooseExitCode(Object exception) {
     if (exception is SolveFailure) {
       var packageNotFound = exception.packageNotFound;
       if (packageNotFound != null) exception = packageNotFound;
     }
     while (exception is WrappedException && exception.innerError is Exception) {
-      exception = exception.innerError;
+      exception = exception.innerError!;
     }
 
     if (exception is HttpException ||
diff --git a/lib/src/command/lish.dart b/lib/src/command/lish.dart
index 325d482..b226c55 100644
--- a/lib/src/command/lish.dart
+++ b/lib/src/command/lish.dart
@@ -120,6 +120,22 @@
         handleJsonSuccess(
             await client.get(Uri.parse(location), headers: pubApiHeaders));
       });
+    } on AuthenticationException catch (error) {
+      var msg = '';
+      if (error.statusCode == 401) {
+        msg += '$server package repository requested authentication!\n'
+            'You can provide credentials using:\n'
+            '    pub token add $server\n';
+      }
+      if (error.statusCode == 403) {
+        msg += 'Insufficient permissions to the resource at the $server '
+            'package repository.\nYou can modify credentials using:\n'
+            '    pub token add $server\n';
+      }
+      if (error.serverMessage != null) {
+        msg += '\n' + error.serverMessage! + '\n';
+      }
+      dataError(msg + log.red('Authentication failed!'));
     } on PubHttpException catch (error) {
       var url = error.response.request!.url;
       if (url == cloudStorageUrl) {
diff --git a/lib/src/exceptions.dart b/lib/src/exceptions.dart
index 82a5dac..fcde747 100644
--- a/lib/src/exceptions.dart
+++ b/lib/src/exceptions.dart
@@ -11,7 +11,6 @@
 import 'package:yaml/yaml.dart';
 
 import 'dart.dart';
-import 'sdk.dart';
 
 /// An exception class for exceptions that are intended to be seen by the user.
 ///
@@ -89,18 +88,20 @@
 /// that other code in pub can use this to show a more detailed explanation of
 /// why the package was being requested.
 class PackageNotFoundException extends WrappedException {
-  /// If this failure was caused by an SDK being unavailable, this is that SDK.
-  final Sdk? missingSdk;
+  /// A hint indicating an action the user could take to resolve this problem.
+  ///
+  /// This will be printed after the package resolution conflict.
+  final String? hint;
 
   PackageNotFoundException(
     String message, {
     Object? innerError,
     StackTrace? innerTrace,
-    this.missingSdk,
+    this.hint,
   }) : super(message, innerError, innerTrace);
 
   @override
-  String toString() => "Package doesn't exist ($message).";
+  String toString() => 'Package not available ($message).';
 }
 
 /// Returns whether [error] is a user-facing error object.
diff --git a/lib/src/solver/failure.dart b/lib/src/solver/failure.dart
index ba27a3b..60f70c5 100644
--- a/lib/src/solver/failure.dart
+++ b/lib/src/solver/failure.dart
@@ -7,7 +7,6 @@
 import '../exceptions.dart';
 import '../log.dart' as log;
 import '../package_name.dart';
-import '../sdk.dart';
 import '../utils.dart';
 import 'incompatibility.dart';
 import 'incompatibility_cause.dart';
@@ -93,39 +92,20 @@
   String write() {
     var buffer = StringBuffer();
 
-    // SDKs whose version constraints weren't matched.
-    var sdkConstraintCauses = <Sdk>{};
-
-    // SDKs implicated in any way in the solve failure.
-    var sdkCauses = <Sdk>{};
-
-    for (var incompatibility in _root.externalIncompatibilities) {
-      var cause = incompatibility.cause;
-      if (cause is PackageNotFoundCause) {
-        var sdk = cause.sdk;
-        if (sdk != null) {
-          sdkCauses.add(sdk);
-        }
-      } else if (cause is SdkCause) {
-        sdkCauses.add(cause.sdk);
-        sdkConstraintCauses.add(cause.sdk);
-      }
+    // Find all notices from incompatibility causes. This allows an
+    // [IncompatibilityCause] to provide a notice that is printed before the
+    // explanation of the conflict.
+    // Notably, this is used for stating which SDK version is currently
+    // installed, if an SDK is incompatible with a dependency.
+    final notices = _root.externalIncompatibilities
+        .map((c) => c.cause.notice)
+        .whereNotNull()
+        .toSet() // Avoid duplicates
+        .sortedBy((n) => n); // sort for consistency
+    for (final n in notices) {
+      buffer.writeln(n);
     }
-
-    // If the failure was caused in part by unsatisfied SDK constraints,
-    // indicate the actual versions so we don't have to list them (possibly
-    // multiple times) in the main body of the error message.
-    //
-    // Iterate through [sdks] to ensure that SDKs versions are printed in a
-    // consistent order
-    var wroteLine = false;
-    for (var sdk in sdks.values) {
-      if (!sdkConstraintCauses.contains(sdk)) continue;
-      if (!sdk.isAvailable) continue;
-      wroteLine = true;
-      buffer.writeln('The current ${sdk.name} SDK version is ${sdk.version}.');
-    }
-    if (wroteLine) buffer.writeln();
+    if (notices.isNotEmpty) buffer.writeln();
 
     if (_root.cause is ConflictCause) {
       _visit(_root, const {});
@@ -159,15 +139,21 @@
       buffer.writeln(wordWrap(message, prefix: ' ' * (padding + 2)));
     }
 
-    // Iterate through [sdks] to ensure that SDKs versions are printed in a
-    // consistent order
-    for (var sdk in sdks.values) {
-      if (!sdkCauses.contains(sdk)) continue;
-      if (sdk.isAvailable) continue;
-      if (sdk.installMessage == null) continue;
+    // Iterate through all hints, these are intended to be actionable, such as:
+    //  * How to install an SDK, and,
+    //  * How to provide authentication.
+    // Hence, it makes sense to show these at the end of the explanation, as the
+    // user will ideally see these before reading the actual conflict and
+    // understand how to fix the issue.
+    _root.externalIncompatibilities
+        .map((c) => c.cause.hint)
+        .whereNotNull()
+        .toSet() // avoid duplicates
+        .sortedBy((hint) => hint) // sort hints for consistent ordering.
+        .forEach((hint) {
       buffer.writeln();
-      buffer.writeln(sdk.installMessage);
-    }
+      buffer.writeln(hint);
+    });
 
     return buffer.toString();
   }
diff --git a/lib/src/solver/incompatibility_cause.dart b/lib/src/solver/incompatibility_cause.dart
index 53dcfe2..a2a2327 100644
--- a/lib/src/solver/incompatibility_cause.dart
+++ b/lib/src/solver/incompatibility_cause.dart
@@ -10,6 +10,8 @@
 
 /// The reason an [Incompatibility]'s terms are incompatible.
 abstract class IncompatibilityCause {
+  const IncompatibilityCause._();
+
   /// The incompatibility represents the requirement that the root package
   /// exists.
   static const IncompatibilityCause root = _Cause('root');
@@ -27,11 +29,26 @@
 
   /// The incompatibility indicates that the package has an unknown source.
   static const IncompatibilityCause unknownSource = _Cause('unknown source');
+
+  /// Human readable notice / information providing context for this
+  /// incompatibility.
+  ///
+  /// This may be multiple lines, and will be printed before the explanation.
+  /// This is used highlight information that is useful for understanding the
+  /// why this conflict happened.
+  String? get notice => null;
+
+  /// Human readable hint indicating how this incompatibility may be resolved.
+  ///
+  /// This may be multiple lines, and will be printed after the explanation.
+  /// This should only be included if it is actionable and likely to resolve the
+  /// issue for the user.
+  String? get hint => null;
 }
 
 /// The incompatibility was derived from two existing incompatibilities during
 /// conflict resolution.
-class ConflictCause implements IncompatibilityCause {
+class ConflictCause extends IncompatibilityCause {
   /// The incompatibility that was originally found to be in conflict, from
   /// which the target incompatibility was derived.
   final Incompatibility conflict;
@@ -40,14 +57,14 @@
   /// from which the target incompatibility was derived.
   final Incompatibility other;
 
-  ConflictCause(this.conflict, this.other);
+  ConflictCause(this.conflict, this.other) : super._();
 }
 
 /// A class for stateless [IncompatibilityCause]s.
-class _Cause implements IncompatibilityCause {
+class _Cause extends IncompatibilityCause {
   final String _name;
 
-  const _Cause(this._name);
+  const _Cause(this._name) : super._();
 
   @override
   String toString() => _name;
@@ -55,7 +72,7 @@
 
 /// The incompatibility represents a package's SDK constraint being
 /// incompatible with the current SDK.
-class SdkCause implements IncompatibilityCause {
+class SdkCause extends IncompatibilityCause {
   /// The union of all the incompatible versions' constraints on the SDK.
   // TODO(zarah): Investigate if this can be non-nullable
   final VersionConstraint? constraint;
@@ -63,20 +80,41 @@
   /// The SDK with which the package was incompatible.
   final Sdk sdk;
 
-  SdkCause(this.constraint, this.sdk);
+  @override
+  String? get notice {
+    // If the SDK is not available, then we have an actionable [hint] printed
+    // after the explanation. So we don't need to state that the SDK is not
+    // available.
+    if (!sdk.isAvailable) {
+      return null;
+    }
+    // If the SDK is available and we have an incompatibility, then the user has
+    // the wrong SDK version (one that is not compatible with any solution).
+    // Thus, it makes sense to highlight the current SDK version.
+    return 'The current ${sdk.name} SDK version is ${sdk.version}.';
+  }
+
+  @override
+  String? get hint {
+    // If the SDK is available, then installing it won't help
+    if (sdk.isAvailable) {
+      return null;
+    }
+    // Return an install message for the SDK, if there is an install message.
+    return sdk.installMessage;
+  }
+
+  SdkCause(this.constraint, this.sdk) : super._();
 }
 
 /// The incompatibility represents a package that couldn't be found by its
 /// source.
-class PackageNotFoundCause implements IncompatibilityCause {
+class PackageNotFoundCause extends IncompatibilityCause {
   /// The exception indicating why the package couldn't be found.
   final PackageNotFoundException exception;
 
-  /// If the incompatibility was caused by an SDK being unavailable, this is
-  /// that SDK.
-  ///
-  /// Otherwise `null`.
-  Sdk? get sdk => exception.missingSdk;
+  PackageNotFoundCause(this.exception) : super._();
 
-  PackageNotFoundCause(this.exception);
+  @override
+  String? get hint => exception.hint;
 }
diff --git a/lib/src/source/hosted.dart b/lib/src/source/hosted.dart
index 0b6fbaa..1cb1950 100644
--- a/lib/src/source/hosted.dart
+++ b/lib/src/source/hosted.dart
@@ -405,8 +405,8 @@
       body = decoded;
       result = _versionInfoFromPackageListing(body, ref, url);
     } on Exception catch (error, stackTrace) {
-      var parsed = source._asDescription(ref.description);
-      _throwFriendlyError(error, stackTrace, parsed.packageName, parsed.uri);
+      final packageName = source._asDescription(ref.description).packageName;
+      _throwFriendlyError(error, stackTrace, packageName, serverUrl);
     }
 
     // Cache the response on disk.
@@ -823,40 +823,64 @@
     });
   }
 
-  /// When an error occurs trying to read something about [package] from [url],
+  /// When an error occurs trying to read something about [package] from [hostedUrl],
   /// this tries to translate into a more user friendly error message.
   ///
   /// Always throws an error, either the original one or a better one.
   Never _throwFriendlyError(
-    error,
+    Exception error,
     StackTrace stackTrace,
     String package,
-    Uri url,
+    Uri hostedUrl,
   ) {
     if (error is PubHttpException) {
       if (error.response.statusCode == 404) {
         throw PackageNotFoundException(
-            'could not find package $package at $url',
+            'could not find package $package at $hostedUrl',
             innerError: error,
             innerTrace: stackTrace);
       }
 
       fail(
           '${error.response.statusCode} ${error.response.reasonPhrase} trying '
-          'to find package $package at $url.',
+          'to find package $package at $hostedUrl.',
           error,
           stackTrace);
     } else if (error is io.SocketException) {
-      fail('Got socket error trying to find package $package at $url.', error,
-          stackTrace);
+      fail('Got socket error trying to find package $package at $hostedUrl.',
+          error, stackTrace);
     } else if (error is io.TlsException) {
-      fail('Got TLS error trying to find package $package at $url.', error,
-          stackTrace);
+      fail('Got TLS error trying to find package $package at $hostedUrl.',
+          error, stackTrace);
+    } else if (error is AuthenticationException) {
+      String? hint;
+      var message = 'authentication failed';
+
+      assert(error.statusCode == 401 || error.statusCode == 403);
+      if (error.statusCode == 401) {
+        hint = '$hostedUrl package repository requested authentication!\n'
+            'You can provide credentials using:\n'
+            '    pub token add $hostedUrl';
+      }
+      if (error.statusCode == 403) {
+        hint = 'Insufficient permissions to the resource at the $hostedUrl '
+            'package repository.\nYou can modify credentials using:\n'
+            '    pub token add $hostedUrl';
+        message = 'authorization failed';
+      }
+
+      if (error.serverMessage?.isNotEmpty == true && hint != null) {
+        hint += '\n${error.serverMessage}';
+      }
+
+      throw PackageNotFoundException(message, hint: hint);
     } else if (error is FormatException) {
       throw PackageNotFoundException(
-          'Got badly formatted response trying to find package $package at $url',
-          innerError: error,
-          innerTrace: stackTrace);
+        'Got badly formatted response trying to find package $package at $hostedUrl',
+        innerError: error,
+        innerTrace: stackTrace,
+        hint: 'Check that "$hostedUrl" is a valid package repository.',
+      );
     } else {
       // Otherwise re-throw the original exception.
       throw error;
@@ -974,7 +998,9 @@
     // If there are no versions in the cache, report a clearer error.
     if (versions.isEmpty) {
       throw PackageNotFoundException(
-          'could not find package ${ref.name} in cache');
+        'could not find package ${ref.name} in cache',
+        hint: 'Try again without --offline!',
+      );
     }
 
     return versions;
@@ -990,7 +1016,9 @@
   @override
   Future<Pubspec> describeUncached(PackageId id) {
     throw PackageNotFoundException(
-        '${id.name} ${id.version} is not available in your system cache');
+      '${id.name} ${id.version} is not available in cache',
+      hint: 'Try again without --offline!',
+    );
   }
 
   @override
diff --git a/lib/src/source/sdk.dart b/lib/src/source/sdk.dart
index f1dde9e..1458d1b 100644
--- a/lib/src/source/sdk.dart
+++ b/lib/src/source/sdk.dart
@@ -101,8 +101,10 @@
     if (sdk == null) {
       throw PackageNotFoundException('unknown SDK "$identifier"');
     } else if (!sdk.isAvailable) {
-      throw PackageNotFoundException('the ${sdk.name} SDK is not available',
-          missingSdk: sdk);
+      throw PackageNotFoundException(
+        'the ${sdk.name} SDK is not available',
+        hint: sdk.installMessage,
+      );
     }
 
     var path = sdk.packagePath(package.name);
diff --git a/test/cache/add/package_not_found_test.dart b/test/cache/add/package_not_found_test.dart
index 91f3f3e..6b3f2bc 100644
--- a/test/cache/add/package_not_found_test.dart
+++ b/test/cache/add/package_not_found_test.dart
@@ -13,8 +13,9 @@
 
     await runPub(
         args: ['cache', 'add', 'foo'],
-        error: RegExp(r"Package doesn't exist \(could not find package foo at "
-            r'http://.*\)\.'),
+        error: RegExp(
+          r'Package not available \(could not find package foo at http://.*\)\.',
+        ),
         exitCode: exit_codes.UNAVAILABLE);
   });
 }
diff --git a/test/cache/repair/handles_failure_test.dart b/test/cache/repair/handles_failure_test.dart
index 471bcf8..4a6bd64 100644
--- a/test/cache/repair/handles_failure_test.dart
+++ b/test/cache/repair/handles_failure_test.dart
@@ -39,7 +39,7 @@
     expect(pub.stderr, emits(startsWith('Failed to repair foo 1.2.4. Error:')));
     expect(
         pub.stderr,
-        emits('Package doesn\'t exist '
+        emits('Package not available '
             '(Package foo has no version 1.2.4).'));
 
     expect(pub.stdout, emits('Reinstalled 2 packages.'));
diff --git a/test/hosted/fail_gracefully_on_bad_version_listing_response_test.dart b/test/hosted/fail_gracefully_on_bad_version_listing_response_test.dart
index baee4b0..25f3c50 100644
--- a/test/hosted/fail_gracefully_on_bad_version_listing_response_test.dart
+++ b/test/hosted/fail_gracefully_on_bad_version_listing_response_test.dart
@@ -9,6 +9,7 @@
 import 'package:test/test.dart';
 
 import '../descriptor.dart' as d;
+import '../golden_file.dart';
 import '../test_pub.dart';
 
 void main() {
@@ -36,4 +37,85 @@
           exitCode: exit_codes.DATA);
     });
   });
+
+  testWithGolden('bad_json', (ctx) async {
+    await servePackages((b) => b..serve('foo', '1.2.3'));
+    globalPackageServer.extraHandlers[RegExp('/api/packages/.*')] =
+        expectAsync1((request) {
+      expect(request.method, 'GET');
+      return Response(200,
+          body: jsonEncode({
+            'notTheRight': {'response': 'type'}
+          }));
+    });
+    await d.appDir({'foo': '1.2.3'}).create();
+
+    await ctx.run(['get']);
+  });
+
+  testWithGolden('403', (ctx) async {
+    await servePackages((b) => b..serve('foo', '1.2.3'));
+    globalPackageServer.extraHandlers[RegExp('/api/packages/.*')] =
+        expectAsync1((request) {
+      expect(request.method, 'GET');
+      return Response(403,
+          body: jsonEncode({
+            'notTheRight': {'response': 'type'}
+          }));
+    });
+    await d.appDir({'foo': '1.2.3'}).create();
+
+    await ctx.run(['get']);
+  });
+
+  testWithGolden('401', (ctx) async {
+    await servePackages((b) => b..serve('foo', '1.2.3'));
+    globalPackageServer.extraHandlers[RegExp('/api/packages/.*')] =
+        expectAsync1((request) {
+      expect(request.method, 'GET');
+      return Response(401,
+          body: jsonEncode({
+            'notTheRight': {'response': 'type'}
+          }));
+    });
+    await d.appDir({'foo': '1.2.3'}).create();
+
+    await ctx.run(['get']);
+  });
+
+  testWithGolden('403-with-message', (ctx) async {
+    await servePackages((b) => b..serve('foo', '1.2.3'));
+    globalPackageServer.extraHandlers[RegExp('/api/packages/.*')] =
+        expectAsync1((request) {
+      expect(request.method, 'GET');
+      return Response(403,
+          headers: {
+            'www-authenticate': 'Bearer realm="pub", message="<message>"',
+          },
+          body: jsonEncode({
+            'notTheRight': {'response': 'type'}
+          }));
+    });
+    await d.appDir({'foo': '1.2.3'}).create();
+
+    await ctx.run(['get']);
+  });
+
+  testWithGolden('401-with-message', (ctx) async {
+    await servePackages((b) => b..serve('foo', '1.2.3'));
+    globalPackageServer.extraHandlers[RegExp('/api/packages/.*')] =
+        expectAsync1((request) {
+      expect(request.method, 'GET');
+      return Response(401,
+          headers: {
+            'www-authenticate': 'Bearer realm="pub", message="<message>"',
+          },
+          body: jsonEncode({
+            'notTheRight': {'response': 'type'}
+          }));
+    });
+    await d.appDir({'foo': '1.2.3'}).create();
+
+    await ctx.run(['get']);
+  });
 }
diff --git a/test/hosted/fail_gracefully_with_hint_test.dart b/test/hosted/fail_gracefully_with_hint_test.dart
new file mode 100644
index 0000000..bbb510b
--- /dev/null
+++ b/test/hosted/fail_gracefully_with_hint_test.dart
@@ -0,0 +1,64 @@
+// Copyright (c) 2012, the Dart project authors.  Please see the AUTHORS file
+// for details. 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:pub/src/exit_codes.dart' as exit_codes;
+import 'package:test/test.dart';
+
+import '../descriptor.dart' as d;
+import '../golden_file.dart';
+import '../test_pub.dart';
+
+void main() {
+  testWithGolden('hint: try without --offline', (ctx) async {
+    // Run the server so that we know what URL to use in the system cache.
+    await serveErrors();
+
+    await d.appDir({'foo': 'any'}).create();
+
+    await pubGet(
+      args: ['--offline'],
+      exitCode: exit_codes.UNAVAILABLE,
+      error: contains('Try again without --offline!'),
+    );
+  });
+
+  testWithGolden('supports two hints', (ctx) async {
+    // Run the server so that we know what URL to use in the system cache.
+    await serveErrors();
+
+    await d.hostedCache([
+      d.dir('foo-1.2.3', [
+        d.pubspec({
+          'name': 'foo',
+          'version': '1.2.3',
+          'environment': {
+            'flutter': 'any', // generates hint -> flutter pub get
+          },
+        }),
+      ]),
+      d.dir('foo-1.2.4', [
+        d.pubspec({
+          'name': 'foo',
+          'version': '1.2.4',
+          'dependencies': {
+            'bar': 'any', // generates hint -> try without --offline
+          },
+        }),
+      ]),
+    ]).create();
+
+    await d.appDir({'foo': 'any'}).create();
+
+    await pubGet(
+      args: ['--offline'],
+      exitCode: exit_codes.UNAVAILABLE,
+      error: allOf(
+        contains('Try again without --offline!'),
+        contains('flutter pub get'), // hint that
+      ),
+    );
+
+    await ctx.run(['get', '--offline']);
+  });
+}
diff --git a/test/hosted/offline_test.dart b/test/hosted/offline_test.dart
index 713ea3f..911f405 100644
--- a/test/hosted/offline_test.dart
+++ b/test/hosted/offline_test.dart
@@ -80,6 +80,8 @@
           error: equalsIgnoringWhitespace("""
             Because myapp depends on foo any which doesn't exist (could not find
               package foo in cache), version solving failed.
+
+            Try again without --offline!
           """));
     });
 
@@ -116,6 +118,8 @@
           error: equalsIgnoringWhitespace("""
             Because myapp depends on foo any which doesn't exist (could not find
               package foo in cache), version solving failed.
+
+            Try again without --offline!
           """));
     });
 
diff --git a/test/package_config_file_test.dart b/test/package_config_file_test.dart
index 82882ae..e609d28 100644
--- a/test/package_config_file_test.dart
+++ b/test/package_config_file_test.dart
@@ -117,6 +117,8 @@
           args: ['--offline'], error: equalsIgnoringWhitespace("""
             Because myapp depends on foo any which doesn't exist (could not find
               package foo in cache), version solving failed.
+
+            Try again without --offline!
           """), exitCode: exit_codes.UNAVAILABLE);
 
       await d.dir(appPath, [
diff --git a/test/packages_file_test.dart b/test/packages_file_test.dart
index 67ca572..e7a8a88 100644
--- a/test/packages_file_test.dart
+++ b/test/packages_file_test.dart
@@ -71,6 +71,8 @@
           args: ['--offline'], error: equalsIgnoringWhitespace("""
             Because myapp depends on foo any which doesn't exist (could not find
               package foo in cache), version solving failed.
+
+            Try again without --offline!
           """), exitCode: exit_codes.UNAVAILABLE);
 
       await d.dir(appPath, [d.nothing('.packages')]).validate();
diff --git a/test/testdata/goldens/hosted/fail_gracefully_on_bad_version_listing_response_test/401-with-message.txt b/test/testdata/goldens/hosted/fail_gracefully_on_bad_version_listing_response_test/401-with-message.txt
new file mode 100644
index 0000000..8dcaaf5
--- /dev/null
+++ b/test/testdata/goldens/hosted/fail_gracefully_on_bad_version_listing_response_test/401-with-message.txt
@@ -0,0 +1,13 @@
+# GENERATED BY: test/hosted/fail_gracefully_on_bad_version_listing_response_test.dart
+
+## Section 0
+$ pub get
+Resolving dependencies...
+[STDERR] Because myapp depends on foo any which doesn't exist (authentication failed), version solving failed.
+[STDERR] 
+[STDERR] http://localhost:$PORT package repository requested authentication!
+[STDERR] You can provide credentials using:
+[STDERR]     pub token add http://localhost:$PORT
+[STDERR] <message>
+[EXIT CODE] 69
+
diff --git a/test/testdata/goldens/hosted/fail_gracefully_on_bad_version_listing_response_test/401.txt b/test/testdata/goldens/hosted/fail_gracefully_on_bad_version_listing_response_test/401.txt
new file mode 100644
index 0000000..68d5bd0
--- /dev/null
+++ b/test/testdata/goldens/hosted/fail_gracefully_on_bad_version_listing_response_test/401.txt
@@ -0,0 +1,12 @@
+# GENERATED BY: test/hosted/fail_gracefully_on_bad_version_listing_response_test.dart
+
+## Section 0
+$ pub get
+Resolving dependencies...
+[STDERR] Because myapp depends on foo any which doesn't exist (authentication failed), version solving failed.
+[STDERR] 
+[STDERR] http://localhost:$PORT package repository requested authentication!
+[STDERR] You can provide credentials using:
+[STDERR]     pub token add http://localhost:$PORT
+[EXIT CODE] 69
+
diff --git a/test/testdata/goldens/hosted/fail_gracefully_on_bad_version_listing_response_test/403-with-message.txt b/test/testdata/goldens/hosted/fail_gracefully_on_bad_version_listing_response_test/403-with-message.txt
new file mode 100644
index 0000000..882660c
--- /dev/null
+++ b/test/testdata/goldens/hosted/fail_gracefully_on_bad_version_listing_response_test/403-with-message.txt
@@ -0,0 +1,13 @@
+# GENERATED BY: test/hosted/fail_gracefully_on_bad_version_listing_response_test.dart
+
+## Section 0
+$ pub get
+Resolving dependencies...
+[STDERR] Because myapp depends on foo any which doesn't exist (authorization failed), version solving failed.
+[STDERR] 
+[STDERR] Insufficient permissions to the resource at the http://localhost:$PORT package repository.
+[STDERR] You can modify credentials using:
+[STDERR]     pub token add http://localhost:$PORT
+[STDERR] <message>
+[EXIT CODE] 69
+
diff --git a/test/testdata/goldens/hosted/fail_gracefully_on_bad_version_listing_response_test/403.txt b/test/testdata/goldens/hosted/fail_gracefully_on_bad_version_listing_response_test/403.txt
new file mode 100644
index 0000000..f8a5af9
--- /dev/null
+++ b/test/testdata/goldens/hosted/fail_gracefully_on_bad_version_listing_response_test/403.txt
@@ -0,0 +1,12 @@
+# GENERATED BY: test/hosted/fail_gracefully_on_bad_version_listing_response_test.dart
+
+## Section 0
+$ pub get
+Resolving dependencies...
+[STDERR] Because myapp depends on foo any which doesn't exist (authorization failed), version solving failed.
+[STDERR] 
+[STDERR] Insufficient permissions to the resource at the http://localhost:$PORT package repository.
+[STDERR] You can modify credentials using:
+[STDERR]     pub token add http://localhost:$PORT
+[EXIT CODE] 69
+
diff --git a/test/testdata/goldens/hosted/fail_gracefully_on_bad_version_listing_response_test/bad_json.txt b/test/testdata/goldens/hosted/fail_gracefully_on_bad_version_listing_response_test/bad_json.txt
new file mode 100644
index 0000000..8e446c2
--- /dev/null
+++ b/test/testdata/goldens/hosted/fail_gracefully_on_bad_version_listing_response_test/bad_json.txt
@@ -0,0 +1,10 @@
+# GENERATED BY: test/hosted/fail_gracefully_on_bad_version_listing_response_test.dart
+
+## Section 0
+$ pub get
+Resolving dependencies...
+[STDERR] Because myapp depends on foo any which doesn't exist (Got badly formatted response trying to find package foo at http://localhost:$PORT), version solving failed.
+[STDERR] 
+[STDERR] Check that "http://localhost:$PORT" is a valid package repository.
+[EXIT CODE] 65
+
diff --git a/test/testdata/goldens/hosted/fail_gracefully_with_hint_test/supports two hints.txt b/test/testdata/goldens/hosted/fail_gracefully_with_hint_test/supports two hints.txt
new file mode 100644
index 0000000..a45b0f0
--- /dev/null
+++ b/test/testdata/goldens/hosted/fail_gracefully_with_hint_test/supports two hints.txt
@@ -0,0 +1,13 @@
+# GENERATED BY: test/hosted/fail_gracefully_with_hint_test.dart
+
+## Section 0
+$ pub get --offline
+Resolving dependencies...
+[STDERR] Because foo <1.2.4 requires the Flutter SDK and foo >=1.2.4 depends on bar any, every version of foo requires bar any.
+[STDERR] So, because bar doesn't exist (could not find package bar in cache) and myapp depends on foo any, version solving failed.
+[STDERR] 
+[STDERR] Flutter users should run `flutter pub get` instead of `dart pub get`.
+[STDERR] 
+[STDERR] Try again without --offline!
+[EXIT CODE] 69
+