Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
2 changes: 2 additions & 0 deletions pkgs/http2/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
## 3.0.1-wip

- Gracefully handle receiving headers on a stream that the client has canceled. (#1799)
- Add `Http2Client` (`package:http2/client.dart`), a pooled, multiplexed
`package:http` `Client` backed by HTTP/2 connections.

## 3.0.0

Expand Down
24 changes: 24 additions & 0 deletions pkgs/http2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,27 @@ Future<void> main() async {
An example with better error handling is available [here][example].

See the [API docs][api] for more details.

## Pooled `http.Client`

`package:http2/client.dart` provides `Http2Client`, a `package:http`
`Client` that pools and multiplexes requests over shared HTTP/2 connections
instead of opening one connection per request. This is useful for workloads
that send many concurrent requests to the same host or hosts, where
`dart:io`'s `HttpClient` (HTTP/1.1 only) would otherwise open a new TCP+TLS
connection per request.

```dart
import 'package:http2/client.dart';

Future<void> main() async {
final client = Http2Client();
final response = await client.get(Uri.parse('https://example.com/'));
print(response.body);
await client.terminate();
}
```

A connection is dialed per `host:port` as needed, so a single `Http2Client`
is safe to reuse across requests to different hosts. See the example
[here](example/pooled_client.dart).
34 changes: 34 additions & 0 deletions pkgs/http2/example/pooled_client.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// 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:io';

import 'package:http2/client.dart';

/// Sends several concurrent requests through a single [Http2Client],
/// demonstrating that they share pooled HTTP/2 connections instead of each
/// opening their own.
void main(List<String> args) async {
if (args.length != 1) {
print('Usage: dart pooled_client.dart <HTTPS_URI>');
exit(1);
}

final uri = Uri.parse(args[0]);
final client = Http2Client();

try {
final responses = await Future.wait(
List.generate(5, (_) => client.get(uri)),
);
for (final response in responses) {
print('${response.statusCode}: ${response.body.length} bytes');
}
print('Connections used: ${client.connectionCount}');
} finally {
// Waits for the requests above to finish before closing every
// connection - see Http2Client.terminate().
await client.terminate();
}
}
13 changes: 13 additions & 0 deletions pkgs/http2/lib/client.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// 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.

/// A pooled, multiplexed `package:http` `Client` backed by HTTP/2
/// connections.
///
/// See [Http2Client].
library;

import 'src/http2_client.dart' show Http2Client;

export 'src/http2_client.dart' show Http2Client;
136 changes: 136 additions & 0 deletions pkgs/http2/lib/src/client_pool.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// 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';

class _PooledResource<T> {
_PooledResource(this.future);
final Future<T> future;
int inFlight = 0;
bool failed = false;
}

/// A pool of resources of type [T].
///
/// Packs load onto the most-full resource under [maxConcurrentOperations]
/// (rather than spreading evenly across resources), opens a new resource
/// once existing ones are full, stops routing new work to a resource once an
/// operation on it throws, and garbage-collects idle resources past
/// [maxIdleResources].
class ClientPool<T> {
ClientPool(
Future<T> Function() create, {
required this.maxConcurrentOperations,
required Future<void> Function(T resource) destroy,
this.maxIdleResources = 1,
}) : _create = create,
_destroy = destroy;

final Future<T> Function() _create;
final Future<void> Function(T resource) _destroy;
final int maxConcurrentOperations;
final int maxIdleResources;

final _resources = <_PooledResource<T>>[];
var _terminated = false;
Completer<void>? _drained;

/// The number of resources currently in the pool. For testing.
Comment thread
demolaf marked this conversation as resolved.
Outdated
int get size => _resources.length;

/// The number of in-flight operations across every resource. For testing.
int get opCount =>
Comment thread
demolaf marked this conversation as resolved.
Outdated
_resources.fold(0, (total, resource) => total + resource.inFlight);

/// Runs [operation] on an available (or newly created) resource.
Future<R> run<R>(Future<R> Function(T resource) operation) async {
if (_terminated) {
throw StateError('This pool has already been terminated.');
}

final pooled = _acquire();
pooled.inFlight++;
try {
return await operation(await pooled.future);
} catch (_) {
pooled.failed = true;
rethrow;
} finally {
pooled.inFlight--;
if (_terminated) {
_maybeCompleteDrain();
} else {
await _collectIfIdle(pooled);
}
}
}

// Synchronous (no `await`), so concurrent calls can't race each other
// into both creating a resource before either sees the other's.
_PooledResource<T> _acquire() {
_PooledResource<T>? selected;
for (final resource in _resources) {
if (resource.failed) continue;
if (resource.inFlight < maxConcurrentOperations &&
(selected == null || resource.inFlight > selected.inFlight)) {
selected = resource;
}
}
if (selected != null) return selected;

final resource = _PooledResource<T>(_create());
_resources.add(resource);
return resource;
}

Future<void> _collectIfIdle(_PooledResource<T> resource) async {
if (resource.inFlight > 0) return;
if (!resource.failed && !_hasExcessIdleCapacity) return;

_resources.remove(resource);
try {
await _destroy(await resource.future);
} catch (_) {
// Best-effort: a failure here must not shadow the caller's own
// request error, since this runs inside run()'s finally block.
}
}

bool get _hasExcessIdleCapacity {
final idleCapacity = _resources.fold(
Comment thread
demolaf marked this conversation as resolved.
Outdated
0,
(total, resource) =>
total + (maxConcurrentOperations - resource.inFlight),
);
return idleCapacity > maxIdleResources * maxConcurrentOperations;
}

void _maybeCompleteDrain() {
final drained = _drained;
Comment thread
demolaf marked this conversation as resolved.
Outdated
if (drained != null && !drained.isCompleted && opCount == 0) {
drained.complete();
}
}

/// Waits for in-flight operations to finish, then destroys every
/// resource in the pool. No further operations can run afterward.
Future<void> terminate() async {
_terminated = true;

if (opCount > 0) {
_drained = Completer<void>();
await _drained!.future;
}

for (final resource in _resources) {
try {
await _destroy(await resource.future);
} catch (_) {
// Best-effort: one resource failing to close shouldn't stop the
// rest from being destroyed.
}
}
_resources.clear();
}
}
Loading
Loading