Skip chunked framing when contentLength is null (#1970)
* Skip chunked framing when contentLength is null
IOClient mapped a null BaseRequest.contentLength to dart:io's -1
sentinel, which forces Transfer-Encoding: chunked. For bodyless GET/HEAD
this overwrote dart:io's contentLength = 0 default and sent an empty
chunked body that can desync pooled keep-alive connections. Skip the
assignment when contentLength is null so the per-method default stands.
* Minor tweeks
---------
Co-authored-by: Brian Quinlan <bquinlan@google.com>
diff --git a/pkgs/http/CHANGELOG.md b/pkgs/http/CHANGELOG.md
index 5ff32e4..8b56ac4 100644
--- a/pkgs/http/CHANGELOG.md
+++ b/pkgs/http/CHANGELOG.md
@@ -8,6 +8,11 @@
* Preserve header cases in `IOClient`.
* Fix a [bug](https://github.com/dart-lang/http/issues/1934) to release the
underlying connection when an `AbortableRequest` is aborted before its body is read.
+* Stop `IOClient` from sending bodyless GET/HEAD requests with
+ `Transfer-Encoding: chunked` when `BaseRequest.contentLength` is `null`;
+ `dart:io`'s per-method default framing is used instead. A GET or HEAD request
+ that streams a non-empty body while leaving `contentLength` `null` now throws
+ instead of being sent chunked.
## 1.6.0
diff --git a/pkgs/http/lib/src/io_client.dart b/pkgs/http/lib/src/io_client.dart
index a00ef9d..d8ec393 100644
--- a/pkgs/http/lib/src/io_client.dart
+++ b/pkgs/http/lib/src/io_client.dart
@@ -114,8 +114,13 @@
var ioRequest = (await _inner!.openUrl(request.method, request.url))
..followRedirects = request.followRedirects
..maxRedirects = request.maxRedirects
- ..contentLength = (request.contentLength ?? -1)
..persistentConnection = request.persistentConnection;
+ if (request.contentLength case final contentLength?) {
+ // Work around a Dart SDK issue where setting the contentLength to -1
+ // provokes the use of chunked transfer encoding. See:
+ // https://github.com/dart-lang/sdk/issues/60333
+ ioRequest.contentLength = contentLength;
+ }
request.headers.forEach((name, value) {
ioRequest.headers.set(name, value, preserveHeaderCase: true);
});
diff --git a/pkgs/http/test/io/streamed_request_test.dart b/pkgs/http/test/io/streamed_request_test.dart
index f0e990c..ca506bf 100644
--- a/pkgs/http/test/io/streamed_request_test.dart
+++ b/pkgs/http/test/io/streamed_request_test.dart
@@ -40,6 +40,22 @@
expect(await utf8.decodeStream(response.stream),
parse(containsPair('headers', isNot(contains('content-length')))));
});
+
+ test('streaming a non-empty body with null contentLength on GET throws',
+ () async {
+ var request = http.StreamedRequest('GET', serverUrl);
+ request.sink.add([1, 2, 3]);
+ unawaited(request.sink.close());
+ expect(request.send(), throwsClientException());
+ });
+
+ test('streaming a non-empty body with null contentLength on HEAD throws',
+ () async {
+ var request = http.StreamedRequest('HEAD', serverUrl);
+ request.sink.add([1, 2, 3]);
+ unawaited(request.sink.close());
+ expect(request.send(), throwsClientException());
+ });
});
// Regression test.
diff --git a/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart b/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart
index 4a31783..345de28 100644
--- a/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart
+++ b/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart
@@ -5,6 +5,7 @@
import 'package:http/http.dart';
import 'src/abort_tests.dart';
+import 'src/bodyless_request_tests.dart';
import 'src/close_tests.dart';
import 'src/compressed_response_body_tests.dart';
import 'src/isolate_test.dart';
@@ -24,6 +25,7 @@
import 'src/server_errors_test.dart';
export 'src/abort_tests.dart' show testAbort;
+export 'src/bodyless_request_tests.dart' show testBodylessRequests;
export 'src/close_tests.dart' show testClose;
export 'src/compressed_response_body_tests.dart'
show testCompressedResponseBody;
@@ -100,6 +102,7 @@
testRequestBody(clientFactory);
testRequestBodyStreamed(clientFactory,
canStreamRequestBody: canStreamRequestBody);
+ testBodylessRequests(clientFactory);
testResponseBody(clientFactory, canStreamResponseBody: canStreamResponseBody);
testResponseBodyStreamed(clientFactory,
canStreamResponseBody: canStreamResponseBody);
diff --git a/pkgs/http_client_conformance_tests/lib/src/bodyless_request_server.dart b/pkgs/http_client_conformance_tests/lib/src/bodyless_request_server.dart
new file mode 100644
index 0000000..be5f9d0
--- /dev/null
+++ b/pkgs/http_client_conformance_tests/lib/src/bodyless_request_server.dart
@@ -0,0 +1,110 @@
+// Copyright (c) 2026, 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 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+import 'dart:typed_data';
+
+import 'package:stream_channel/stream_channel.dart';
+
+/// Starts an HTTP server that captures request headers and any body bytes
+/// transferred on the wire.
+///
+/// Channel protocol:
+/// On Startup:
+/// - send port
+/// On Request Received:
+/// - send Map with 'headers' (`Map<String, List<String>>`) and 'body'
+/// (`List<int>`)
+/// When Receive Anything:
+/// - exit
+void hybridMain(StreamChannel<Object?> channel) async {
+ late ServerSocket server;
+
+ server = (await ServerSocket.bind('localhost', 0))
+ ..listen((Socket socket) {
+ final buffer = BytesBuilder();
+ var responded = false;
+
+ socket.listen(
+ (data) {
+ buffer.add(data);
+ final bytes = buffer.toBytes();
+ final headerEnd = _findHeaderEnd(bytes);
+
+ if (headerEnd != -1 && !responded) {
+ final headerText = ascii.decode(bytes.sublist(0, headerEnd));
+ final requestLine = headerText.split('\r\n').first;
+ final method = requestLine.split(' ').first;
+
+ if (method == 'OPTIONS') {
+ responded = true;
+ socket.write(
+ 'HTTP/1.1 200 OK\r\n'
+ 'Access-Control-Allow-Origin: *\r\n'
+ 'Access-Control-Allow-Methods: GET, HEAD, OPTIONS\r\n'
+ 'Access-Control-Allow-Headers: *\r\n'
+ 'Content-Length: 0\r\n\r\n',
+ );
+ unawaited(socket.close());
+ return;
+ }
+
+ responded = true;
+ final headers = _parseHeaders(headerText);
+ socket.write(
+ 'HTTP/1.1 200 OK\r\n'
+ 'Access-Control-Allow-Origin: *\r\n'
+ 'Content-Length: 0\r\n\r\n',
+ );
+ Future.delayed(const Duration(milliseconds: 100), () {
+ final allBytes = buffer.toBytes();
+ final body = allBytes.sublist(headerEnd);
+ channel.sink.add({
+ 'headers': headers,
+ 'body': body.toList(),
+ });
+ unawaited(socket.close());
+ });
+ }
+ },
+ onError: (Object _) {},
+ cancelOnError: true,
+ );
+ });
+
+ channel.sink.add(server.port);
+ await channel
+ .stream.first; // Any writes indicates that the server should exit.
+ unawaited(server.close());
+}
+
+int _findHeaderEnd(List<int> bytes) {
+ for (var i = 0; i < bytes.length - 3; i++) {
+ if (bytes[i] == 13 &&
+ bytes[i + 1] == 10 &&
+ bytes[i + 2] == 13 &&
+ bytes[i + 3] == 10) {
+ return i + 4;
+ }
+ }
+ return -1;
+}
+
+Map<String, List<String>> _parseHeaders(String headerString) {
+ final lines = const LineSplitter().convert(headerString);
+ final headers = <String, List<String>>{};
+ for (var i = 1; i < lines.length; ++i) {
+ final line = lines[i];
+ if (line.isEmpty) break;
+ final colonIndex = line.indexOf(':');
+ if (colonIndex != -1) {
+ final key = line.substring(0, colonIndex).trim().toLowerCase();
+ final value = line.substring(colonIndex + 1).trim();
+ headers.putIfAbsent(key, () => []).add(value);
+ }
+ }
+ return headers;
+}
diff --git a/pkgs/http_client_conformance_tests/lib/src/bodyless_request_server_vm.dart b/pkgs/http_client_conformance_tests/lib/src/bodyless_request_server_vm.dart
new file mode 100644
index 0000000..67e6e73
--- /dev/null
+++ b/pkgs/http_client_conformance_tests/lib/src/bodyless_request_server_vm.dart
@@ -0,0 +1,14 @@
+// Generated by generate_server_wrappers.dart. Do not edit.
+
+import 'package:stream_channel/stream_channel.dart';
+
+import 'bodyless_request_server.dart';
+
+export 'server_queue_helpers.dart' show StreamQueueOfNullableObjectExtension;
+
+/// Starts the redirect test HTTP server in the same process.
+Future<StreamChannel<Object?>> startServer() async {
+ final controller = StreamChannelController<Object?>(sync: true);
+ hybridMain(controller.foreign);
+ return controller.local;
+}
diff --git a/pkgs/http_client_conformance_tests/lib/src/bodyless_request_server_web.dart b/pkgs/http_client_conformance_tests/lib/src/bodyless_request_server_web.dart
new file mode 100644
index 0000000..bfd3b73
--- /dev/null
+++ b/pkgs/http_client_conformance_tests/lib/src/bodyless_request_server_web.dart
@@ -0,0 +1,11 @@
+// Generated by generate_server_wrappers.dart. Do not edit.
+
+import 'package:stream_channel/stream_channel.dart';
+import 'package:test/test.dart';
+
+export 'server_queue_helpers.dart' show StreamQueueOfNullableObjectExtension;
+
+/// Starts the redirect test HTTP server out-of-process.
+Future<StreamChannel<Object?>> startServer() async => spawnHybridUri(Uri(
+ scheme: 'package',
+ path: 'http_client_conformance_tests/src/bodyless_request_server.dart'));
diff --git a/pkgs/http_client_conformance_tests/lib/src/bodyless_request_tests.dart b/pkgs/http_client_conformance_tests/lib/src/bodyless_request_tests.dart
new file mode 100644
index 0000000..f98b7af
--- /dev/null
+++ b/pkgs/http_client_conformance_tests/lib/src/bodyless_request_tests.dart
@@ -0,0 +1,78 @@
+// Copyright (c) 2026, 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 'dart:async';
+
+import 'package:async/async.dart';
+import 'package:http/http.dart';
+import 'package:stream_channel/stream_channel.dart';
+import 'package:test/test.dart';
+
+import 'bodyless_request_server_vm.dart'
+ if (dart.library.js_interop) 'bodyless_request_server_web.dart';
+
+/// Tests that the [Client] correctly sends bodyless requests without framing
+/// headers (`Content-Length` or `Transfer-Encoding`) and without transferring
+/// body bytes on the wire.
+///
+/// RFC-9110 8.6 says:
+/// > A user agent SHOULD NOT send a Content-Length header field when the
+/// > request message does not contain content and the method semantics do not
+/// > anticipate such data.
+///
+/// RFC-9110 9.3.1 says:
+/// > A client SHOULD NOT generate content in a GET request unless it is made
+/// > directly to an origin server that has previously indicated, in or out of
+/// > band, that such a request has a purpose and will be adequately supported.
+void testBodylessRequests(Client Function() clientFactory) {
+ group('bodyless requests', () {
+ late Client client;
+ late String host;
+ late StreamChannel<Object?> httpServerChannel;
+ late StreamQueue<Object?> httpServerQueue;
+
+ setUp(() async {
+ client = clientFactory();
+ httpServerChannel = await startServer();
+ httpServerQueue = StreamQueue(httpServerChannel.stream);
+ host = 'localhost:${await httpServerQueue.nextAsInt}';
+ });
+ tearDown(() {
+ client.close();
+ httpServerChannel.sink.add(null);
+ });
+
+ test('client.send() with bodyless StreamedRequest GET', () async {
+ final request = StreamedRequest('GET', Uri.http(host, ''));
+ unawaited(request.sink.close());
+ final response = await client.send(request);
+ await response.stream.drain<void>();
+
+ final serverReceived = await httpServerQueue.next as Map;
+ final headers =
+ (serverReceived['headers'] as Map).cast<String, List<Object?>>();
+ final body = (serverReceived['body'] as List).cast<int>();
+
+ expect(headers.containsKey('transfer-encoding'), isFalse);
+ expect(headers.containsKey('content-length'), isFalse);
+ expect(body, isEmpty);
+ });
+
+ test('client.send() with bodyless StreamedRequest HEAD', () async {
+ final request = StreamedRequest('HEAD', Uri.http(host, ''));
+ unawaited(request.sink.close());
+ final response = await client.send(request);
+ await response.stream.drain<void>();
+
+ final serverReceived = await httpServerQueue.next as Map;
+ final headers =
+ (serverReceived['headers'] as Map).cast<String, List<Object?>>();
+ final body = (serverReceived['body'] as List).cast<int>();
+
+ expect(headers.containsKey('transfer-encoding'), isFalse);
+ expect(headers.containsKey('content-length'), isFalse);
+ expect(body, isEmpty);
+ });
+ });
+}