Skip to content

Commit a4f5a8d

Browse files
authored
feat: support aborting HTTP requests (#1773)
1 parent 7d2d87e commit a4f5a8d

25 files changed

+793
-43
lines changed

pkgs/cronet_http/example/pubspec.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,9 @@ dev_dependencies:
2929

3030
flutter:
3131
uses-material-design: true
32+
33+
# TODO(brianquinlan): Remove this when a release version of `package:http`
34+
# supports abortable requests.
35+
dependency_overrides:
36+
http:
37+
path: ../../http/

pkgs/cupertino_http/example/pubspec.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,9 @@ dev_dependencies:
3838

3939
flutter:
4040
uses-material-design: true
41+
42+
# TODO(brianquinlan): Remove this when a release version of `package:http`
43+
# supports abortable requests.
44+
dependency_overrides:
45+
http:
46+
path: ../../http/

pkgs/http/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
## 1.4.1
1+
## 1.5.0-wip
22

3+
* Added support for aborting requests before they complete.
34
* Clarify that some header names may not be sent/received.
45

56
## 1.4.0

pkgs/http/README.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,92 @@ the [`RetryClient()`][new RetryClient] constructor.
113113

114114
[new RetryClient]: https://pub.dev/documentation/http/latest/retry/RetryClient/RetryClient.html
115115

116+
## Aborting requests
117+
118+
Some clients, such as [`BrowserClient`][browserclient], [`IOClient`][ioclient], and
119+
[`RetryClient`][retryclient], support aborting requests before they complete.
120+
121+
Aborting in this way can only be performed when using [`Client.send`][clientsend] or
122+
[`BaseRequest.send`][baserequestsend] with an [`Abortable`][abortable] request (such
123+
as [`AbortableRequest`][abortablerequest]).
124+
125+
To abort a request, complete the [`Abortable.abortTrigger`][aborttrigger] `Future`.
126+
127+
If the request is aborted before the response `Future` completes, then the response
128+
`Future` will complete with [`RequestAbortedException`][requestabortedexception]. If
129+
the response is a `StreamedResponse` and the the request is cancelled while the
130+
response stream is being consumed, then the response stream will contain a
131+
[`RequestAbortedException`][requestabortedexception].
132+
133+
```dart
134+
import 'dart:async';
135+
136+
import 'package:http/http.dart' as http;
137+
138+
Future<void> main() async {
139+
final abortTrigger = Completer<void>();
140+
final client = Client();
141+
final request = AbortableRequest(
142+
'GET',
143+
Uri.https('example.com'),
144+
abortTrigger: abortTrigger.future,
145+
);
146+
147+
// Whenever abortion is required:
148+
// > abortTrigger.complete();
149+
150+
// Send request
151+
final StreamedResponse response;
152+
try {
153+
response = await client.send(request);
154+
} on RequestAbortedException {
155+
// request aborted before it was fully sent
156+
rethrow;
157+
}
158+
159+
// Using full response bytes listener
160+
response.stream.listen(
161+
(data) {
162+
// consume response bytes
163+
},
164+
onError: (Object err) {
165+
if (err is RequestAbortedException) {
166+
// request aborted whilst response bytes are being streamed;
167+
// the stream will always be finished early
168+
}
169+
},
170+
onDone: () {
171+
// response bytes consumed, or partially consumed if finished
172+
// early due to abortion
173+
},
174+
);
175+
176+
// Alternatively, using `asFuture`
177+
try {
178+
await response.stream.listen(
179+
(data) {
180+
// consume response bytes
181+
},
182+
).asFuture<void>();
183+
} on RequestAbortedException {
184+
// request aborted whilst response bytes are being streamed
185+
rethrow;
186+
}
187+
// response bytes fully consumed
188+
}
189+
```
190+
191+
[browserclient]: https://pub.dev/documentation/http/latest/browser_client/BrowserClient-class.html
192+
[ioclient]: https://pub.dev/documentation/http/latest/io_client/IOClient-class.html
193+
[retryclient]: https://pub.dev/documentation/http/latest/retry/RetryClient-class.html
194+
[clientsend]: https://pub.dev/documentation/http/latest/http/Client/send.html
195+
[baserequestsend]: https://pub.dev/documentation/http/latest/http/BaseRequest/send.html
196+
[abortable]: https://pub.dev/documentation/http/latest/http/Abortable-class.html
197+
[abortablerequest]: https://pub.dev/documentation/http/latest/http/AbortableRequest-class.html
198+
[aborttrigger]: https://pub.dev/documentation/http/latest/http/Abortable/abortTrigger.html
199+
[requestabortedexception]: https://pub.dev/documentation/http/latest/http/RequestAbortedException-class.html
200+
201+
116202
## Choosing an implementation
117203

118204
There are multiple implementations of the `package:http` [`Client`][client] interface. By default, `package:http` uses [`BrowserClient`][browserclient] on the web and [`IOClient`][ioclient] on all other platforms. You can choose a different [`Client`][client] implementation based on the needs of your application.

pkgs/http/lib/http.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import 'src/request.dart';
1414
import 'src/response.dart';
1515
import 'src/streamed_request.dart';
1616

17+
export 'src/abortable.dart';
1718
export 'src/base_client.dart';
1819
export 'src/base_request.dart';
1920
export 'src/base_response.dart'

pkgs/http/lib/retry.dart

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ final class RetryClient extends BaseClient {
5252
/// the client has a chance to perform side effects like logging. The
5353
/// `response` parameter will be null if the request was retried due to an
5454
/// error for which [whenError] returned `true`.
55+
///
56+
/// If the inner client supports aborting requests, then this client will
57+
/// forward any [RequestAbortedException]s thrown. A request will not be
58+
/// retried if it is aborted (even if the inner client does not support
59+
/// aborting requests).
5560
RetryClient(
5661
this._inner, {
5762
int retries = 3,
@@ -108,11 +113,22 @@ final class RetryClient extends BaseClient {
108113
Future<StreamedResponse> send(BaseRequest request) async {
109114
final splitter = StreamSplitter(request.finalize());
110115

116+
var aborted = false;
117+
if (request case Abortable(:final abortTrigger?)) {
118+
unawaited(abortTrigger.whenComplete(() => aborted = true));
119+
}
120+
111121
var i = 0;
112122
for (;;) {
113123
StreamedResponse? response;
114124
try {
125+
// If the inner client doesn't support abortable, we still try to avoid
126+
// re-requests when aborted
127+
if (aborted) throw RequestAbortedException(request.url);
128+
115129
response = await _inner.send(_copyRequest(request, splitter.split()));
130+
} on RequestAbortedException {
131+
rethrow;
116132
} catch (error, stackTrace) {
117133
if (i == _retries || !await _whenError(error, stackTrace)) rethrow;
118134
}
@@ -122,7 +138,7 @@ final class RetryClient extends BaseClient {
122138

123139
// Make sure the response stream is listened to so that we don't leave
124140
// dangling connections.
125-
_unawaited(response.stream.listen((_) {}).cancel().catchError((_) {}));
141+
unawaited(response.stream.listen((_) {}).cancel().catchError((_) {}));
126142
}
127143

128144
await Future<void>.delayed(_delay(i));
@@ -133,7 +149,18 @@ final class RetryClient extends BaseClient {
133149

134150
/// Returns a copy of [original] with the given [body].
135151
StreamedRequest _copyRequest(BaseRequest original, Stream<List<int>> body) {
136-
final request = StreamedRequest(original.method, original.url)
152+
final StreamedRequest request;
153+
if (original case Abortable(:final abortTrigger?)) {
154+
request = AbortableStreamedRequest(
155+
original.method,
156+
original.url,
157+
abortTrigger: abortTrigger,
158+
);
159+
} else {
160+
request = StreamedRequest(original.method, original.url);
161+
}
162+
163+
request
137164
..contentLength = original.contentLength
138165
..followRedirects = original.followRedirects
139166
..headers.addAll(original.headers)
@@ -158,5 +185,3 @@ bool _defaultWhenError(Object error, StackTrace stackTrace) => false;
158185

159186
Duration _defaultDelay(int retryCount) =>
160187
const Duration(milliseconds: 500) * math.pow(1.5, retryCount);
161-
162-
void _unawaited(Future<void>? f) {}

pkgs/http/lib/src/abortable.dart

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:async';
6+
7+
import 'base_request.dart';
8+
import 'client.dart';
9+
import 'exception.dart';
10+
import 'streamed_response.dart';
11+
12+
/// An HTTP request that can be aborted before it completes.
13+
abstract mixin class Abortable implements BaseRequest {
14+
/// Completion of this future aborts this request (if the client supports
15+
/// abortion).
16+
///
17+
/// Requests/responses may be aborted at any time during their lifecycle.
18+
///
19+
/// * If completed before the request has been finalized and sent,
20+
/// [Client.send] completes with [RequestAbortedException].
21+
/// * If completed after the response headers are available, or whilst
22+
/// streaming the response, clients inject [RequestAbortedException] into
23+
/// the [StreamedResponse.stream] then close the stream.
24+
/// * If completed after the response is fully complete, there is no effect.
25+
///
26+
/// A common pattern is aborting a request when another event occurs (such as
27+
/// a user action): use a [Completer] to implement this. To implement a
28+
/// timeout (to abort the request after a set time has elapsed), use
29+
/// [Future.delayed].
30+
///
31+
/// This future must not complete with an error.
32+
///
33+
/// Some clients may not support abortion, or may not support this trigger.
34+
abstract final Future<void>? abortTrigger;
35+
}
36+
37+
/// Thrown when an HTTP request is aborted.
38+
///
39+
/// This exception is triggered when [Abortable.abortTrigger] completes.
40+
class RequestAbortedException extends ClientException {
41+
RequestAbortedException([Uri? uri])
42+
: super('Request aborted by `abortTrigger`', uri);
43+
}

pkgs/http/lib/src/base_request.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'dart:collection';
77
import 'package:meta/meta.dart';
88

99
import '../http.dart' show ClientException, get;
10+
import 'abortable.dart';
1011
import 'base_client.dart';
1112
import 'base_response.dart';
1213
import 'byte_stream.dart';
@@ -20,6 +21,10 @@ import 'utils.dart';
2021
/// [BaseClient.send], which allows the user to provide fine-grained control
2122
/// over the request properties. However, usually it's easier to use convenience
2223
/// methods like [get] or [BaseClient.get].
24+
///
25+
/// Subclasses/implementers should mixin/implement [Abortable] to support
26+
/// request cancellation. A future breaking version of 'package:http' will
27+
/// merge [Abortable] into [BaseRequest], making it a requirement.
2328
abstract class BaseRequest {
2429
/// The HTTP method of the request.
2530
///

pkgs/http/lib/src/browser_client.dart

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import 'dart:js_interop';
88
import 'package:web/web.dart'
99
show
1010
AbortController,
11+
DOMException,
1112
HeadersInit,
1213
ReadableStreamDefaultReader,
1314
RequestInfo,
1415
RequestInit,
1516
Response;
1617

18+
import 'abortable.dart';
1719
import 'base_client.dart';
1820
import 'base_request.dart';
1921
import 'exception.dart';
@@ -49,15 +51,14 @@ external JSPromise<Response> _fetch(
4951
/// Responses are streamed but requests are not. A request will only be sent
5052
/// once all the data is available.
5153
class BrowserClient extends BaseClient {
52-
final _abortController = AbortController();
53-
5454
/// Whether to send credentials such as cookies or authorization headers for
5555
/// cross-site requests.
5656
///
5757
/// Defaults to `false`.
5858
bool withCredentials = false;
5959

6060
bool _isClosed = false;
61+
final _openRequestAbortControllers = <AbortController>[];
6162

6263
/// Sends an HTTP request and asynchronously returns the response.
6364
@override
@@ -67,8 +68,17 @@ class BrowserClient extends BaseClient {
6768
'HTTP request failed. Client is already closed.', request.url);
6869
}
6970

71+
final abortController = AbortController();
72+
_openRequestAbortControllers.add(abortController);
73+
7074
final bodyBytes = await request.finalize().toBytes();
7175
try {
76+
if (request case Abortable(:final abortTrigger?)) {
77+
// Tear-offs of external extension type interop members are disallowed
78+
// ignore: unnecessary_lambdas
79+
unawaited(abortTrigger.whenComplete(() => abortController.abort()));
80+
}
81+
7282
final response = await _fetch(
7383
'${request.url}'.toJS,
7484
RequestInit(
@@ -81,7 +91,7 @@ class BrowserClient extends BaseClient {
8191
for (var header in request.headers.entries)
8292
header.key: header.value,
8393
}.jsify()! as HeadersInit,
84-
signal: _abortController.signal,
94+
signal: abortController.signal,
8595
redirect: request.followRedirects ? 'follow' : 'error',
8696
),
8797
).toDart;
@@ -116,20 +126,28 @@ class BrowserClient extends BaseClient {
116126
);
117127
} catch (e, st) {
118128
_rethrowAsClientException(e, st, request);
129+
} finally {
130+
_openRequestAbortControllers.remove(abortController);
119131
}
120132
}
121133

122134
/// Closes the client.
123135
///
124-
/// This terminates all active requests.
136+
/// This terminates all active requests, which may cause them to throw
137+
/// [RequestAbortedException] or [ClientException].
125138
@override
126139
void close() {
140+
for (final abortController in _openRequestAbortControllers) {
141+
abortController.abort();
142+
}
127143
_isClosed = true;
128-
_abortController.abort();
129144
}
130145
}
131146

132147
Never _rethrowAsClientException(Object e, StackTrace st, BaseRequest request) {
148+
if (e case DOMException(:final name) when name == 'AbortError') {
149+
Error.throwWithStackTrace(RequestAbortedException(request.url), st);
150+
}
133151
if (e is! ClientException) {
134152
var message = e.toString();
135153
if (message.startsWith('TypeError: ')) {

0 commit comments

Comments
 (0)