Fix Copy as cURL omitting headers and body on failed requests (#9963)

Fix Copy as cURL omitting headers and body on failed requests.

Timed-out and cancelled POSTs never complete a response, so the Network
profiler dropped request data from the generated curl command.

---------

Co-authored-by: Sam Rawlins <sam.rawlins@gmail.com>
diff --git a/packages/devtools_app/lib/src/screens/network/network_screen.dart b/packages/devtools_app/lib/src/screens/network/network_screen.dart
index 9721eaa..ef42f64 100644
--- a/packages/devtools_app/lib/src/screens/network/network_screen.dart
+++ b/packages/devtools_app/lib/src/screens/network/network_screen.dart
@@ -476,17 +476,25 @@
         MenuItemButton(
           child: const Text('Copy as cURL'),
           onPressed: () {
-            unawaited(
-              copyToClipboard(
-                CurlCommand.from(data).toString(),
-                successMessage: 'Copied the cURL command to the clipboard',
-              ),
-            );
+            unawaited(_copyAsCurl(data));
           },
         ),
       ],
     );
   }
+
+  Future<void> _copyAsCurl(DartIOHttpRequestData data) async {
+    try {
+      await data.getFullRequestData();
+    } catch (_) {
+      // Ignore errors fetching full data so we can still attempt to copy
+      // the partial request data we already have.
+    }
+    await copyToClipboard(
+      CurlCommand.from(data).toString(),
+      successMessage: 'Copied the cURL command to the clipboard',
+    );
+  }
 }
 
 class StatusColumn extends ColumnData<NetworkRequest>
diff --git a/packages/devtools_app/lib/src/shared/http/http_request_data.dart b/packages/devtools_app/lib/src/shared/http/http_request_data.dart
index 619db21..0277cbe 100644
--- a/packages/devtools_app/lib/src/shared/http/http_request_data.dart
+++ b/packages/devtools_app/lib/src/shared/http/http_request_data.dart
@@ -42,8 +42,15 @@
     this._request, {
     bool requestFullDataFromVmService = true,
   }) {
-    if (requestFullDataFromVmService && _request.isResponseComplete) {
-      unawaited(getFullRequestData());
+    if (requestFullDataFromVmService &&
+        (_request.isResponseComplete ||
+            _request.isRequestComplete ||
+            (_request.request?.hasError ?? false))) {
+      unawaited(
+        getFullRequestData().catchError((Object e, StackTrace st) {
+          _log.warning('Failed to fetch full request data: $e', e, st);
+        }),
+      );
     }
   }
 
@@ -120,6 +127,8 @@
         }
         notifyListeners();
       }
+    } catch (e, st) {
+      _log.warning('Failed to fetch full request data: $e', e, st);
     } finally {
       isFetchingFullData = false;
     }
@@ -308,8 +317,17 @@
       DartIOHttpRequestData._parseCookies(_request.response?.cookies);
 
   /// The request headers for the HTTP request.
-  Map<String, dynamic>? get requestHeaders =>
-      _hasError ? null : _request.request?.headers;
+  ///
+  /// Returned even when the request failed, so failed / timed-out requests can
+  /// still be replayed (e.g. Copy as cURL). Accessing headers on some error
+  /// profiles throws, so failures fall back to `null`.
+  Map<String, dynamic>? get requestHeaders {
+    try {
+      return _request.request?.headers;
+    } catch (_) {
+      return null;
+    }
+  }
 
   /// The response headers for the HTTP request.
   Map<String, dynamic>? get responseHeaders => _request.response?.headers;
@@ -401,7 +419,8 @@
     }
     final fullRequest = _request as HttpProfileRequest;
     try {
-      if (!_request.isResponseComplete) return null;
+      // Request body is independent of whether a response arrived. Timed-out
+      // and cancelled POSTs should still expose the body for Copy as cURL.
       final acceptedMethods = {'POST', 'PUT', 'PATCH'};
       if (!acceptedMethods.contains(_request.method)) return null;
       if (_requestBody != null) return _requestBody;
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index 10b1377..22f639d 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -62,6 +62,8 @@
 
 ## Network profiler updates
 
+* Fixed Copy as cURL omitting request headers and body for failed or timed-out
+  requests. [#9963](https://github.com/flutter/devtools/pull/9963)
 * Added WebSocket support to the Network profiler, including WebSocket
   connection details, lifecycle events, frame-level inspection, and connection
   timing information. [#9968](https://github.com/flutter/devtools/pull/9968)
diff --git a/packages/devtools_app/test/http/curl_command_test.dart b/packages/devtools_app/test/http/curl_command_test.dart
index 81cda7c..e23825c 100644
--- a/packages/devtools_app/test/http/curl_command_test.dart
+++ b/packages/devtools_app/test/http/curl_command_test.dart
@@ -2,6 +2,7 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
 
+import 'dart:convert';
 import 'dart:typed_data';
 
 import 'package:devtools_app/devtools_app.dart';
@@ -205,6 +206,74 @@
         "curl --location --request POST 'https://jsonplaceholder.typicode.com/posts' \\\n--data-raw '{\n \"title\": \"foo\", \"body\": \"bar\", \"userId\": 1\n}\n '",
       );
     });
+
+    test('includes headers and body when response never completes', () {
+      final data = DartIOHttpRequestData(
+        HttpProfileRequest.parse(<String, Object?>{
+          'id': '7',
+          'isolateId': 'isolates/0',
+          'method': 'POST',
+          'uri': 'https://example.com/api/login',
+          'events': <Object>[],
+          'startTime': 0,
+          'endTime': 1000,
+          'request': <String, Object?>{
+            'headers': <String, Object?>{
+              'content-type': <String>['application/json'],
+              'accept': <String>['application/json'],
+              'locale': <String>['en'],
+            },
+            'contentLength': 42,
+            'cookies': <Object>[],
+            'followRedirects': true,
+            'maxRedirects': 5,
+            'persistentConnection': false,
+          },
+          'response': null,
+          'requestBody': utf8.encode(
+            '{"email":"user@example.com","password":"secret"}',
+          ),
+        })!,
+        requestFullDataFromVmService: false,
+      );
+
+      expect(
+        data.requestBody,
+        '{"email":"user@example.com","password":"secret"}',
+      );
+      expect(
+        CurlCommand.from(data).toString(),
+        "curl --location --request POST 'https://example.com/api/login' \\\n--header 'content-type: application/json' \\\n--header 'accept: application/json' \\\n--header 'locale: en' \\\n--data-raw '{\"email\":\"user@example.com\",\"password\":\"secret\"}'",
+      );
+    });
+
+    test('includes body when request has an error', () {
+      final data = DartIOHttpRequestData(
+        HttpProfileRequest.parse(<String, Object?>{
+          'id': '8',
+          'isolateId': 'isolates/0',
+          'method': 'POST',
+          'uri': 'https://example.com/api/login',
+          'events': <Object>[],
+          'startTime': 0,
+          'endTime': 1000,
+          'request': <String, Object?>{
+            'error': 'Connection timed out',
+            'contentLength': 2,
+            'cookies': <Object>[],
+            'followRedirects': true,
+            'maxRedirects': 5,
+            'persistentConnection': false,
+          },
+          'response': null,
+          'requestBody': utf8.encode('{}'),
+        })!,
+        requestFullDataFromVmService: false,
+      );
+
+      expect(data.requestBody, '{}');
+      expect(CurlCommand.from(data).toString(), contains("--data-raw '{}'"));
+    });
   });
 }