Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion plugins/http2_adapter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@ See the [Migration Guide][] for the complete breaking changes list.**

## Unreleased

*None.*
- Fix crash on 1xx informational responses (e.g. `103 Early Hints`): interim
responses are now ignored so the request resolves with the final response
instead of throwing `Bad state: Future already completed`. Response headers
are also reset per HEADERS frame so interim headers no longer leak into the
final response. Trailer HEADERS frames (a HEADERS frame with no `:status`
after the response body, e.g. gRPC trailing metadata) are consequently
discarded rather than merged into the response headers; proper trailer
support is tracked in https://github.com/cfug/dio/issues/2602. A malformed
1xx response carrying END_STREAM now fails fast with a `connectionError`
instead of hanging until `receiveTimeout`.

## 2.9.0

Expand Down
35 changes: 31 additions & 4 deletions plugins/http2_adapter/lib/src/http2_adapter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -219,16 +219,43 @@ class Http2Adapter implements HttpClientAdapter {
responseSubscription = stream.incomingMessages.listen(
(StreamMessage message) async {
if (message is HeadersStreamMessage) {
final frameHeaders = <MapEntry<String, String>>[];
String? status;
for (final header in message.headers) {
final name = utf8.decode(header.name);
final value = utf8.decode(header.value);
responseHeaders.add(name, value);
if (name == ':status') {
status = value;
} else {
frameHeaders.add(MapEntry(name, value));
}
}

final status = responseHeaders.value(':status');
if (status != null) {
statusCode = int.parse(status);
responseHeaders.removeAll(':status');
final code = int.parse(status);
if (code >= 100 && code < 200) {
// Interim (1xx) responses precede a final response and never
// terminate the stream (RFC 9110 §15.2, RFC 9113 §8.4). A 1xx
// HEADERS frame carrying END_STREAM is therefore malformed: the
// stream ends without a final response. Fail fast instead of
// waiting forever on a pooled connection (matching the behavior
// of Go's `x/net/http2`).
if (message.endStream && !responseCompleter.isCompleted) {
responseCompleter.completeError(
DioException.connectionError(
requestOptions: options,
reason: 'Received an interim 1xx response with END_STREAM; '
'the stream ended without a final response.',
),
);
}
return;
}
statusCode = code;
responseHeaders.clear();
for (final entry in frameHeaders) {
responseHeaders.add(entry.key, entry.value);
}
needRedirect = _needRedirect(options, statusCode);
needResponse =
!needRedirect && options.validateStatus(statusCode) ||
Expand Down
217 changes: 217 additions & 0 deletions plugins/http2_adapter/test/early_hints_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import 'dart:async';
import 'dart:io';

import 'package:dio/dio.dart';
import 'package:dio_http2_adapter/dio_http2_adapter.dart';
import 'package:http2/transport.dart';
import 'package:test/test.dart';

void main() {
test('ignores 1xx interim responses and resolves with the final response',
() async {
final fixture = await _H2Fixture.serve((stream) {
stream.sendHeaders(
[
Header.ascii(':status', '103'),
Header.ascii('link', '</s.css>; rel=preload; as=style'),
],
endStream: false,
);
stream.sendHeaders(
[
Header.ascii(':status', '200'),
Header.ascii('content-type', 'text/plain'),
],
endStream: false,
);
stream.sendData('hello'.codeUnits, endStream: true);
});
addTearDown(fixture.close);

final dio = Dio()
..httpClientAdapter = Http2Adapter(fixture.connectionManager);

final response = await dio.get<String>(
'http://127.0.0.1/',
options: Options(responseType: ResponseType.plain),
);

expect(response.statusCode, 200);
expect(response.data, 'hello');
expect(response.headers.value('link'), isNull);
expect(response.headers.value('content-type'), 'text/plain');
});

test(
'completes normally when the server sends a trailer HEADERS frame '
'after DATA', () async {
// A trailer section is a HEADERS frame without `:status`, sent after the
// response body with END_STREAM (RFC 9113 §8.4, §8.8.5). Trailers are
// currently discarded (proper support tracked in
// https://github.com/cfug/dio/issues/2602); this test pins that the
// request still resolves normally instead of hanging or throwing.
final fixture = await _H2Fixture.serve((stream) {
stream.sendHeaders(
[
Header.ascii(':status', '200'),
Header.ascii('content-type', 'text/plain'),
],
endStream: false,
);
stream.sendData('hello'.codeUnits, endStream: false);
// Trailing metadata (e.g. gRPC `grpc-status`) — no `:status`.
stream.sendHeaders(
[Header.ascii('grpc-status', '0')],
endStream: true,
);
});
addTearDown(fixture.close);

final dio = Dio()
..httpClientAdapter = Http2Adapter(fixture.connectionManager);

final response = await dio.get<String>(
'http://127.0.0.1/',
options: Options(responseType: ResponseType.plain),
);

expect(response.statusCode, 200);
expect(response.data, 'hello');
expect(response.headers.value('content-type'), 'text/plain');
// Trailers are dropped for now.
expect(response.headers.value('grpc-status'), isNull);
});

test(
'fails fast when a 1xx interim response carries END_STREAM '
'instead of hanging', () async {
// A 1xx HEADERS frame with END_STREAM terminates the stream without a
// final response — malformed per RFC 9110 §15.2 and RFC 9113 §8.4. With
// `receiveTimeout` unset (the default) the request would otherwise wait
// forever on a pooled connection, so it should error out promptly.
final fixture = await _H2Fixture.serve((stream) {
stream.sendHeaders(
[Header.ascii(':status', '100')],
endStream: true,
);
});
addTearDown(fixture.close);

final dio = Dio()
..httpClientAdapter = Http2Adapter(fixture.connectionManager);

await expectLater(
dio.get<String>(
'http://127.0.0.1/',
options: Options(responseType: ResponseType.plain),
),
throwsA(
isA<DioException>()
.having((e) => e.type, 'type', DioExceptionType.connectionError)
.having(
(e) => e.message,
'message',
contains('interim 1xx response with END_STREAM'),
),
),
);
});
}

class _H2Fixture {
_H2Fixture._(
this._server,
this._serverSubscription,
this._connectionManager,
this._serverConnections,
this._subscriptions,
);

final ServerSocket _server;
final StreamSubscription<Socket> _serverSubscription;
final _TestConnectionManager _connectionManager;
final List<ServerTransportConnection> _serverConnections;
final List<StreamSubscription<dynamic>> _subscriptions;

ConnectionManager get connectionManager => _connectionManager;

static Future<_H2Fixture> serve(
void Function(ServerTransportStream stream) handler,
) async {
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final serverConnections = <ServerTransportConnection>[];
final subscriptions = <StreamSubscription<dynamic>>[];
late final StreamSubscription<Socket> serverSubscription;
serverSubscription = server.listen((socket) {
final connection = ServerTransportConnection.viaSocket(socket);
serverConnections.add(connection);
subscriptions.add(
connection.incomingStreams.listen(
(stream) {
subscriptions.add(
stream.incomingMessages.listen(
(message) {
if (message is HeadersStreamMessage) {
handler(stream);
}
},
onError: (_, __) {},
),
);
},
onError: (_, __) {},
),
);
});

final socket = await Socket.connect(server.address, server.port);
final clientConnection = ClientTransportConnection.viaSocket(socket);
return _H2Fixture._(
server,
serverSubscription,
_TestConnectionManager(clientConnection),
serverConnections,
subscriptions,
);
}

Future<void> close() async {
await _serverSubscription.cancel();
for (final subscription in _subscriptions) {
await subscription.cancel();
}
try {
await _connectionManager.terminate();
} catch (_) {}
for (final connection in _serverConnections) {
try {
await connection.terminate();
} catch (_) {}
}
await _server.close();
}
}

class _TestConnectionManager implements ConnectionManager {
_TestConnectionManager(this._connection);

final ClientTransportConnection _connection;

@override
int get cachedConnectionsCount => 1;

@override
Future<ClientTransportConnection> getConnection(
RequestOptions options,
List<RedirectRecord> redirects,
) async =>
_connection;

@override
void removeConnection(ClientTransportConnection transport) {}

@override
void close({bool force = false}) {}

Future<void> terminate() => _connection.terminate();
}