Skip to content

feat: support aborting HTTP requests #1773

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 32 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
32 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
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
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

* Added support for abortion of requests
* Clarify that some header names may not be sent/received.

## 1.4.0
Expand Down
81 changes: 81 additions & 0 deletions pkgs/http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,87 @@ 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 abortion of requests in-flight.

Abortion 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].

Depending on when the abortion is triggered, an [`AbortedRequest`][abortedrequest] may be thrown in different places.

```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.parse('http://example.org'),
abortTrigger: abortTrigger.future,
);

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

// Send request
final StreamedResponse response;
try {
response = await client.send(request);
} on AbortedRequest {
// 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 AbortedRequest) {
// 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 AbortedRequest {
// 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
[abortedrequest]: https://pub.dev/documentation/http/latest/http/AbortedRequest-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 @@ -19,6 +19,7 @@ export 'src/base_request.dart';
export 'src/base_response.dart'
show BaseResponse, BaseResponseWithUrl, HeadersWithSplitValues;
export 'src/byte_stream.dart';
export 'src/abortable.dart';
export 'src/client.dart' hide zoneClient;
export 'src/exception.dart';
export 'src/multipart_file.dart';
Expand Down
13 changes: 12 additions & 1 deletion pkgs/http/lib/retry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,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?)) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Don't we want to avoid retries if the request is aborted? Maybe have special handling for Abortable requests around line 116 and probably have to deal with the response stream somehow on line 125.

Are there tests that need to be updated?

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 Down
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) 2013, 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 'streamed_response.dart';

/// Enables a request to be recognised by a [Client] as abortable
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 an [AbortedRequest] exception
/// * If completed after the response headers are available, or whilst
/// streaming response bytes, clients inject [AbortedRequest] into the
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
/// streaming response bytes, clients inject [AbortedRequest] into the
/// the response, clients inject [AbortedRequest] into the

/// [StreamedResponse.stream] then finish it early
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
/// [StreamedResponse.stream] then finish it early
/// [StreamedResponse.stream] then closes the stream.

/// * If completed after the response is fully complete, there is no effect
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
/// * If completed after the response is fully complete, there is no effect
/// * 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 to 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
///
/// Usually, this is due to [Abortable.abortTrigger]. See documentation on that
/// property for more info.
class AbortedRequest implements Exception {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think that this should extend ClientException - what would mean that the request URL is accessible and that code can just catch ClientException if it doesn't want to differentiate between cancellation and other errors.

What do you think?

/// Indicator that the request has been aborted
const AbortedRequest();
}
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
/// abortion of requests. 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
38 changes: 34 additions & 4 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>[];

/// Sends an HTTP request and asynchronously returns the response.
@override
Expand All @@ -67,8 +68,16 @@ 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 {
Future<void>? canceller;
if (request case Abortable(:final abortTrigger?)) {
canceller = abortTrigger.whenComplete(() => _abortController.abort());
}

final response = await _fetch(
'${request.url}'.toJS,
RequestInit(
Expand All @@ -86,6 +95,8 @@ class BrowserClient extends BaseClient {
),
).toDart;

canceller?.ignore();

final contentLengthHeader = response.headers.get('content-length');

final contentLength = contentLengthHeader != null
Expand Down Expand Up @@ -114,18 +125,29 @@ class BrowserClient extends BaseClient {
url: Uri.parse(response.url),
reasonPhrase: response.statusText,
);
} on DOMException catch (e, st) {
if (e.name == 'AbortError') {
Error.throwWithStackTrace(const AbortedRequest(), st);
}

_rethrowAsClientException(e, st, request);
} 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
/// [AbortedRequest] or [ClientException].
@override
void close() {
for (final abortController in _openRequestAbortControllers) {
abortController.abort();
}
_isClosed = true;
_abortController.abort();
}
}

Expand Down Expand Up @@ -158,6 +180,14 @@ Stream<List<int>> _readBody(BaseRequest request, Response response) async* {
}
yield (chunk.value! as JSUint8Array).toDart;
}
} on DOMException catch (e, st) {
isError = true;

if (e.name == 'AbortError') {
Error.throwWithStackTrace(const AbortedRequest(), st);
Copy link
Collaborator

Choose a reason for hiding this comment

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

If AbortedRequest becomes a ClientException then you could move this logic to _rethrowAsClientException.

}

_rethrowAsClientException(e, st, request);
} catch (e, st) {
isError = true;
_rethrowAsClientException(e, st, request);
Expand Down
77 changes: 58 additions & 19 deletions pkgs/http/lib/src/io_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
// 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 'dart:io';

import 'abortable.dart';
import 'base_client.dart';
import 'base_request.dart';
import 'base_response.dart';
Expand Down Expand Up @@ -123,7 +125,45 @@ class IOClient extends BaseClient {
ioRequest.headers.set(name, value);
});

var response = await stream.pipe(ioRequest) as HttpClientResponse;
// We can only abort the actual connection up until the point we obtain
// the response.
// After that point, the full response bytes are always available.
// However, we instead inject an error into the response stream to match
// the behaviour of `BrowserClient`.

StreamSubscription<List<int>>? subscription;
Copy link
Collaborator

Choose a reason for hiding this comment

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

maybe ioResponseSubscription?

final controller = StreamController<List<int>>(sync: true);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe responseController?


if (request case Abortable(:final abortTrigger?)) {
abortTrigger.whenComplete(() async {
if (subscription == null) {
ioRequest.abort(const AbortedRequest());
} else {
if (!controller.isClosed) {
controller.addError(const AbortedRequest());
}
await subscription.cancel();
}
await controller.close();
});
}

final response = await stream.pipe(ioRequest) as HttpClientResponse;

subscription = response.listen(
controller.add,
onDone: controller.close,
onError: (Object err, StackTrace stackTrace) {
if (err is HttpException) {
controller.addError(
ClientException(err.message, err.uri),
stackTrace,
);
} else {
controller.addError(err, stackTrace);
}
},
);

var headers = <String, String>{};
response.headers.forEach((key, values) {
Expand All @@ -134,22 +174,20 @@ class IOClient extends BaseClient {
});

return _IOStreamedResponseV2(
response.handleError((Object error) {
final httpException = error as HttpException;
throw ClientException(httpException.message, httpException.uri);
}, test: (error) => error is HttpException),
response.statusCode,
contentLength:
response.contentLength == -1 ? null : response.contentLength,
request: request,
headers: headers,
isRedirect: response.isRedirect,
url: response.redirects.isNotEmpty
? response.redirects.last.location
: request.url,
persistentConnection: response.persistentConnection,
reasonPhrase: response.reasonPhrase,
inner: response);
controller.stream,
response.statusCode,
contentLength:
response.contentLength == -1 ? null : response.contentLength,
request: request,
headers: headers,
isRedirect: response.isRedirect,
url: response.redirects.isNotEmpty
? response.redirects.last.location
: request.url,
persistentConnection: response.persistentConnection,
reasonPhrase: response.reasonPhrase,
inner: response,
);
} on SocketException catch (error) {
throw _ClientSocketException(error, request.url);
} on HttpException catch (error) {
Expand All @@ -159,8 +197,9 @@ class IOClient extends BaseClient {

/// Closes the client.
///
/// Terminates all active connections. If a client remains unclosed, the Dart
/// process may not terminate.
/// Terminates all active connections, which may cause them to throw
/// [AbortedRequest] or [ClientException]/[SocketException]. If a client
/// remains unclosed, the Dart process may not terminate.
@override
void close() {
if (_inner != null) {
Expand Down
Loading
Loading