IOClient.send copies the content length onto the dart:io request like this:
|
..contentLength = (request.contentLength ?? -1) |
..contentLength = (request.contentLength ?? -1)
BaseRequest.contentLength documents null as "the size of the request is not known in advance", and StreamedRequest leaves it null unless the caller sets it. dart:io's HttpClientRequest constructor starts GET and HEAD out with contentLength = 0 (the comment there reads "GET and HEAD have 'content-length: 0' by default"). The -1 overwrites that, and on HTTP/1.1 setting contentLength = -1 removes the Content-Length header and turns on chunked transfer encoding. So a bodyless GET made through any BaseRequest with a null content length goes on the wire with a chunked empty body.
This does not reproduce through dart:io directly. Captured with the script below, same bodyless GET, Dart 3.10, http 1.6.0:
--- dart:io HttpClient.getUrl ---
"GET / HTTP/1.1\r\nuser-agent: Dart/3.10 (dart:io)\r\naccept-encoding: gzip\r\nhost: 127.0.0.1:58200\r\n\r\n"
--- package:http StreamedRequest (contentLength left null) ---
"GET / HTTP/1.1\r\nuser-agent: Dart/3.10 (dart:io)\r\ntransfer-encoding: chunked\r\naccept-encoding: gzip\r\nhost: 127.0.0.1:58202\r\n\r\n0\r\n\r\n"
The other Client implementations in this repo don't do this either: CronetClient and CupertinoClient only attach body machinery when the finalized stream has data, and browser fetch can't send chunked request bodies at all. IOClient is the only one that frames an empty GET as chunked.
Why it matters
dart-lang/sdk#60333 showed with a packet capture what a chunked bodyless GET does to googleapis.com: the server answers the GET, then treats the trailing 0\r\n\r\n terminator as the start of a second request, and the pooled connection is desynchronized. The next request on that connection fails with an HTML 400 or HttpException: Unexpected response (unsolicited response without request). The fix there was a clearer exception message, plus advice that BaseRequest implementations should set Content-Length: 0 themselves when there is no body.
The most widely used implementation that doesn't is googleapis_auth. Its AuthenticatedClient rebuilds every request as a RequestImpl that never sets contentLength, so every Google API call through an authenticated client inherits this framing. The resulting flakiness shows up in reports that were never connected to the cause: google/googleapis.dart#376 (plain http.Client() works, AuthenticatedClient desyncs) and google/googleapis.dart#665. We hit it in production as intermittent 400s and unsolicited-response errors on GCS and IAM calls.
The framing also runs against the spec. RFC 9112 §6.3 defines a request with neither Content-Length nor Transfer-Encoding as having a zero-length body, which is the natural encoding for a bodyless GET. RFC 9110 §9.3.1 says a client SHOULD NOT generate content in a GET request and warns that implementations may reject it as a request smuggling risk, which is close to what the Google frontend is doing here.
Reproduction
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
Future<String> capture(Future<void> Function(Uri url) send) async {
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final bytes = BytesBuilder();
server.listen((socket) {
var responded = false;
socket.listen((data) {
bytes.add(data);
if (!responded) {
responded = true;
socket.write('HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n');
}
});
});
await send(Uri.parse('http://127.0.0.1:${server.port}/'));
await Future<void>.delayed(const Duration(milliseconds: 100));
await server.close();
return latin1.decode(bytes.takeBytes());
}
Future<void> main() async {
final viaDartIo = await capture((url) async {
final client = HttpClient();
final request = await client.getUrl(url);
final response = await request.close();
await response.drain<void>();
client.close();
});
final viaPackageHttp = await capture((url) async {
final client = http.Client();
final request = http.StreamedRequest('GET', url);
unawaited(request.sink.close());
final response = await client.send(request);
await response.stream.drain<void>();
client.close();
});
print('--- dart:io HttpClient.getUrl ---');
print(jsonEncode(viaDartIo));
print('');
print('--- package:http StreamedRequest (contentLength left null) ---');
print(jsonEncode(viaPackageHttp));
exit(0);
}
Suggested fix
Skip the assignment when contentLength is null and let dart:io's per-method default stand:
if (request.contentLength case final contentLength?) {
ioRequest.contentLength = contentLength;
}
For methods other than GET and HEAD this changes nothing. dart:io already defaults those to chunked (the headers' internal content length starts at -1 and the request constructor enables chunked transfer encoding), so assigning -1 there is a no-op today, and the existing chunked-POST tests keep passing.
There is one observable behavior change: a GET or HEAD StreamedRequest that streams a non-empty body while leaving contentLength null goes out chunked today, and would throw after the change because the body exceeds the declared length of 0. I think that's defensible given RFC 9110 §9.3.1, but it is a change and deserves an explicit decision rather than being buried in a diff.
Some history, for context on whether the current behavior is intentional. The ?? -1 mapping goes back to 2014 (ff116dc), when BaseRequest.contentLength's "unknown" sentinel changed from -1 to null and the shim kept feeding dart:io -1. Before that commit the field was a straight passthrough. The same commit added the reverse mapping on the response side (-1 back to null), which IOClient still does. I couldn't find any issue or review where "null means chunked" was chosen on purpose for requests, and until 2021 the test suite asserted that a bodyless GET carries content-length: 0 (removed in #565 alongside an SDK change).
I have a PR ready with the fix and a regression test asserting that a bodyless streamed GET carries neither transfer-encoding nor content-length. Happy to adjust the approach if you'd rather scope it differently. We're shipping a client-side wrapper that sets known lengths regardless, since already-released versions are affected either way.
IOClient.sendcopies the content length onto the dart:io request like this:http/pkgs/http/lib/src/io_client.dart
Line 117 in a9176ac
BaseRequest.contentLengthdocumentsnullas "the size of the request is not known in advance", andStreamedRequestleaves it null unless the caller sets it. dart:io'sHttpClientRequestconstructor starts GET and HEAD out withcontentLength = 0(the comment there reads "GET and HEAD have 'content-length: 0' by default"). The-1overwrites that, and on HTTP/1.1 settingcontentLength = -1removes the Content-Length header and turns on chunked transfer encoding. So a bodyless GET made through anyBaseRequestwith a null content length goes on the wire with a chunked empty body.This does not reproduce through
dart:iodirectly. Captured with the script below, same bodyless GET, Dart 3.10, http 1.6.0:The other
Clientimplementations in this repo don't do this either:CronetClientandCupertinoClientonly attach body machinery when the finalized stream has data, and browser fetch can't send chunked request bodies at all.IOClientis the only one that frames an empty GET as chunked.Why it matters
dart-lang/sdk#60333 showed with a packet capture what a chunked bodyless GET does to googleapis.com: the server answers the GET, then treats the trailing
0\r\n\r\nterminator as the start of a second request, and the pooled connection is desynchronized. The next request on that connection fails with an HTML 400 orHttpException: Unexpected response (unsolicited response without request). The fix there was a clearer exception message, plus advice thatBaseRequestimplementations should setContent-Length: 0themselves when there is no body.The most widely used implementation that doesn't is
googleapis_auth. ItsAuthenticatedClientrebuilds every request as aRequestImplthat never setscontentLength, so every Google API call through an authenticated client inherits this framing. The resulting flakiness shows up in reports that were never connected to the cause: google/googleapis.dart#376 (plainhttp.Client()works,AuthenticatedClientdesyncs) and google/googleapis.dart#665. We hit it in production as intermittent 400s and unsolicited-response errors on GCS and IAM calls.The framing also runs against the spec. RFC 9112 §6.3 defines a request with neither Content-Length nor Transfer-Encoding as having a zero-length body, which is the natural encoding for a bodyless GET. RFC 9110 §9.3.1 says a client SHOULD NOT generate content in a GET request and warns that implementations may reject it as a request smuggling risk, which is close to what the Google frontend is doing here.
Reproduction
Suggested fix
Skip the assignment when
contentLengthis null and let dart:io's per-method default stand:For methods other than GET and HEAD this changes nothing. dart:io already defaults those to chunked (the headers' internal content length starts at -1 and the request constructor enables chunked transfer encoding), so assigning
-1there is a no-op today, and the existing chunked-POST tests keep passing.There is one observable behavior change: a GET or HEAD
StreamedRequestthat streams a non-empty body while leavingcontentLengthnull goes out chunked today, and would throw after the change because the body exceeds the declared length of 0. I think that's defensible given RFC 9110 §9.3.1, but it is a change and deserves an explicit decision rather than being buried in a diff.Some history, for context on whether the current behavior is intentional. The
?? -1mapping goes back to 2014 (ff116dc), whenBaseRequest.contentLength's "unknown" sentinel changed from-1tonulland the shim kept feeding dart:io-1. Before that commit the field was a straight passthrough. The same commit added the reverse mapping on the response side (-1back tonull), whichIOClientstill does. I couldn't find any issue or review where "null means chunked" was chosen on purpose for requests, and until 2021 the test suite asserted that a bodyless GET carriescontent-length: 0(removed in #565 alongside an SDK change).I have a PR ready with the fix and a regression test asserting that a bodyless streamed GET carries neither
transfer-encodingnorcontent-length. Happy to adjust the approach if you'd rather scope it differently. We're shipping a client-side wrapper that sets known lengths regardless, since already-released versions are affected either way.