improve failure modes of ChromeConnection.getTabs (#88)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 121b26f..c175eda 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
-## 1.0.2-dev
+## 1.1.0-dev
 
+- Have `ChromeConnection.getTabs` return better exceptions where there's a
+  failure setting up the Chrome connection (#85).
+- Introduce a new, optional `retryFor` parameter to `ChromeConnection.getTabs`.
+  This will re-try failed connections for a period of time; it can be useful to
+  mitigate some intermittent connection issues very early in Chrome's startup.
 
 ## 1.0.1
 - Use `package:lints` for analysis.
diff --git a/lib/webkit_inspection_protocol.dart b/lib/webkit_inspection_protocol.dart
index ff5a7e2..6f5f7df 100644
--- a/lib/webkit_inspection_protocol.dart
+++ b/lib/webkit_inspection_protocol.dart
@@ -6,7 +6,7 @@
 
 import 'dart:async';
 import 'dart:convert';
-import 'dart:io' show HttpClient, HttpClientResponse, WebSocket;
+import 'dart:io' show HttpClient, HttpClientResponse, IOException, WebSocket;
 
 import 'src/console.dart';
 import 'src/debugger.dart';
@@ -36,16 +36,49 @@
   ChromeConnection(String host, [int port = 9222])
       : url = Uri.parse('http://$host:$port/');
 
-  // TODO(DrMarcII): consider changing this to return Stream<ChromeTab>.
-  Future<List<ChromeTab>> getTabs() async {
+  /// Return all the available tabs.
+  ///
+  /// This method can potentially throw a [ConnectionException] on some protocol
+  /// issues.
+  ///
+  /// An optional [retryFor] duration can be used to automatically re-try
+  /// connections for some period of time. Anecdotally, Chrome can return errors
+  /// when trying to list the available tabs very early in its startup sequence.
+  Future<List<ChromeTab>> getTabs({
+    Duration? retryFor,
+  }) async {
+    final start = DateTime.now();
+    DateTime? end = retryFor == null ? null : start.add(retryFor);
+
     var response = await getUrl('/json');
-    var respBody = await utf8.decodeStream(response.cast<List<int>>());
-    return List<ChromeTab>.from(
-        (jsonDecode(respBody) as List).map((m) => ChromeTab(m as Map)));
+    var responseBody = await utf8.decodeStream(response.cast<List<int>>());
+
+    late List decoded;
+    while (true) {
+      try {
+        decoded = jsonDecode(responseBody);
+        return List<ChromeTab>.from(decoded.map((m) => ChromeTab(m as Map)));
+      } on FormatException catch (formatException) {
+        if (end != null && end.isBefore(DateTime.now())) {
+          // Delay for retryFor / 4 milliseconds.
+          await Future.delayed(
+            Duration(milliseconds: retryFor!.inMilliseconds ~/ 4),
+          );
+        } else {
+          throw ConnectionException(
+            formatException: formatException,
+            responseStatus: '${response.statusCode} ${response.reasonPhrase}',
+            responseBody: responseBody,
+          );
+        }
+      }
+    }
   }
 
-  Future<ChromeTab?> getTab(bool Function(ChromeTab tab) accept,
-      {Duration? retryFor}) async {
+  Future<ChromeTab?> getTab(
+    bool Function(ChromeTab tab) accept, {
+    Duration? retryFor,
+  }) async {
     var start = DateTime.now();
     var end = start;
     if (retryFor != null) {
@@ -79,6 +112,39 @@
   void close() => _client.close(force: true);
 }
 
+/// An exception that can be thrown early in the connection sequence for a
+/// [ChromeConnection].
+///
+/// This exception includes the underlying exception, as well as the http
+/// response from the browser that we failed on. The [toString] implementation
+/// includes a summary of the response.
+class ConnectionException implements IOException {
+  final FormatException formatException;
+  final String responseStatus;
+  final String responseBody;
+
+  ConnectionException({
+    required this.formatException,
+    required this.responseStatus,
+    required this.responseBody,
+  });
+
+  @override
+  String toString() {
+    final buf = StringBuffer('${formatException.message}\n');
+    buf.writeln('$responseStatus; body:');
+    var lines = responseBody.split('\n');
+    if (lines.length > 10) {
+      lines = [
+        ...lines.take(10),
+        '...',
+      ];
+    }
+    buf.writeAll(lines, '\n');
+    return buf.toString();
+  }
+}
+
 class ChromeTab {
   final Map _map;
 
diff --git a/pubspec.yaml b/pubspec.yaml
index d4850c0..a36f2e6 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: webkit_inspection_protocol
-version: 1.0.2-dev
+version: 1.1.0-dev
 description: >
   A client for the Chrome DevTools Protocol (previously called the Webkit
   Inspection Protocol).