Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
e1a6c30
Added `Abortable` interface for `BaseRequest`s
JaffaKetchup May 20, 2025
b17468d
Minor improvement
JaffaKetchup May 20, 2025
12ae9df
Added support to `RetryClient` (conversion of `BaseRequest` to `Strea…
JaffaKetchup May 21, 2025
5c5f59c
Added support to `BrowserClient`
JaffaKetchup May 21, 2025
e7487c3
Revert pkgs/http/example/main.dart
JaffaKetchup May 21, 2025
b4ab341
Fixed bugs in `BrowserClient`
JaffaKetchup May 21, 2025
88b9692
Add `const`
JaffaKetchup May 22, 2025
28e05aa
Conformance tests for aborting requests
brianquinlan May 22, 2025
8563f19
Update abort_tests.dart
brianquinlan May 22, 2025
bdac03d
Make `IOClient` emit an error on the response stream when aborted
JaffaKetchup May 24, 2025
fad438d
Fix tests
JaffaKetchup May 25, 2025
55fb040
Added section to README
JaffaKetchup May 25, 2025
1c195fb
Bumped to v1.5.0
JaffaKetchup May 25, 2025
d19e9e3
Fixed formatting
JaffaKetchup May 27, 2025
05f8ae1
Fixed linting/analysis
JaffaKetchup May 27, 2025
7ee00e6
Apply basic suggestions from code review
JaffaKetchup May 27, 2025
3914e2b
Fix applied suggestion
JaffaKetchup May 27, 2025
1088ec8
Made `AbortedRequest` extend `ClientException`
JaffaKetchup May 27, 2025
917d573
s/_abortController/abortController/g
brianquinlan May 28, 2025
6b5d2e2
Update io_client.dart
brianquinlan May 28, 2025
bfe0031
Test test fix
JaffaKetchup Jun 3, 2025
e707d2c
Fix the analysis issue
JaffaKetchup Jun 3, 2025
e02a40c
Close the request sink in `during request stream` test
brianquinlan Jun 4, 2025
a57318a
Update abort_tests.dart
brianquinlan Jun 4, 2025
1cc77b0
Add an extra test
brianquinlan Jun 4, 2025
9001a1d
Don't listen to `HttpClientResponse` until `StreamedResponse` is list…
brianquinlan Jun 4, 2025
24025ba
Update io_client.dart
brianquinlan Jun 4, 2025
3d5caa7
Add a test to verify the behavior when the response is not being list…
brianquinlan Jun 5, 2025
9da76aa
Add a test for a paused stream
brianquinlan Jun 5, 2025
0b32a5a
Update `pubspec.yaml`s to use local http
brianquinlan Jun 5, 2025
b9e8543
Skip abort tests if disabled
brianquinlan Jun 5, 2025
d302777
Update abort_tests.dart
brianquinlan Jun 5, 2025
67132f6
Fix bugs with abortion timing in IOClient
JaffaKetchup Jun 25, 2025
5ac3805
Renamed `AbortedRequest` to `RequestAborted`
JaffaKetchup Jun 25, 2025
baef552
Fixed linting/analysis
JaffaKetchup Jun 25, 2025
63eabca
Skips abort tests when `supportsAbort` is `false`
brianquinlan Jun 30, 2025
c8a0403
Update abort_tests.dart
brianquinlan Jun 30, 2025
5d25620
Add a minor clarification about `abortTrigger`
brianquinlan Jun 30, 2025
ba7f6c6
Remove spurious words
brianquinlan Jun 30, 2025
3511b38
Rename aborted exception to `RequestAbortedException`
JaffaKetchup Jul 2, 2025
a9255fd
Adjust "while streaming response" test to count response lines
JaffaKetchup Jul 2, 2025
8e62b5f
Merge remote-tracking branch 'upstream/master' into pr/1773
brianquinlan Jul 2, 2025
47944b1
Merge branch 'abort-support' of https://github.com/JaffaKetchup/http …
brianquinlan Jul 2, 2025
0b4c3a0
Add const where needed
brianquinlan Jul 2, 2025
dc2b4ae
Update abort_tests.dart
brianquinlan Jul 2, 2025
a6807d5
Added abort tests for retry client
JaffaKetchup Jul 6, 2025
b4d843a
Fix lints
JaffaKetchup Jul 7, 2025
b663463
Nit
brianquinlan Jul 7, 2025
f04e4fb
Update pkgs/http/README.md
brianquinlan Jul 7, 2025
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
6 changes: 6 additions & 0 deletions pkgs/cronet_http/example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,9 @@ dev_dependencies:

flutter:
uses-material-design: true

# TODO(brianquinlan): Remove this when a release version of `package:http`
# supports abortable requests.
dependency_overrides:
Copy link
Member

Choose a reason for hiding this comment

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

Not for this PR, but we could also address this by moving this repo to a workspace.

http:
path: ../../http/
6 changes: 6 additions & 0 deletions pkgs/cupertino_http/example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,9 @@ dev_dependencies:

flutter:
uses-material-design: true

# TODO(brianquinlan): Remove this when a release version of `package:http`
# supports abortable requests.
dependency_overrides:
http:
path: ../../http/
3 changes: 2 additions & 1 deletion pkgs/http/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## 1.4.1
## 1.5.0-wip

* Added support for aborting requests before they complete.
Copy link
Member

Choose a reason for hiding this comment

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

🎉

Copy link
Contributor Author

Choose a reason for hiding this comment

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

🎉indeed 😂

* Clarify that some header names may not be sent/received.

## 1.4.0
Expand Down
86 changes: 86 additions & 0 deletions pkgs/http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,92 @@ the [`RetryClient()`][new RetryClient] constructor.

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

## Aborting requests

Some clients, such as [`BrowserClient`][browserclient], [`IOClient`][ioclient], and
[`RetryClient`][retryclient], support aborting requests before they complete.

Aborting in this way can only be performed when using [`Client.send`][clientsend] or
[`BaseRequest.send`][baserequestsend] with an [`Abortable`][abortable] request (such
as [`AbortableRequest`][abortablerequest]).

To abort a request, complete the [`Abortable.abortTrigger`][aborttrigger] `Future`.

If the request is aborted before the response `Future` completes, then the response
`Future` will complete with [`RequestAbortedException`][requestabortedexception]. If
the response is a `StreamedResponse` and the the request is cancelled while the
response stream is being consumed, then the response stream will contain a
[`RequestAbortedException`][requestabortedexception].

```dart
import 'dart:async';

import 'package:http/http.dart' as http;

Future<void> main() async {
final abortTrigger = Completer<void>();
final client = Client();
final request = AbortableRequest(
'GET',
Uri.https('example.com'),
abortTrigger: abortTrigger.future,
);

// Whenever abortion is required:
// > abortTrigger.complete();

// Send request
final StreamedResponse response;
try {
response = await client.send(request);
} on RequestAbortedException {
// request aborted before it was fully sent
rethrow;
}

// Using full response bytes listener
response.stream.listen(
(data) {
// consume response bytes
},
onError: (Object err) {
if (err is RequestAbortedException) {
// request aborted whilst response bytes are being streamed;
// the stream will always be finished early
}
},
onDone: () {
// response bytes consumed, or partially consumed if finished
// early due to abortion
},
);

// Alternatively, using `asFuture`
try {
await response.stream.listen(
(data) {
// consume response bytes
},
).asFuture<void>();
} on RequestAbortedException {
// request aborted whilst response bytes are being streamed
rethrow;
}
// response bytes fully consumed
}
```

[browserclient]: https://pub.dev/documentation/http/latest/browser_client/BrowserClient-class.html
[ioclient]: https://pub.dev/documentation/http/latest/io_client/IOClient-class.html
[retryclient]: https://pub.dev/documentation/http/latest/retry/RetryClient-class.html
[clientsend]: https://pub.dev/documentation/http/latest/http/Client/send.html
[baserequestsend]: https://pub.dev/documentation/http/latest/http/BaseRequest/send.html
[abortable]: https://pub.dev/documentation/http/latest/http/Abortable-class.html
[abortablerequest]: https://pub.dev/documentation/http/latest/http/AbortableRequest-class.html
[aborttrigger]: https://pub.dev/documentation/http/latest/http/Abortable/abortTrigger.html
[requestabortedexception]: https://pub.dev/documentation/http/latest/http/RequestAbortedException-class.html


## Choosing an implementation

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.
Expand Down
1 change: 1 addition & 0 deletions pkgs/http/lib/http.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import 'src/request.dart';
import 'src/response.dart';
import 'src/streamed_request.dart';

export 'src/abortable.dart';
export 'src/base_client.dart';
export 'src/base_request.dart';
export 'src/base_response.dart'
Expand Down
33 changes: 29 additions & 4 deletions pkgs/http/lib/retry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ final class RetryClient extends BaseClient {
/// the client has a chance to perform side effects like logging. The
/// `response` parameter will be null if the request was retried due to an
/// error for which [whenError] returned `true`.
///
/// If the inner client supports aborting requests, then this client will
/// forward any [RequestAbortedException]s thrown. A request will not be
/// retried if it is aborted (even if the inner client does not support
/// aborting requests).
RetryClient(
this._inner, {
int retries = 3,
Expand Down Expand Up @@ -108,11 +113,22 @@ final class RetryClient extends BaseClient {
Future<StreamedResponse> send(BaseRequest request) async {
final splitter = StreamSplitter(request.finalize());

var aborted = false;
if (request case Abortable(:final abortTrigger?)) {
unawaited(abortTrigger.whenComplete(() => aborted = true));
}

var i = 0;
for (;;) {
StreamedResponse? response;
try {
// If the inner client doesn't support abortable, we still try to avoid
// re-requests when aborted
if (aborted) throw RequestAbortedException(request.url);

response = await _inner.send(_copyRequest(request, splitter.split()));
} on RequestAbortedException {
rethrow;
} catch (error, stackTrace) {
if (i == _retries || !await _whenError(error, stackTrace)) rethrow;
}
Expand All @@ -122,7 +138,7 @@ final class RetryClient extends BaseClient {

// Make sure the response stream is listened to so that we don't leave
// dangling connections.
_unawaited(response.stream.listen((_) {}).cancel().catchError((_) {}));
unawaited(response.stream.listen((_) {}).cancel().catchError((_) {}));
}

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

/// Returns a copy of [original] with the given [body].
StreamedRequest _copyRequest(BaseRequest original, Stream<List<int>> body) {
final request = StreamedRequest(original.method, original.url)
final StreamedRequest request;
if (original case Abortable(:final abortTrigger?)) {
request = AbortableStreamedRequest(
original.method,
original.url,
abortTrigger: abortTrigger,
);
} else {
request = StreamedRequest(original.method, original.url);
}

request
..contentLength = original.contentLength
..followRedirects = original.followRedirects
..headers.addAll(original.headers)
Expand All @@ -158,5 +185,3 @@ bool _defaultWhenError(Object error, StackTrace stackTrace) => false;

Duration _defaultDelay(int retryCount) =>
const Duration(milliseconds: 500) * math.pow(1.5, retryCount);

void _unawaited(Future<void>? f) {}
43 changes: 43 additions & 0 deletions pkgs/http/lib/src/abortable.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright (c) 2025, 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';

import 'base_request.dart';
import 'client.dart';
import 'exception.dart';
import 'streamed_response.dart';

/// An HTTP request that can be aborted before it completes.
abstract mixin class Abortable implements BaseRequest {
/// Completion of this future aborts this request (if the client supports
/// abortion).
///
/// Requests/responses may be aborted at any time during their lifecycle.
///
/// * If completed before the request has been finalized and sent,
/// [Client.send] completes with [RequestAbortedException].
/// * If completed after the response headers are available, or whilst
/// streaming the response, clients inject [RequestAbortedException] into
/// the [StreamedResponse.stream] then close the stream.
/// * If completed after the response is fully complete, there is no effect.
///
/// A common pattern is aborting a request when another event occurs (such as
/// a user action): use a [Completer] to implement this. To implement a
/// timeout (to abort the request after a set time has elapsed), use
/// [Future.delayed].
///
/// This future must not complete with an error.
///
/// Some clients may not support abortion, or may not support this trigger.
abstract final Future<void>? abortTrigger;
}

/// Thrown when an HTTP request is aborted.
///
/// This exception is triggered when [Abortable.abortTrigger] completes.
class RequestAbortedException extends ClientException {
RequestAbortedException([Uri? uri])
: super('Request aborted by `abortTrigger`', uri);
}
5 changes: 5 additions & 0 deletions pkgs/http/lib/src/base_request.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'dart:collection';
import 'package:meta/meta.dart';

import '../http.dart' show ClientException, get;
import 'abortable.dart';
import 'base_client.dart';
import 'base_response.dart';
import 'byte_stream.dart';
Expand All @@ -20,6 +21,10 @@ import 'utils.dart';
/// [BaseClient.send], which allows the user to provide fine-grained control
/// over the request properties. However, usually it's easier to use convenience
/// methods like [get] or [BaseClient.get].
///
/// Subclasses/implementers should mixin/implement [Abortable] to support
/// request cancellation. A future breaking version of 'package:http' will
/// merge [Abortable] into [BaseRequest], making it a requirement.
abstract class BaseRequest {
/// The HTTP method of the request.
///
Expand Down
28 changes: 23 additions & 5 deletions pkgs/http/lib/src/browser_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import 'dart:js_interop';
import 'package:web/web.dart'
show
AbortController,
DOMException,
HeadersInit,
ReadableStreamDefaultReader,
RequestInfo,
RequestInit,
Response;

import 'abortable.dart';
import 'base_client.dart';
import 'base_request.dart';
import 'exception.dart';
Expand Down Expand Up @@ -49,15 +51,14 @@ external JSPromise<Response> _fetch(
/// Responses are streamed but requests are not. A request will only be sent
/// once all the data is available.
class BrowserClient extends BaseClient {
final _abortController = AbortController();

/// Whether to send credentials such as cookies or authorization headers for
/// cross-site requests.
///
/// Defaults to `false`.
bool withCredentials = false;

bool _isClosed = false;
final _openRequestAbortControllers = <AbortController>[];
Copy link
Member

Choose a reason for hiding this comment

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

This might want a type on the left ('DO type annotate fields and top-level variables if the type isn't obvious'). 'Obvious' isn't precisely defined, but adding one would also match w/ the other fields.

Suggested change
final _openRequestAbortControllers = <AbortController>[];
final List<AbortController> _openRequestAbortControllers = [];

Copy link
Member

Choose a reason for hiding this comment

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

I really don't like this style. Prefer as-is!

Devon: No offence to your bad ideas. 😛

Copy link
Collaborator

Choose a reason for hiding this comment

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

I agree with @kevmoo here - since the type is clearly indicated on the RHS, adding it explicitly is redundant


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

final abortController = AbortController();
_openRequestAbortControllers.add(abortController);

final bodyBytes = await request.finalize().toBytes();
try {
if (request case Abortable(:final abortTrigger?)) {
// Tear-offs of external extension type interop members are disallowed
// ignore: unnecessary_lambdas
unawaited(abortTrigger.whenComplete(() => abortController.abort()));
}

final response = await _fetch(
'${request.url}'.toJS,
RequestInit(
Expand All @@ -81,7 +91,7 @@ class BrowserClient extends BaseClient {
for (var header in request.headers.entries)
header.key: header.value,
}.jsify()! as HeadersInit,
signal: _abortController.signal,
signal: abortController.signal,
redirect: request.followRedirects ? 'follow' : 'error',
),
).toDart;
Expand Down Expand Up @@ -116,20 +126,28 @@ class BrowserClient extends BaseClient {
);
} catch (e, st) {
_rethrowAsClientException(e, st, request);
} finally {
_openRequestAbortControllers.remove(abortController);
}
}

/// Closes the client.
///
/// This terminates all active requests.
/// This terminates all active requests, which may cause them to throw
/// [RequestAbortedException] or [ClientException].
@override
void close() {
for (final abortController in _openRequestAbortControllers) {
abortController.abort();
}
_isClosed = true;
_abortController.abort();
}
}

Never _rethrowAsClientException(Object e, StackTrace st, BaseRequest request) {
if (e case DOMException(:final name) when name == 'AbortError') {
Error.throwWithStackTrace(RequestAbortedException(request.url), st);
}
if (e is! ClientException) {
var message = e.toString();
if (message.startsWith('TypeError: ')) {
Expand Down
Loading
Loading