feat: support aborting HTTP requests (#1773)
diff --git a/pkgs/cronet_http/example/pubspec.yaml b/pkgs/cronet_http/example/pubspec.yaml
index dfaa39a..87c8234 100644
--- a/pkgs/cronet_http/example/pubspec.yaml
+++ b/pkgs/cronet_http/example/pubspec.yaml
@@ -29,3 +29,9 @@
flutter:
uses-material-design: true
+
+# TODO(brianquinlan): Remove this when a release version of `package:http`
+# supports abortable requests.
+dependency_overrides:
+ http:
+ path: ../../http/
diff --git a/pkgs/cupertino_http/example/pubspec.yaml b/pkgs/cupertino_http/example/pubspec.yaml
index 38f75b0..0d44ce4 100644
--- a/pkgs/cupertino_http/example/pubspec.yaml
+++ b/pkgs/cupertino_http/example/pubspec.yaml
@@ -38,3 +38,9 @@
flutter:
uses-material-design: true
+
+# TODO(brianquinlan): Remove this when a release version of `package:http`
+# supports abortable requests.
+dependency_overrides:
+ http:
+ path: ../../http/
diff --git a/pkgs/http/CHANGELOG.md b/pkgs/http/CHANGELOG.md
index 73e036c..ee8bfa7 100644
--- a/pkgs/http/CHANGELOG.md
+++ b/pkgs/http/CHANGELOG.md
@@ -1,5 +1,6 @@
-## 1.4.1
+## 1.5.0-wip
+* Added support for aborting requests before they complete.
* Clarify that some header names may not be sent/received.
## 1.4.0
diff --git a/pkgs/http/README.md b/pkgs/http/README.md
index 07e4394..547cb64 100644
--- a/pkgs/http/README.md
+++ b/pkgs/http/README.md
@@ -113,6 +113,92 @@
[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.
diff --git a/pkgs/http/lib/http.dart b/pkgs/http/lib/http.dart
index 317a2c1..31043f0 100644
--- a/pkgs/http/lib/http.dart
+++ b/pkgs/http/lib/http.dart
@@ -14,6 +14,7 @@
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'
diff --git a/pkgs/http/lib/retry.dart b/pkgs/http/lib/retry.dart
index dedba9a..8d8c370 100644
--- a/pkgs/http/lib/retry.dart
+++ b/pkgs/http/lib/retry.dart
@@ -52,6 +52,11 @@
/// 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,
@@ -108,11 +113,22 @@
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;
}
@@ -122,7 +138,7 @@
// 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));
@@ -133,7 +149,18 @@
/// 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)
@@ -158,5 +185,3 @@
Duration _defaultDelay(int retryCount) =>
const Duration(milliseconds: 500) * math.pow(1.5, retryCount);
-
-void _unawaited(Future<void>? f) {}
diff --git a/pkgs/http/lib/src/abortable.dart b/pkgs/http/lib/src/abortable.dart
new file mode 100644
index 0000000..dd26a48
--- /dev/null
+++ b/pkgs/http/lib/src/abortable.dart
@@ -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);
+}
diff --git a/pkgs/http/lib/src/base_request.dart b/pkgs/http/lib/src/base_request.dart
index d718e38..616eff9 100644
--- a/pkgs/http/lib/src/base_request.dart
+++ b/pkgs/http/lib/src/base_request.dart
@@ -7,6 +7,7 @@
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';
@@ -20,6 +21,10 @@
/// [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.
///
diff --git a/pkgs/http/lib/src/browser_client.dart b/pkgs/http/lib/src/browser_client.dart
index acf2334..0f57d5c 100644
--- a/pkgs/http/lib/src/browser_client.dart
+++ b/pkgs/http/lib/src/browser_client.dart
@@ -8,12 +8,14 @@
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';
@@ -49,8 +51,6 @@
/// 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.
///
@@ -58,6 +58,7 @@
bool withCredentials = false;
bool _isClosed = false;
+ final _openRequestAbortControllers = <AbortController>[];
/// Sends an HTTP request and asynchronously returns the response.
@override
@@ -67,8 +68,17 @@
'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(
@@ -81,7 +91,7 @@
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;
@@ -116,20 +126,28 @@
);
} 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: ')) {
diff --git a/pkgs/http/lib/src/io_client.dart b/pkgs/http/lib/src/io_client.dart
index fe4834b..7d64af2 100644
--- a/pkgs/http/lib/src/io_client.dart
+++ b/pkgs/http/lib/src/io_client.dart
@@ -2,13 +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 'base_client.dart';
-import 'base_request.dart';
-import 'base_response.dart';
-import 'client.dart';
-import 'exception.dart';
+import '../http.dart';
import 'io_streamed_response.dart';
/// Create an [IOClient].
@@ -123,7 +120,79 @@
ioRequest.headers.set(name, value);
});
- var response = await stream.pipe(ioRequest) as HttpClientResponse;
+ // SDK request aborting is only effective up until the request is closed,
+ // at which point the full response always becomes available.
+ // This occurs at `pipe`, which automatically closes the request once the
+ // request stream has been pumped in.
+ //
+ // Therefore, we have multiple strategies:
+ // * If the user aborts before we have a response, we can use SDK abort,
+ // which causes the `pipe` (and therefore this method) to throw the
+ // aborted error
+ // * If the user aborts after we have a response but before they listen
+ // to it, we immediately emit the aborted error then close the response
+ // as soon as they listen to it
+ // * If the user aborts whilst streaming the response, we inject the
+ // aborted error, then close the response
+
+ var isAborted = false;
+ var hasResponse = false;
+
+ if (request case Abortable(:final abortTrigger?)) {
+ unawaited(
+ abortTrigger.whenComplete(() {
+ isAborted = true;
+ if (!hasResponse) {
+ ioRequest.abort(RequestAbortedException(request.url));
+ }
+ }),
+ );
+ }
+
+ final response = await stream.pipe(ioRequest) as HttpClientResponse;
+ hasResponse = true;
+
+ StreamSubscription<List<int>>? ioResponseSubscription;
+
+ late final StreamController<List<int>> responseController;
+ responseController = StreamController(
+ onListen: () {
+ if (isAborted) {
+ responseController
+ ..addError(RequestAbortedException(request.url))
+ ..close();
+ return;
+ } else if (request case Abortable(:final abortTrigger?)) {
+ abortTrigger.whenComplete(() {
+ if (!responseController.isClosed) {
+ responseController
+ ..addError(RequestAbortedException(request.url))
+ ..close();
+ }
+ ioResponseSubscription?.cancel();
+ });
+ }
+
+ ioResponseSubscription = response.listen(
+ responseController.add,
+ onDone: responseController.close,
+ onError: (Object err, StackTrace stackTrace) {
+ if (err is HttpException) {
+ responseController.addError(
+ ClientException(err.message, err.uri),
+ stackTrace,
+ );
+ } else {
+ responseController.addError(err, stackTrace);
+ }
+ },
+ );
+ },
+ onPause: () => ioResponseSubscription?.pause(),
+ onResume: () => ioResponseSubscription?.resume(),
+ onCancel: () => ioResponseSubscription?.cancel(),
+ sync: true,
+ );
var headers = <String, String>{};
response.headers.forEach((key, values) {
@@ -134,22 +203,20 @@
});
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);
+ responseController.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) {
@@ -161,6 +228,9 @@
///
/// Terminates all active connections. If a client remains unclosed, the Dart
/// process may not terminate.
+ ///
+ /// The behavior of `close` is not defined if there are requests executing
+ /// when `close` is called.
@override
void close() {
if (_inner != null) {
diff --git a/pkgs/http/lib/src/mock_client.dart b/pkgs/http/lib/src/mock_client.dart
index 52f108a..cc02ffc 100644
--- a/pkgs/http/lib/src/mock_client.dart
+++ b/pkgs/http/lib/src/mock_client.dart
@@ -4,6 +4,7 @@
import 'dart:convert';
+import 'abortable.dart';
import 'base_client.dart';
import 'base_request.dart';
import 'byte_stream.dart';
@@ -26,6 +27,10 @@
/// This client allows you to define a handler callback for all requests that
/// are made through it so that you can mock a server without having to send
/// real HTTP requests.
+///
+/// This client does not support aborting requests directly - it is the
+/// handler's responsibility to throw [RequestAbortedException] as and when
+/// necessary.
class MockClient extends BaseClient {
/// The handler for receiving [StreamedRequest]s and sending
/// [StreamedResponse]s.
diff --git a/pkgs/http/lib/src/multipart_request.dart b/pkgs/http/lib/src/multipart_request.dart
index 7952542..e571574 100644
--- a/pkgs/http/lib/src/multipart_request.dart
+++ b/pkgs/http/lib/src/multipart_request.dart
@@ -5,6 +5,7 @@
import 'dart:convert';
import 'dart:math';
+import 'abortable.dart';
import 'base_request.dart';
import 'boundary_characters.dart';
import 'byte_stream.dart';
@@ -160,3 +161,15 @@
return '$prefix${String.fromCharCodes(list)}';
}
}
+
+/// A [MultipartRequest] which supports abortion using [abortTrigger].
+///
+/// A future breaking version of 'package:http' will merge this into
+/// [MultipartRequest], making it a requirement.
+final class AbortableMultipartRequest extends MultipartRequest with Abortable {
+ AbortableMultipartRequest(super.method, super.url, {this.abortTrigger})
+ : super();
+
+ @override
+ final Future<void>? abortTrigger;
+}
diff --git a/pkgs/http/lib/src/request.dart b/pkgs/http/lib/src/request.dart
index c15e551..b7e56ab 100644
--- a/pkgs/http/lib/src/request.dart
+++ b/pkgs/http/lib/src/request.dart
@@ -7,6 +7,7 @@
import 'package:http_parser/http_parser.dart';
+import 'abortable.dart';
import 'base_request.dart';
import 'byte_stream.dart';
import 'utils.dart';
@@ -182,3 +183,14 @@
throw StateError("Can't modify a finalized Request.");
}
}
+
+/// A [Request] which supports abortion using [abortTrigger].
+///
+/// A future breaking version of 'package:http' will merge this into [Request],
+/// making it a requirement.
+final class AbortableRequest extends Request with Abortable {
+ AbortableRequest(super.method, super.url, {this.abortTrigger}) : super();
+
+ @override
+ final Future<void>? abortTrigger;
+}
diff --git a/pkgs/http/lib/src/streamed_request.dart b/pkgs/http/lib/src/streamed_request.dart
index d10386e..8a910c5 100644
--- a/pkgs/http/lib/src/streamed_request.dart
+++ b/pkgs/http/lib/src/streamed_request.dart
@@ -4,6 +4,7 @@
import 'dart:async';
+import 'abortable.dart';
import 'base_client.dart';
import 'base_request.dart';
import 'byte_stream.dart';
@@ -53,3 +54,15 @@
return ByteStream(_controller.stream);
}
}
+
+/// A [StreamedRequest] which supports abortion using [abortTrigger].
+///
+/// A future breaking version of 'package:http' will merge this into
+/// [StreamedRequest], making it a requirement.
+final class AbortableStreamedRequest extends StreamedRequest with Abortable {
+ AbortableStreamedRequest(super.method, super.url, {this.abortTrigger})
+ : super();
+
+ @override
+ final Future<void>? abortTrigger;
+}
diff --git a/pkgs/http/pubspec.yaml b/pkgs/http/pubspec.yaml
index bd915cf..2c8eafc 100644
--- a/pkgs/http/pubspec.yaml
+++ b/pkgs/http/pubspec.yaml
@@ -1,5 +1,5 @@
name: http
-version: 1.4.1-wip
+version: 1.5.0-wip
description: A composable, multi-platform, Future-based API for HTTP requests.
repository: https://github.com/dart-lang/http/tree/master/pkgs/http
@@ -15,7 +15,7 @@
async: ^2.5.0
http_parser: ^4.0.0
meta: ^1.3.0
- web: '>=0.5.0 <2.0.0'
+ web: ">=0.5.0 <2.0.0"
dev_dependencies:
dart_flutter_team_lints: ^3.0.0
diff --git a/pkgs/http/test/html/client_conformance_test.dart b/pkgs/http/test/html/client_conformance_test.dart
index 4400c6a..51a8134 100644
--- a/pkgs/http/test/html/client_conformance_test.dart
+++ b/pkgs/http/test/html/client_conformance_test.dart
@@ -10,10 +10,13 @@
import 'package:test/test.dart';
void main() {
- testAll(BrowserClient.new,
- redirectAlwaysAllowed: true,
- canStreamRequestBody: false,
- canStreamResponseBody: true,
- canWorkInIsolates: false,
- supportsMultipartRequest: false);
+ testAll(
+ BrowserClient.new,
+ redirectAlwaysAllowed: true,
+ canStreamRequestBody: false,
+ canStreamResponseBody: true,
+ canWorkInIsolates: false,
+ supportsMultipartRequest: false,
+ supportsAbort: true,
+ );
}
diff --git a/pkgs/http/test/http_retry_test.dart b/pkgs/http/test/http_retry_test.dart
index da51154..f29be9a 100644
--- a/pkgs/http/test/http_retry_test.dart
+++ b/pkgs/http/test/http_retry_test.dart
@@ -2,6 +2,8 @@
// 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 'package:fake_async/fake_async.dart';
import 'package:http/http.dart';
import 'package:http/retry.dart';
@@ -252,4 +254,94 @@
final response = await client.get(Uri.http('example.org', ''));
expect(response.statusCode, equals(200));
});
+
+ test('abort in first response', () async {
+ final client = RetryClient(
+ MockClient(expectAsync1((_) async => throw RequestAbortedException())),
+ delay: (_) => Duration.zero,
+ );
+
+ expect(
+ client.get(Uri.http('example.org', '')),
+ throwsA(isA<RequestAbortedException>()),
+ );
+ });
+
+ test('abort in second response', () async {
+ var count = 0;
+ final client = RetryClient(
+ MockClient(
+ expectAsync1(
+ (_) async {
+ if (++count == 1) return Response('', 503);
+ throw RequestAbortedException();
+ },
+ count: 2,
+ ),
+ ),
+ delay: (_) => Duration.zero,
+ );
+
+ expect(
+ client.get(Uri.http('example.org', '')),
+ throwsA(isA<RequestAbortedException>()),
+ );
+ });
+
+ test('abort in second response stream', () async {
+ var count = 0;
+ final client = RetryClient(
+ MockClient.streaming(
+ expectAsync2(
+ (_, __) async {
+ if (++count == 1) {
+ return StreamedResponse(const Stream.empty(), 503);
+ }
+ return StreamedResponse(
+ Stream.error(RequestAbortedException()),
+ 200,
+ );
+ },
+ count: 2,
+ ),
+ ),
+ delay: (_) => Duration.zero,
+ );
+
+ expect(
+ (await client.send(StreamedRequest('GET', Uri.http('example.org', ''))))
+ .stream
+ .single,
+ throwsA(isA<RequestAbortedException>()),
+ );
+ });
+
+ test('abort without abortable inner client', () async {
+ final abortCompleter = Completer<void>();
+
+ var count = 0;
+ final client = RetryClient(
+ MockClient(
+ expectAsync1(
+ (_) async {
+ if (++count == 2) abortCompleter.complete();
+ return Response('', 503);
+ },
+ count: 2,
+ ),
+ ),
+ delay: (_) => Duration.zero,
+ );
+
+ expect(
+ client.send(
+ AbortableRequest(
+ 'GET',
+ Uri.http('example.org', ''),
+ abortTrigger: abortCompleter.future,
+ ),
+ ),
+ throwsA(isA<RequestAbortedException>()),
+ );
+ });
}
diff --git a/pkgs/http/test/io/client_conformance_test.dart b/pkgs/http/test/io/client_conformance_test.dart
index cc4b788..149b041 100644
--- a/pkgs/http/test/io/client_conformance_test.dart
+++ b/pkgs/http/test/io/client_conformance_test.dart
@@ -16,5 +16,6 @@
canSendCookieHeaders: true,
correctlyHandlesNullHeaderValues:
false, // https://github.com/dart-lang/sdk/issues/56636
+ supportsAbort: true,
);
}
diff --git a/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart b/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart
index 0686c9d..d7e21ce 100644
--- a/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart
+++ b/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart
@@ -4,6 +4,7 @@
import 'package:http/http.dart';
+import 'src/abort_tests.dart';
import 'src/close_tests.dart';
import 'src/compressed_response_body_tests.dart';
import 'src/isolate_test.dart';
@@ -22,6 +23,7 @@
import 'src/response_status_line_tests.dart';
import 'src/server_errors_test.dart';
+export 'src/abort_tests.dart' show testAbort;
export 'src/close_tests.dart' show testClose;
export 'src/compressed_response_body_tests.dart'
show testCompressedResponseBody;
@@ -49,7 +51,7 @@
//
/// If [canStreamResponseBody] is `false` then tests that assume that the
/// [Client] supports receiving HTTP responses with unbounded body sizes will
-/// be skipped
+/// be skipped.
///
/// If [redirectAlwaysAllowed] is `true` then tests that require the [Client]
/// to limit redirects will be skipped.
@@ -75,6 +77,9 @@
/// If [supportsMultipartRequest] is `false` then tests that assume that
/// multipart requests can be sent will be skipped.
///
+/// If [supportsAbort] is `false` then tests that assume that requests can be
+/// aborted will be skipped.
+///
/// The tests are run against a series of HTTP servers that are started by the
/// tests. If the tests are run in the browser, then the test servers are
/// started in another process. Otherwise, the test servers are run in-process.
@@ -90,6 +95,7 @@
bool canSendCookieHeaders = false,
bool canReceiveSetCookieHeaders = false,
bool supportsMultipartRequest = true,
+ bool supportsAbort = false,
}) {
testRequestBody(clientFactory());
testRequestBodyStreamed(clientFactory(),
@@ -116,4 +122,8 @@
canSendCookieHeaders: canSendCookieHeaders);
testResponseCookies(clientFactory(),
canReceiveSetCookieHeaders: canReceiveSetCookieHeaders);
+ testAbort(clientFactory(),
+ supportsAbort: supportsAbort,
+ canStreamRequestBody: canStreamRequestBody,
+ canStreamResponseBody: canStreamResponseBody);
}
diff --git a/pkgs/http_client_conformance_tests/lib/src/abort_server.dart b/pkgs/http_client_conformance_tests/lib/src/abort_server.dart
new file mode 100644
index 0000000..3075d77
--- /dev/null
+++ b/pkgs/http_client_conformance_tests/lib/src/abort_server.dart
@@ -0,0 +1,40 @@
+// 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 'dart:io';
+
+import 'package:async/async.dart';
+import 'package:stream_channel/stream_channel.dart';
+
+/// Starts an HTTP server that sends a stream of integers.
+///
+/// Channel protocol:
+/// On Startup:
+/// - send port
+/// When Receive Anything:
+/// - close current request
+/// - exit server
+void hybridMain(StreamChannel<Object?> channel) async {
+ final channelQueue = StreamQueue(channel.stream);
+
+ late HttpServer server;
+ server = (await HttpServer.bind('localhost', 0))
+ ..listen((request) async {
+ await request.drain<void>();
+ request.response.headers.set('Access-Control-Allow-Origin', '*');
+ request.response.headers.set('Content-Type', 'text/plain');
+
+ for (var i = 0; i < 10000; ++i) {
+ request.response.write('$i\n');
+ await request.response.flush();
+ // Let the event loop run.
+ await Future<void>.delayed(const Duration());
+ }
+ await request.response.close();
+ });
+
+ channel.sink.add(server.port);
+ unawaited(channelQueue.next.then((value) => unawaited(server.close())));
+}
diff --git a/pkgs/http_client_conformance_tests/lib/src/abort_server_vm.dart b/pkgs/http_client_conformance_tests/lib/src/abort_server_vm.dart
new file mode 100644
index 0000000..eddcd8a
--- /dev/null
+++ b/pkgs/http_client_conformance_tests/lib/src/abort_server_vm.dart
@@ -0,0 +1,14 @@
+// Generated by generate_server_wrappers.dart. Do not edit.
+
+import 'package:stream_channel/stream_channel.dart';
+
+import 'abort_server.dart';
+
+export 'server_queue_helpers.dart' show StreamQueueOfNullableObjectExtension;
+
+/// Starts the redirect test HTTP server in the same process.
+Future<StreamChannel<Object?>> startServer() async {
+ final controller = StreamChannelController<Object?>(sync: true);
+ hybridMain(controller.foreign);
+ return controller.local;
+}
diff --git a/pkgs/http_client_conformance_tests/lib/src/abort_server_web.dart b/pkgs/http_client_conformance_tests/lib/src/abort_server_web.dart
new file mode 100644
index 0000000..3dd1104
--- /dev/null
+++ b/pkgs/http_client_conformance_tests/lib/src/abort_server_web.dart
@@ -0,0 +1,14 @@
+// Generated by generate_server_wrappers.dart. Do not edit.
+
+import 'package:stream_channel/stream_channel.dart';
+import 'package:test/test.dart';
+
+export 'server_queue_helpers.dart' show StreamQueueOfNullableObjectExtension;
+
+/// Starts the redirect test HTTP server out-of-process.
+Future<StreamChannel<Object?>> startServer() async => spawnHybridUri(
+ Uri(
+ scheme: 'package',
+ path: 'http_client_conformance_tests/src/abort_server.dart',
+ ),
+ );
diff --git a/pkgs/http_client_conformance_tests/lib/src/abort_tests.dart b/pkgs/http_client_conformance_tests/lib/src/abort_tests.dart
new file mode 100644
index 0000000..bfc691b
--- /dev/null
+++ b/pkgs/http_client_conformance_tests/lib/src/abort_tests.dart
@@ -0,0 +1,262 @@
+// 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 'dart:convert';
+
+import 'package:async/async.dart';
+import 'package:http/http.dart';
+import 'package:stream_channel/stream_channel.dart';
+import 'package:test/test.dart';
+
+import 'abort_server_vm.dart'
+ if (dart.library.js_interop) 'abort_server_web.dart';
+
+/// Tests that the client supports aborting requests.
+///
+/// If [supportsAbort] is `false` then tests that assume that requests can be
+/// aborted will be skipped.
+///
+/// If [canStreamResponseBody] is `false` then tests that assume that the
+/// [Client] supports receiving HTTP responses with unbounded body sizes will
+/// be skipped.
+///
+/// If [canStreamRequestBody] is `false` then tests that assume that the
+/// [Client] supports sending HTTP requests with unbounded body sizes will be
+/// skipped.
+void testAbort(
+ Client client, {
+ bool supportsAbort = false,
+ bool canStreamRequestBody = true,
+ bool canStreamResponseBody = true,
+}) {
+ group('abort', () {
+ late String host;
+ late StreamChannel<Object?> httpServerChannel;
+ late StreamQueue<Object?> httpServerQueue;
+ late Uri serverUrl;
+
+ setUp(() async {
+ httpServerChannel = await startServer();
+ httpServerQueue = StreamQueue(httpServerChannel.stream);
+ host = 'localhost:${await httpServerQueue.nextAsInt}';
+ serverUrl = Uri.http(host, '');
+ });
+ tearDownAll(() => httpServerChannel.sink.add(null));
+
+ test('before request', () async {
+ final abortTrigger = Completer<void>();
+ final request = AbortableRequest(
+ 'GET',
+ serverUrl,
+ abortTrigger: abortTrigger.future,
+ );
+ abortTrigger.complete();
+
+ expect(
+ client.send(request),
+ throwsA(isA<RequestAbortedException>()),
+ );
+ });
+
+ test('before first streamed item', () async {
+ final abortTrigger = Completer<void>();
+
+ final request = AbortableStreamedRequest(
+ 'POST',
+ serverUrl,
+ abortTrigger: abortTrigger.future,
+ );
+
+ final response = client.send(request);
+
+ abortTrigger.complete();
+
+ expect(
+ response,
+ throwsA(isA<RequestAbortedException>()),
+ );
+
+ // Ensure that `request.sink` is still writeable after the request is
+ // aborted.
+ for (var i = 0; i < 1000; ++i) {
+ request.sink.add('Hello World'.codeUnits);
+ await Future<void>.delayed(const Duration());
+ }
+ await request.sink.close();
+ },
+ skip: supportsAbort
+ ? (canStreamRequestBody ? false : 'does not stream response bodies')
+ : 'does not support aborting requests');
+
+ test('during request stream', () async {
+ final abortTrigger = Completer<void>();
+
+ final request = AbortableStreamedRequest(
+ 'POST',
+ serverUrl,
+ abortTrigger: abortTrigger.future,
+ );
+
+ final response = client.send(request);
+ request.sink.add('Hello World'.codeUnits);
+
+ abortTrigger.complete();
+
+ expect(
+ response,
+ throwsA(isA<RequestAbortedException>()),
+ );
+
+ // Ensure that `request.sink` is still writeable after the request is
+ // aborted.
+ for (var i = 0; i < 1000; ++i) {
+ request.sink.add('Hello World'.codeUnits);
+ await Future<void>.delayed(const Duration());
+ }
+ await request.sink.close();
+ },
+ skip: supportsAbort
+ ? (canStreamRequestBody ? false : 'does not stream request bodies')
+ : 'does not support aborting requests');
+
+ test('after response, response stream listener', () async {
+ final abortTrigger = Completer<void>();
+
+ final request = AbortableRequest(
+ 'GET',
+ serverUrl,
+ abortTrigger: abortTrigger.future,
+ );
+ final response = await client.send(request);
+
+ abortTrigger.complete();
+
+ expect(
+ response.stream.single,
+ throwsA(isA<RequestAbortedException>()),
+ );
+ });
+
+ test('after response, response stream no listener', () async {
+ final abortTrigger = Completer<void>();
+
+ final request = AbortableRequest(
+ 'GET',
+ serverUrl,
+ abortTrigger: abortTrigger.future,
+ );
+ final response = await client.send(request);
+
+ abortTrigger.complete();
+ // Ensure that the abort has time to run before listening to the response
+ // stream
+ await Future<void>.delayed(const Duration());
+
+ expect(
+ response.stream.single,
+ throwsA(isA<RequestAbortedException>()),
+ );
+ });
+
+ test('after response, response stream paused', () async {
+ final abortTrigger = Completer<void>();
+
+ final request = AbortableRequest(
+ 'GET',
+ serverUrl,
+ abortTrigger: abortTrigger.future,
+ );
+ final response = await client.send(request);
+
+ final subscription = response.stream.listen(print)..pause();
+ abortTrigger.complete();
+ // Ensure that the abort has time to run before listening to the response
+ // stream
+ await Future<void>.delayed(const Duration());
+ subscription.resume();
+
+ expect(
+ subscription.asFuture<void>(),
+ throwsA(isA<RequestAbortedException>()),
+ );
+ });
+
+ test(
+ 'while streaming response',
+ () async {
+ final abortTrigger = Completer<void>();
+
+ final request = AbortableRequest(
+ 'GET',
+ serverUrl,
+ abortTrigger: abortTrigger.future,
+ );
+ final response = await client.send(request);
+
+ // Verify that fewer than the 10000 lines sent by the server are
+ // received.
+ var i = 0;
+ await expectLater(
+ response.stream
+ .transform(const Utf8Decoder())
+ .transform(const LineSplitter())
+ .listen(
+ (_) {
+ if (++i >= 1000 && !abortTrigger.isCompleted) {
+ abortTrigger.complete();
+ }
+ },
+ ).asFuture<void>(),
+ throwsA(isA<RequestAbortedException>()),
+ );
+ expect(i, lessThan(10000));
+ },
+ skip: supportsAbort
+ ? (canStreamResponseBody ? false : 'does not stream response bodies')
+ : 'does not support aborting requests',
+ );
+
+ test('after streaming response', () async {
+ final abortTrigger = Completer<void>();
+
+ final request = AbortableRequest(
+ 'GET',
+ serverUrl,
+ abortTrigger: abortTrigger.future,
+ );
+
+ final response = await client.send(request);
+ await response.stream.drain<void>();
+
+ abortTrigger.complete();
+ });
+
+ test('after response, client still useable', () async {
+ final abortTrigger = Completer<void>();
+
+ final request = AbortableRequest(
+ 'GET',
+ serverUrl,
+ abortTrigger: abortTrigger.future,
+ );
+
+ final abortResponse = await client.send(request);
+
+ abortTrigger.complete();
+
+ var requestAbortCaught = false;
+ try {
+ await abortResponse.stream.drain<void>();
+ } on RequestAbortedException {
+ requestAbortCaught = true;
+ }
+
+ final response = await client.get(serverUrl);
+ expect(response.statusCode, 200);
+ expect(response.body, endsWith('9999\n'));
+ expect(requestAbortCaught, true);
+ });
+ }, skip: supportsAbort ? false : 'does not support aborting requests');
+}
diff --git a/pkgs/http_client_conformance_tests/pubspec.yaml b/pkgs/http_client_conformance_tests/pubspec.yaml
index 2d8d652..60f8a2f 100644
--- a/pkgs/http_client_conformance_tests/pubspec.yaml
+++ b/pkgs/http_client_conformance_tests/pubspec.yaml
@@ -3,7 +3,6 @@
A library that tests whether implementations of package:http's `Client` class
behave as expected.
repository: https://github.com/dart-lang/http/tree/master/pkgs/http_client_conformance_tests
-
publish_to: none
environment:
@@ -11,10 +10,15 @@
dependencies:
async: ^2.8.2
- dart_style: '>=2.3.7 <4.0.0'
+ dart_style: ">=2.3.7 <4.0.0"
http: ^1.2.0
stream_channel: ^2.1.1
test: ^1.21.2
+# TODO(brianquinlan): Remove dependency_overrides when package:http 1.5.0 is released.
+dependency_overrides:
+ http:
+ path: ../http
+
dev_dependencies:
dart_flutter_team_lints: ^3.0.0
diff --git a/pkgs/ok_http/example/pubspec.yaml b/pkgs/ok_http/example/pubspec.yaml
index 235b649..873d9aa 100644
--- a/pkgs/ok_http/example/pubspec.yaml
+++ b/pkgs/ok_http/example/pubspec.yaml
@@ -35,3 +35,9 @@
uses-material-design: true
assets:
- test_certs/ # Used in integration tests.
+
+# TODO(brianquinlan): Remove this when a release version of `package:http`
+# supports abortable requests.
+dependency_overrides:
+ http:
+ path: ../../http/