Skip to content

feat(http2): add a pooled, multiplexed HTTP/2 http.Client - #1956

Open
demolaf wants to merge 5 commits into
dart-lang:masterfrom
demolaf:pooled-http2-client
Open

feat(http2): add a pooled, multiplexed HTTP/2 http.Client#1956
demolaf wants to merge 5 commits into
dart-lang:masterfrom
demolaf:pooled-http2-client

Conversation

@demolaf

@demolaf demolaf commented Jul 30, 2026

Copy link
Copy Markdown

Closes #1385

Adds Http2Client, a pooled, multiplexed http.Client backed by HTTP/2 connections, plus the generic ClientPool<T> it's built on — fixes dart:io's HttpClient opening one connection per concurrent request. Ported and generalized from a downstream implementation built for firebase/firebase-admin-dart#305.

  • ClientPool<T>: most-full-first packing, idle GC, failure retirement, awaitable graceful terminate()
  • Http2Client: pools per host:port, caps concurrent handshakes globally, multi-host safe (e.g. as googleapis_auth's baseClient)
  • Retries once when a pooled connection was closed by the peer before reuse
  • Added unit tests for ClientPool and integration tests for Http2Client covering multi-host pooling, connection-cap dialing, graceful terminate, and the peer-close retry

@demolaf
demolaf marked this pull request as draft July 30, 2026 09:43
@demolaf
demolaf marked this pull request as ready for review July 30, 2026 13:14
) async {
if (!transport.isOpen) throw const _ConnectionClosedByPeer();

final path =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI tells me the path should default to / when empty.

Header.ascii(':path', path),
// HTTP/2 requires lowercase header names (RFC 7540 8.1.2).
for (final entry in request.headers.entries)
Header.ascii(entry.key.toLowerCase(), entry.value),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again an AI comment:

HTTP/1.x connection-specific headers (connection, keep-alive, proxy-connection, transfer-encoding, upgrade) are forbidden in HTTP/2 and can cause strict servers to reject the stream with a protocol error. Since Http2Client may wrap requests originating from general HTTP/1.1-oriented code, consider stripping any connection-specific headers (as well as host, since :authority is already sent).

var _terminated = false;
Completer<void>? _drained;

/// The number of resources currently in the pool. For testing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then mark it as testing only

int get size => _resources.length;

/// The number of in-flight operations across every resource. For testing.
int get opCount =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also testing only annotation then

}

bool get _hasExcessIdleCapacity {
final idleCapacity = _resources.fold(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

final idleCapacity = _resources.fold(
  0,
  (total, resource) =>
      total + (resource.failed ? 0 : maxConcurrentOperations - resource.inFlight),
);

To not count failed resources, which technically have no idle capacity, into the sum

}

void _maybeCompleteDrain() {
final drained = _drained;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this just for not having to use a !? Then rather use a case final maybe?

Future<void> terminate() async {
for (final pool in _pools.values) {
await pool.terminate();
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI:

After awaiting pool.terminate() for all entries in _pools, consider calling _pools.clear();. If someone calls client.terminate() and subsequently sends a new request, clearing _pools allows _poolFor() to spin up a fresh pool rather than throwing a StateError('This pool has already been  terminated.') from the old pool. Alternatively, if Http2Client is intended to be permanently unusable after termination, adding a _closed = true flag at the top of send() would give a clearer error message.

statusCompleter.completeError(error, stackTrace);
}
bodyController.addError(error, stackTrace);
if (!bodyController.isClosed) bodyController.close();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Always use parentheses with ifs.


/// The number of connections currently pooled, across every host.
int get connectionCount =>
_pools.values.fold(0, (total, pool) => total + pool.size);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Future<MultiProtocolHttpServer> _bind() =>
MultiProtocolHttpServer.bind('localhost', 0, _serverContext());

// test/certificates/server_chain.pem is long expired and self-signed, same

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, then we should probably regenerate it?

@brianquinlan brianquinlan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll have more review feedback tomorrow. Also, this PR adds (preliminary) conformance tests: #1960

You might want to add them to this PR.

/// networks - do not use it to accept arbitrary certificates in production.
class Http2Client extends BaseClient {
Http2Client({
this.maxStreamsPerConnection = 100,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this default to SETTINGS_MAX_CONCURRENT_STREAMS send by the server?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a http.Client compatible client

3 participants