Repository: dart-lang/http

Issue:
Support Request Cancellation, Timeouts via RequestController
Closes #204, closes #424, closes #567
Supersedes and closes #521, supersedes and closes #602, supersedes and closes #819

This PR implements an opt-in `RequestController` API to manage requests. The API is opt-in, future-proof and backwards-compatible both for implementations of `Client` and for end users. If a controller is not specified by the user, the existing functionality of the package is maintained. Likewise, clients can opt-in to the new API and show they are doing so by overriding `supportsController` to be true and implementing the functionality. Later functionality of a similar nature can also be added in an opt-in manner by simply adding it to the `RequestController` class.

See the full examples below, but a brief tour of the RequestController's functionality is as follows:
```dart
// Create a request controller, can be passed into multiple requests to
// cancel them all at once or to set timeouts on all of them at once.
// Or, one can be created per request.
final controller = RequestController(
  // Optional timeout for the overall request (entire round trip).
  timeout: Duration(seconds: 5),
  // Optional timeouts for each state of the request.
  // If a state is not specified, it will not timeout.
  partialTimeouts: PartialTimeouts(
    // Timeout for connecting to the server.
    connectTimeout: Duration(seconds: 5),
    // Timeout for the request body to be sent and a response to become
    // available from the server.
    sendTimeout: Duration(seconds: 4),
    // Timeout for processing the response body client-side.
    receiveTimeout: Duration(seconds: 6),
  ),
);
```

It can then be used as follows:
```dart
final response = await client.get(
  // URL that hangs for 5 seconds before responding.
  Uri.parse('http://localhost:3456/longrequest'),
  controller: controller,
);
// Unhandled exception:
// TimeoutException after 0:00:04.000000: Future not completed

// or,
controller.cancel(); // Causes request future to throw CancelledException
```

I note, from above, that there was existing work in both request cancellation and timeouts however I felt that this different approach could cleanly solve both issues with a single, more ergonomic, user-friendly and intuitive API. This work started originally as a private fork of the package to implement some functionality required for another package, however I felt that the results might be viable for inclusion in the upstream package.

---

Additionally, with respect to #819, I happened to implement this similarly to make it possible to abort a response of an `IOClient` (useful for applications that need to interrupt a response with a large payload). For this, I created a `ClosableStreamedResponse` that extends `StreamedResponse` with an additional `close` future.

This should only be necessary for the `IOStreamedResponse` (for which it is implemented with detachSocket and destroy which has been tested and works). The browser client appears to obtain the entire buffered response from the browser (via the `onLoad` event) and return that, so there's no sense or utility in closing that response - it can be aborted with `xhr.abort`.

For `ClosableStreamedResponse`s to be cancellable with the controller, `Response.fromStream` grabs the `ActiveRequestTracker` for the response's request (if there is one) and, if that controller has `cancel` called on it, closes the response stream if and only if the response's stream is a ClosableStreamedResponse. (See lines 70-78 of `response.dart`).

---

## Examples

<details>
<summary><b>Partial Timeouts (for each step of the request-response cycle)</b></summary>

<br>

```dart
import 'package:http/http.dart';

Future<void> main() async {
  Client client = Client();

  // Create a request controller, can be passed into multiple requests to
  // cancel them all at once or to set timeouts on all of them at once.
  // Or, one can be created per request.
  final controller = RequestController(
    // Optional timeout for the overall request (entire round trip).
    timeout: Duration(seconds: 5),
    // Optional timeouts for each state of the request.
    // If a state is not specified, it will not timeout.
    partialTimeouts: PartialTimeouts(
      // Timeout for connecting to the server.
      connectTimeout: Duration(seconds: 5),
      // Timeout for the request body to be sent and a response to become
      // available from the server.
      sendTimeout: Duration(seconds: 4),
      // Timeout for processing the response body client-side.
      receiveTimeout: Duration(seconds: 6),
    ),
  );

  final response = await client.get(
    // URL that hangs for 5 seconds before responding.
    Uri.parse('http://localhost:3456/longrequest'),
    controller: controller,
  );

  // Unhandled exception:
  // TimeoutException after 0:00:04.000000: Future not completed
  print(response.body);
}
```

_In this case, the send timeout triggers a `TimeoutException` because no response is received from the server._

Server-side logs:
```
04:29:58.608 PM [Request (/longrequest)]        resume
04:29:58.610 PM [Request (/longrequest)]        readable
04:29:58.610 PM [Request (/longrequest)]        end
04:29:58.610 PM [Request (/longrequest)]        close
04:30:02.618 PM [Response (/longrequest)]       close
```

</details>

<details>
<summary><b>Partial Timeouts (another example)</b></summary>

<br>

```dart
import 'package:http/http.dart';

Future<void> main() async {
  Client client = Client();

  // Create a request controller, can be passed into multiple requests to
  // cancel them all at once or to set timeouts on all of them at once.
  // Or, one can be created per request.
  final controller = RequestController(
    // Optional timeout for the overall request (entire round trip).
    timeout: Duration(seconds: 10),
    // Optional timeouts for each state of the request.
    // If a state is not specified, it will not timeout.
    partialTimeouts: PartialTimeouts(
      // Timeout for connecting to the server.
      connectTimeout: Duration(seconds: 5),
      // Timeout for the request body to be sent and a response to become
      // available from the server.
      sendTimeout: Duration(seconds: 4),
      // Timeout for processing the response body client-side.
      receiveTimeout: Duration(seconds: 6),
    ),
  );

  final response = await client.get(
    // URL that immediately writes headers and a partial body, then hangs for
    // 5 seconds before responding with the remainder.
    Uri.parse('http://localhost:3456/longpoll'),
    controller: controller,
  );

  // Hello, world! (the payload sent immediately)
  // Hello, world! (the payload sent after 5 seconds)
  print(response.body);
}
```

_In this case, none of the timeouts trigger an exception because the response body becomes available immediately, even if it is not complete and the server hangs for only 5 seconds before completing the response, meaning the receive timeout of 6 seconds is not triggered either._

Server-side logs:
```
04:37:36.225 PM [Request (/longpoll)]   resume
04:37:36.226 PM [Request (/longpoll)]   readable
04:37:36.226 PM [Request (/longpoll)]   end
04:37:36.226 PM [Request (/longpoll)]   close
04:37:41.230 PM [Response (/longpoll)]  finish
04:37:41.230 PM [Request (/longpoll)]   resume
04:37:41.231 PM [Response (/longpoll)]  close
```

</details>

<details>
<summary><b>Cancelation Example (cancel during response)</b></summary>

<br>

```dart
import 'package:http/http.dart';

Future<void> main() async {
  Client client = Client();

  // Create a request controller, can be passed into multiple requests to
  // cancel them all at once or to set timeouts on all of them at once.
  // Or, one can be created per request.
  final controller = RequestController();

  final responseFuture = client.get(
    // URL that hangs for 5 seconds before responding.
    Uri.parse('http://localhost:3456/longrequest'),
    controller: controller,
  );

  await Future.delayed(Duration(milliseconds: 500));
  controller.cancel();

  final response = await responseFuture;
  print(response.body);

  // Unhandled exception:
  // CancelledException: Request cancelled
  print(response.body);
}
```

_Cancels after 500ms which is whilst the response is being written. The connection is destroyed so both the client and server clean up immediately after the request is cancelled._

Server-side logs:
```
04:41:11.651 PM [Request (/longrequest)]        resume
04:41:11.651 PM [Request (/longrequest)]        readable
04:41:11.652 PM [Request (/longrequest)]        end
04:41:11.652 PM [Request (/longrequest)]        close
04:41:12.137 PM [Response (/longrequest)]       close
```

</details>

<details>
<summary><b>Cancellation Example (cancelled immediately)</b></summary>

<br>

```dart
import 'package:http/http.dart';

Future<void> main() async {
  Client client = Client();

  // Create a request controller, can be passed into multiple requests to
  // cancel them all at once or to set timeouts on all of them at once.
  // Or, one can be created per request.
  final controller = RequestController();

  final responseFuture = client.get(
    // URL that hangs for 5 seconds before responding.
    Uri.parse('http://localhost:3456/longrequest'),
    controller: controller,
  );

  controller.cancel();

  final response = await responseFuture;
  print(response.body);

  // Unhandled exception:
  // CancelledException: Request cancelled
  print(response.body);
}
```

_The request is cancelled immediately and isn't even logged on the server._

Server-side logs:
```
(none)
```

</details>

---

Server implementation and Dart client example used to test:
https://github.com/SamJakob/cancellable_http_dart_testing

---

- [x] I’ve reviewed the contributor guide and applied the relevant portions to this PR.

<details>
  <summary>Contribution guidelines:</summary><br>

- See our [contributor guide](https://github.com/dart-lang/.github/blob/main/CONTRIBUTING.md) for general expectations for PRs.
- Larger or significant changes should be discussed in an issue before creating a PR.
- Contributions to our repos should follow the [Dart style guide](https://dart.dev/guides/language/effective-dart) and use `dart format`.
- Most changes should add an entry to the changelog and may need to [rev the pubspec package version](https://github.com/dart-lang/sdk/wiki/External-Package-Maintenance#making-a-change).
- Changes to packages require [corresponding tests](https://github.com/dart-lang/.github/blob/main/CONTRIBUTING.md#Testing).

Note that many Dart repos have a weekly cadence for reviewing PRs - please allow for some latency before initial review feedback.
</details>

Request cancellation/abort through cancellation-token
This is an effort trying to tackle the problems discussed in #567, #424, and #204.

This solution introduces a `CancellationToken`, which is passed into the http-methods of `Client` e.g.:
```dart
http.get(url,
      cancellationToken: http.CancellationToken(autoDispose: false));
```
This design is avoiding breaking changes.

Despite most  cancellation-token-based solutions (working with a simple `Completer`), this approach is also trying to really `abort()`a request if necessary.

It is possible to add multiple requests to a `CancellationToken`, to cancel them at once when triggered (disposal of the token has to get done manually in that case).

A request can (if requested) get aborted at multiple points:
1. Before the request is getting prepared
2. When the request is getting registered at the `CancellationToken`
3. When the request is running, through a `Stream`-based invoke of `abort()` 

Status:
Cancellation is supported and implemented for both `IOClient` and `BrowserClient`.
Existing tests have been fixed, but no new tests written yet.
It will need some further reviews.

Problems:
The `abort()` on `HttpRequest` (for web) doesn't seem to have any effect during my tests (request time < 800ms) - although it's called during the according `readyState` (https://api.flutter.dev/flutter/dart-html/HttpRequest/abort.html)

Add support for request timeout
Wire up a timer to perform the appropriate type of aborting and resource
cleanup for each client. Handles canceling the timer when the request
completes.
Add ability to close a StreamedRequest
Adds an extra `close` function to `StreamedRequest` and implements it in `IoStreamedRequest` as well as adds a `BrowserStreamedRequest` for implementing it
Design an API to support aborting requests
On the web `HttpRequest` supports `abort`: https://api.dart.dev/stable/2.8.1/dart-html/HttpRequest/abort.html

We have a proposal for adding abort on the `dart:io` `HttpClientRequest`: https://dart-review.googlesource.com/c/sdk/+/147339

We need to come up with a design for supporting `abort` through the interfaces in this package and validate that we can build it on both `dart:html` and `dart:io` implementations.

The easiest option might be to add a method on `StreamedResponse`. There is some precedent for that with `IOStreamedResponse.detachSocket`. https://github.com/dart-lang/http/blob/9063ba379b7e7386cced830feb27cc7a5fd3ec7e/lib/src/io_streamed_response.dart#L36 (we need to make sure the differences between these are clearly documented.

I do wonder if we need to consider some sort of support for the more convenient methods than `send` though...

cc @kevmoo @jakemac53 @grouma @zichangg @lrhn for thoughts


Discussion: I think generally the ability to control timeouts over individual parts of the lifecycle would be beneficial for specific use cases - particularly those dealing with large payloads - off the top of my head, some examples could be:
- to abort a connection if connecting to the server takes too long, but not once the initial connection has been made.
- to trigger a timeout only once the response is being delivered (for example, uploading a large payload that takes - say 30 seconds - to upload and subsequently alotting a time frame in which to start receiving a response).

These could be used similarly to the `headerTimeout` and `contentTimeout` discussed in #521.

Also, `sendTimeout` refers to the point in the lifecycle where the response headers are delivered because that I considered to be the end of the `sending` phase, but maybe it's more intuitive to call that `headersTimeout` and have `responseTimeout` be `contentTimeout`.

I would imagine it is most useful for things like `StreamedResponse` where you could set a timeout for the initial connection and response headers (`connectTimeout` and `sendTimeout`) but not for the response or the request as a whole.

Also, the lifecycle is tracked implicitly for the purposes of properly supporting cancellation in any client implementing the `RequestController` API, so I felt it useful to expose the timeouts for each of those stages too.
I think that `partialTimeouts` is a pretty complex API to implement and I'm not sure that it can be implemented completely on any `Client` other than `IOClient`.

Would it make sense to ask people who need these more advanced features to use `package:dio`? Though I'm not sure if their timeout, cancellation and progress support works [if adapting from a `package:http` `Client`](https://github.com/cfug/dio/blob/main/plugins/native_dio_adapter/lib/src/conversion_layer_adapter.dart).

I agree that it is complex and maybe there is a point to be made that its complexity falls outside the scope of the package. I had some reservations about `partialTimeouts` with respect to testing too. That said, I replied to your review comment to address implementing it in `BrowserClient` with a streaming implementation of the `fetch` API that could allow for implementation of both platforms in a fairly similar way.

Also, as mentioned before, tracking the lifecycle in a similar manner would still be essential (at least on `dart:io`) to ensure the request is cancelled properly on certain platforms. It would just be a matter of determining the costs/benefits of exposing timeouts over it as an API.

I think the major benefit of this package is that it encapsulates all of the platform-specific implementations and exposes a single consistent API so as long as it is done consistently I would advocate including the feature in this package because it would save anyone needing a cross-platform HTTP solution that also requires control over `partialTimeouts` from having to re-implement each platform implementation.
Just seen that this has been re-opened! 🎉

I've been wondering whether an approach without a dedicated controller could work and the timeouts part could work to get the basic functionality implemented faster.

Regardless, hoping this can be merged as relying on Dio for this is a bit annoying!

(Separately, I was wondering whether aborting HTTP is possible in WASM?)

Thanks for your pull request. It looks like this may be your first contribution to a Google open source project (if not, look below for help). Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

:memo: **Please visit <https://cla.developers.google.com/> to sign.**

Once you've signed (or fixed any issues), please reply here with `@googlebot I signed it!` and we'll verify it.

----

#### What to do if you already signed the CLA

##### Individual signers

*   It's possible we don't have your GitHub username or you're using a different email address on your commit. Check [your existing CLA data](https://cla.developers.google.com/clas) and verify that your [email is set on your git commits](https://help.github.com/articles/setting-your-email-in-git/).

##### Corporate signers

*   Your company has a Point of Contact who decides which employees are authorized to participate. Ask your POC to be added to the group of authorized contributors. If you don't know who your Point of Contact is, direct the Google project maintainer to [go/cla#troubleshoot](http://go/cla#troubleshoot) ([Public version](https://opensource.google/docs/cla/#troubleshoot)).
*   The email used to register you as an authorized contributor must be the email used for the Git commit. Check [your existing CLA data](https://cla.developers.google.com/clas) and verify that your [email is set on your git commits](https://help.github.com/articles/setting-your-email-in-git/).
*   The email used to register you as an authorized contributor must also be [attached to your GitHub account](https://github.com/settings/emails).
		

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2Fdart-lang%2Fhttp%2Fpull%2F602) for more info**.

<!-- need_sender_cla -->
@FaFre - did you have a chance to check out the CLA agreement linked above?
@natebosch - haven't had the time to read the agreement yet. Will do it today or tomorrow.
@googlebot I signed it!
@FaFre Thank you very much for this Draft and PR
This is a very important feature.
Just to make sure I have a question:
if user is downloading a big file and they are at 50%
when the download is cancelled, the rest of the file will not be downloaded right?
Also does Firebase Storage give the necesaary server support out of the box?
Thanks Fabian.
@aytunch I'm happy you find use in it :+1: 
Well, it depends. The provided API is pretty high level. The only option we have is to call [abort](https://api.dart.dev/stable/2.10.2/dart-io/HttpClientRequest/abort.html), but the definition, what actually happens with the underlying TCP connection is not given and will probably also vary depending on the underlying platform libraries/native client.

You need to check that on your own with an package sniffer on each platform you want to provide your app for (unless somebody did already research on that...). I'm not familiar with Firebase storage but I assume you just have an basic HTTP-Download. In that case it should be enough to close the TCP connection to stop the download. And this is what you need to confirm with an package sniffer.
I understand @FaFre thanks
Do you have an ETA for this PR?
@aytunch 
I use the feature already in my App's since a couple of months and didn't encounter any problems with it (not targeting web). 

However, the implementation for web may need some further investigation. Although I implemented it like in the documentation described, my requests didn't get cancelled accordingly. I couldn't fix that on my own so far, but it's also possible that this effect is caused by the dart libraries and I do everything correctly here.
@aytunch  While doing some research on the web request I actually discovered that also the dart:io implementation isn't able to abort requests once the server started sending data.

The implementation seems to see a request as done once the server starts sending data. Therefore they cannot get aborted anymore.

```dart
final bigFileUrl = Uri.https('ftp5.gwdg.de',
      '/pub/misc/openstreetmap/planet.openstreetmap.org/pbf/planet-latest.osm.pbf'); //~60GB file
  final req = await HttpClient().getUrl(bigFileUrl);
  unawaited(Future.delayed(const Duration(seconds: 1)).whenComplete(() {
    req.abort();
    print('sent abort');
  }));

  final resp = await req.close();
  print(await resp.toList());
```

The code above confirms with DevTools that the HTTP request is still running and memory is filling up.

@natebosch I saw you also did some research on abort() and opened some issues regarding it. Is this behavior already discussed somewhere? 
@FaFre Have you seen this issue?
https://github.com/dart-lang/http/issues/424

> Is this behavior already discussed somewhere?

I think once we have a response, instead of `request.abort()` we need to use `response.detachSocket()`.

https://github.com/dart-lang/http/pull/521/files#diff-7a772406b9a2c46d9b0b95da73d10dd5ad9807fa78c9519bac0d247690482957R83


I do have plans to revisit timeouts and request aborting soon. I suspect we won't be able to use this PR because it would be a breaking change that might be too hard to roll out, but I have not yet finished exploring the implications of various designs.
I've been working on a fork with cancellation using a CancellationToken, but I've taken a different approach with the tokens so they're not specific to HTTP requests: https://github.com/JonathanPeterCole/dart-cancellation-token-http
I haven't had a chance to properly test this implementation in a Flutter app yet though.
Closing this, as it is stale and the files are out of date. Feel free to reopen in case you still want to land it!
cc @lrhn @jakemac53 

I'd like to get some initial feedback on the approach and whether this is worth doing.

I think this is worth doing because timeouts are a common case for wanting to abort requests, and they are easier to get wrong than to get right. The naive approach of `.timeout` doesn't clean up resources and the entire result is still going to get read. Even with a general mechanism to abort requests a timeout argument is handy because we can handle the timer cancellation which would otherwise be boilerplate. We also likely can't arrive at a solid design for request aborting in general, but the API design with `timeout:` argument is fairly straightforward.

Please let me know what you think of this initial version, if we think it looks like a good approach I can start work on tests. I'm not sure how easy it is going to be to cover all the behavior here well.
One more question about the API design.

Some backround: There are differences between behaviors with the `send` API. In the browser the `Future` won't complete until the entire response body is read, while on the vm the `Future` will complete as soon as headers are received, and the response body `stream` is kept open until the entire body is read. I do think it's plausible we could change this in the browser - perhaps not going all the way to a real streaming implementation, but potentially at least making the `Future` complete with the headers. I don't know if we need to block on that.

The immediate question is, should the Stream emit the Timeout or not. If it doesn't the timeout would apply only to receiving the headers and you'd need to cancel a stream subscription to get a full timeout.
My current implementation emits the timeout, which is convenient because it makes the `Future` only APIs work as expected without any extra effort.
It sounds like we have two APIs, one future based and one stream based.
They should probably act differently.

It's hard to say what a "timeout" means if the author cannot specify it. Do they need the entire result before that time (when do I need to move on, with or without a response?), or do they just need some evidence that the remote end exists and is willing to answer before that time (how long do I wait if the remote server is down?). I can see both use-cases as valid.

Is there any way to *cancel* the timeout? So, set a timeout of 5 seconds, which will then kill the connection after that time (and put an exception on the stream), but if I get a header and plausible start of the response before that time, then I can cancel the timeout (or start a new timer). That would make it easier for the client to control the timeout behavior without having to know up-front whether they're waiting for a non-remote-end timeout or a large response.
I can see the use cases for both variants of timeout. One thing I'm not sure of is whether most authors would want to timeout the connection (which they can do [with a `dart:io` client](https://api.dart.dev/stable/2.10.5/dart-io/HttpClient/connectionTimeout.html)) or the headers.
> I can see both use-cases as valid.

I agree that both are probably valid, it's hard to say whether both are worth the API space. After some discussion I think we are leaning towards the timeout applying to the Stream as well. It's slightly less general, but also a friendlier API for the case it solves, and my hunch is that is the more common one.

What we can do, though, is hold on to a little room for the other one. If we name the argument `contentTimeout` instead of `timeout` then it's easier to add a `headerTimeout` argument later. It would still be a breaking change and a major version bump, but the vast majority of uses would not be impacted.

Currently I'm leaning towards using the name `timeout` for the `Future` APIs, and `contentTimeout` for the `Future<Stream>` API. We _could_ add a `headerTimeout` to the `Future` APIs but it starts to get weird to have a `Future` which might be timed out within some period, or might wait indefinitely (or to some longer separate timeout) without any way to observe what is happening. I don't know if anyone is asking for a timeout specifically for headers either - I think most users expect a `Future` to have a single timeout, and the name `timeout` is nicest for that. I'd be happy to discuss using the name `contentTimeout` for all of these arguments consistently if anyone wants to bikeshed a bit more or thinks it is important to leave room to add a `headerTimeout` for these other APIs as well.

> Is there any way to cancel the timeout?

Not that I can think of without a difficult to understand API.
After some further discussion we think there is more risk in getting this API wrong than there is in delaying it until after the dust settles on NNBD and we can take more time to design it alongside general request aborting and decide about the rollout plan from there. We may end up with a slower alternate-import type migration for request aborting, and so we could use that same approach to deliver a timeout feature alongside abort with a gradual migration.

We should continue to use this discussion to inform our future design, but I don't think I'll hold the null safe publish on it.
One thing I'm noticing about this as I look at overrides of `send` in other client implementations - it isn't very friendly to deal with the `Timer`. From an overrider standpoint the `Future<void> abortOn` seems easier. It's unfortunate that the design for customizing a client deals with overriding a public facing API - if those were separate signatures we could give a friendly-to-use API for users and a friendly-to-implement API for implementers.
This PR contains important features, has gone under extensive reviews, and should be merged by now.  
There are only a few improvements left on the documentation, and some merge conflicts.  
@natebosch Can you please address this? So an approval and merge can be made?
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this [failed invocation](https://github.com/dart-lang/http/pull/819/checks?check_run_id=9439860416) of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.
Closing this as it is stale and the CLA is missing. Feel free to reopen in case you still want to land it!
> The easiest option might be to add a method on StreamedResponse.
The proposed dart:io's abort() is a method in Class HttpClientRequest. The
method on StreamedResponse should not be able to access
abort().(Technically you can, but it won't make a difference as the
response has already come back)

I think the only use case for abort() is timing out the request if it takes
too long. We can adapt `send` to accept an optional parameter `duration`
and possibly a callback to be fired when timing out.

On Thu, May 28, 2020 at 12:52 PM Nate Bosch <notifications@github.com>
wrote:

> On the web HttpRequest supports abort:
> https://api.dart.dev/stable/2.8.1/dart-html/HttpRequest/abort.html
>
> We have a proposal for adding abort on the dart:io HttpClientRequest:
> https://dart-review.googlesource.com/c/sdk/+/147339
>
> We need to come up with a design for supporting abort through the
> interfaces in this package and validate that we can build it on both
> dart:html and dart:io implementations.
>
> The easiest option might be to add a method on StreamedResponse. There is
> some precedent for that with IOStreamedResponse.detachSocket.
> https://github.com/dart-lang/http/blob/9063ba379b7e7386cced830feb27cc7a5fd3ec7e/lib/src/io_streamed_response.dart#L36
> (we need to make sure the differences between these are clearly documented.
>
> I do wonder if we need to consider some sort of support for the more
> convenient methods than send though...
>
> cc @kevmoo <https://github.com/kevmoo> @jakemac53
> <https://github.com/jakemac53> @grouma <https://github.com/grouma>
> @zichangg <https://github.com/zichangg> for thoughts
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/dart-lang/http/issues/424>, or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AK3IVS7MXK2PWZYCPT4NA7TRT26GZANCNFSM4NNMB7PA>
> .
>


-- 
Best Regards,
Zichang

Here are a couple other options - I'm not advocating for these, just trying to brainstorm. Both of these are breaking changes for anyone with `implements http.Client`.

Allow passing a Future back to the client to cancel it. We'd have some state that lets us abort if you give us back the exact same instance.

```dart
var response = client.get(url);
// something happens
client.abort(response);
```

Add a named argument to all the methods:

```dart
var abortCompleter = Completer<void>();
var response = client.get(url, abortOn: abortCompleter.future);
// something happens
abortCompleter.complete();
```

The latter could pair well with a `timeout` argument. Passing `timeout: Duration(seconds: 5)` would have the same effect as passing `abortOn: Future.delayed(Duration(seconds: 5))`
I like the latter assuming a future that completes after the request has completed doesn't cause an issue.
Hey, in my app I want to give the user an option to abort while uploading a file. I saw that dart:io is going to support it soon. However I use http package in my app so I looked here and saw this thread. Do you think you'll support it too, soon? Thanks
I think having an `abort` method on the response itself is really the ideal design here, for a few reasons:

- It doesn't require the Client to keep track of outgoing requests. This is cleaner, more memory efficient, and less likely to result in memory leaks.
- It is more intuitive as a user of the API - all the useful functionality is encapsulated on the response itself - you can pass that one object around to other parts of the code and they can decide to cancel it, etc. They don't also need a client, and you can decouple the knowledge about when to cancel from the service making the actual requests.
- It allows you to synchronously cancel requests, instead of waiting an arbitrary amount of time for the event queue to catch up. This helps to mitigate race conditions between the cancellation and completion of the request, and it helps to waste less data by cancelling the request sooner.

This does get a bit trickier to implement - you are left with a few not so great options:

- Implement `Future` with some custom type, which beyond being considered an anti-pattern also raises some difficult questions around how you handle the `cancel` case. I think for this you could just complete with a special type of `error`, and not try to do what `CancelableOperation` does (with a special `onCancel` callback that complicates error handling).
- Make a user-facing breaking change in the api to return some new type (possibly `CancelableOperation`).
> Do you think you'll support it too, soon?

The trickiest part of this is rolling it out, I think no matter what design we use it's going to be a breaking change for anyone with a class which `implements Client`.

> * It doesn't require the Client to keep track of outgoing requests. This is cleaner, more memory efficient, and less likely to result in memory leaks.

I don't think that will be difficult in any case. We don't need an extra collection - we can handle this all at the point where we have the `HttpClientRequest` or the `HttpRequest`.

> * It is more intuitive as a user of the API - all the useful functionality is encapsulated on the response itself - you can pass that one object around to other parts of the code and they can decide to cancel it, etc. They don't also need a client, and you can decouple the knowledge about when to cancel from the service making the actual requests.

Attaching it to a `StreamedResponse` has the downside of pushing more users onto that API over `http.get`. I think cancellation (usually timeout) is a common enough use case that we want to support it on those APIs as well. Changing the return type from `Future` to `CancelablleOperation` is more breaking, and I'm not entirely convinced one way or the other whether it's a better API if we were starting from scratch.

> * It allows you to synchronously cancel requests, instead of waiting an arbitrary amount of time for the event queue to catch up. This helps to mitigate race conditions between the cancellation and completion of the request, and it helps to waste less data by cancelling the request sooner.

I would expect that the typical case cancelations only happen after some async trigger either way. I think at most we could shave a microtask or 2, but I don't think that gives much power to typical usages. Which likely can't make many guarantees about behavior even if we give them a synchronous cancel.
I'm running with the `abortOn` approach for now.

One thing that's tricky here, I didn't realize the `dart:io` implementation causes the `done` future to complete as an error as opposed to never completing. I'm pretty sure the implementation in the browsers wouldn't do that.

I _think_ it would be easier to build the error behavior in the browser than to build the never-complete behavior for `dart:io` but probably either is possible.

I don't have a strong opinion between completing as error on abort, or never completing on abort. One nice thing about the error approach is that if you do want to forward an error object and stack trace through it's possible to do so.

cc @lrhn for opinions on completing as error vs never completing for aborted requests.
I really don't think `abortOn` with a `Future` type is the right api here... it has a lot of downsides. Specifically since you can't cancel the future at all I think it will be prone to memory leaks, hung processes on exit, and other similar issues.

How about just supporting the `timeout` argument which we can ensure is implemented better (using a timer which we can cancel) as opposed to a delayed future?
This is a must have feature. A lot of community backed packages are using this package at their core. And because there is not yet a abort functionality for downloads, there are huge amount of wasted data for Flutter users. This is really bad user experience when users check their data usage on their phones.

I am not sure why people are so concerned about breaking changes. Flutter is still new. Breaking changes are to be expected and for a core functionality like this, I am sure devs wont complain. 

Imho what devs would really complain about could be a platform with APIs so hard to understand because they were patched for every new feature instead of getting redesigned. If we want Flutter to remain and feel fresh and different from the old and slow SDks then we have to live with breaking changes for a while.

Personally, the lack of aborting is a big deal for us because we have a video heavy app and we are using flutter_cache_manager to pre-download some of the videos in the list. If the user scrolls even further, we would like to abort the started downloads and start new ones. But for now we are stuck with 10's of started downloads which will be just byte waste.
Hi, is there any updates after half a year?

Related: https://stackoverflow.com/questions/68615374/proper-way-of-setting-request-timeout-for-flutter-http-requests
Related proposal: [HttpClient deadline and retry (dart-lang/sdk#46557)](https://github.com/dart-lang/sdk/issues/46557)
@natebosch – what's the current story here?
This is still something we plan on looking at but we don't have a concrete plan.
Moving on to 2022, we would still be very grateful to have this fixed. I agree 100% with @aytunch - a breaking change is something many developers can live with. At least it's much better than to leave this issue stale as it's affecting many simple use cases like stopping a download/upload of a very big file. If you can please move this to a higher priority... Thanks 🙏🏻
After two years (and half a year after last comments), is there any updates...?  This is very frequently used and wanted feature.
Putting this on your radar, @brianquinlan 
I'll be following this thread too
Any updates? I am willing to PR
Meanwhile, Dio supports `abort()` since March
Proof: https://github.com/flutterchina/dio/pull/1440
@subzero911 Is it a "real" abort, i.e. it cancels the underlying HTTP request / TCP stream? Or, the underlying request is still going on?
> @subzero911 Is it a "real" abort, i.e. it cancels the underlying HTTP request / TCP stream? Or, the underlying request is still going on?

Yes, I believe it closes the connection or destroys socket.
Looking at this from the implementation side instead of the the signature side.

This is going to be challenging to roll out. I think whatever signature we propose, we're going to end up needing to use `CancelableOperation`  in the implementation for `Client` subclasses.

I don't think we can get away from having an incremental migration. I think we need some version that we can publish which is compatible with both old and new implementation of `Client`. There are dependencies that are not likely to be comfortable taking a `git` dependency for any consequential period of time like `flutter_tools`, and enough external dependencies in general that trying to coordinate getting everything landed at once will be tough.


The API between `BaseClient` and subclasses is to override [the `send` method](https://pub.dev/documentation/http/latest/http/BaseClient/send.html). To support cancellation in different types of clients, I think we'd want to change that signature from `Future<StreamedResponse> send(BaseRequest)` to `CancelableOperation<StreamedResponse> send(BaseRequest)`. (Clients will also want to handle cancellation of a listener on the stream in most cases)

I'm not sure the best way to make this migration incremental.
I wonder if it would work out to

- Add ``CancelableOperation<StreamedResponse> sendCancelable(BaseRequest) { /* implement with `send` */ }``
- Add a body to `send` based on `sendCancelable`. Overriding either one is sufficient, but if neither is overridden it's a runtime error, instead of a static error.
- Migrate incrementally from `@override send` to `@override sendCancelable`.
- In the breaking release, remove the body from `sendCancellable` so that the override becomes statically mandatory instead of runtime mandatory.

This expands the API surface area of `BaseClient`, but I think `Client` can keep the same API so consumers are not impacted, only implementors.
@natebosch Glad you're looking to add this feature to Dart. I've been so impressed with Dart, up until now.
I just spent a bunch of time trying to add a cancel download feature to my app. Only to find Dart has no practical way of doing this. 

I don't use a listener on my stream. A listener is not compatible with IOSink (wrong data type), this is my use case:

`http.StreamedResponse response = await client.send(request);

await response.stream.map((event) => showDownloadProgress(event)).pipe(out);

IOSink out = audioFile.openWrite();`

Until there is some elegant way to cancel the response my app will not have the ability to stop a download.


> Until there is some elegant way to cancel the response my app will not have the ability to stop a download.

We may consider some API with a cancelation token or similar, which may be able to interrupt the stream with an error.

Depending on the API though, you can workaround this by interrupting the stream in between. Perhaps using something like `.takeUntil` from `package:stream_transform`

```dart
await response.stream.map(showDownloadProgress).takeUntil(cancelFuture).pipe(out);
```

https://pub.dev/documentation/stream_transform/latest/stream_transform/TakeUntil/takeUntil.html


Although that will currently end the stream and finish writing the file, not result in an error...
Edit: I think it should. https://github.com/dart-lang/stream_transform/pull/165
> Depending on the API though, you can workaround this by interrupting the stream in between. Perhaps using something like `.takeUntil` from `package:stream_transform`
> 

If the stream is interrupted does http.Client() close the connection with the server?
> If the stream is interrupted does http.Client() close the connection with the server?

Yes, it should.
I've created a fork of this package to support cancellation via a token, which includes cancelling the underlying request: https://pub.dev/packages/cancellation_token_http

I went with the token approach for convenience when chaining multiple requests, or making a request and using a cancellable isolate for parsing the result.
> ```dart
> await response.stream.map(showDownloadProgress).takeUntil(cancelFuture).pipe(out);
> ```

I don't see how onPressed inside an IconButton could trigger cancelFuture.
> I've created a fork of this package to support cancellation via a token, which includes cancelling the underlying request: https://pub.dev/packages/cancellation_token_http
> 

I tried that package a couple days ago. The problem I had was, it threw on `token.cancel();` instead of executing the code inside of the block `on http.CancelledException`.
Edit: Although, that may not matter for a StreamedResponse. I may revisit your cancellation_token_http package.
> I tried that package a couple days ago. The problem I had was, it threw on `token.cancel();` instead of executing the code inside of the block `on http.CancelledException`. Edit: Although, that may not matter for a StreamedResponse. I may revisit your cancellation_token_http package.

If you have time please [open an issue](https://github.com/JonathanPeterCole/dart-cancellation-token-http/issues/new) with a reproducible example and I'll look into this. The request future itself should be throwing a CancelledException after the token is cancelled.

I also would welcome a possibility to cancel/abort the request by user or some program logic. Now I'm looking for a solution how to cancel a request which takes a long time to process on server side as it filters a lot of data to be displayed on the map in Flutter app. Whenever the map is moved, new request is sent to get data for new boundaries. So I need to  cancel previous request as it's data will be discarded and it just stresses server.
I'll take a look on `cancelation_token_http`, maybe it will solve my problem.
Otherwise I like proposed solution with `CancelableOperation<...>`
> I'll take a look on `cancelation_token_http`, maybe it will solve my problem.

I got it working for my application. Check out the open issue I created for cancelation_token_http package. You can gleam some usage tips from it.
I'm working on an HTTP library that wraps this package and augments it with functionality from dart:io, etc., and needed to implement request cancellation and timeouts, so I also [forked this package](https://github.com/SamJakob/dart-http/tree/feat/cancellation) to implement cancellable requests.

https://github.com/SamJakob/dart-http/tree/feat/cancellation

My approach was a little different to the above:
- I wanted to avoid introducing any additional dependencies.
- I wanted an API that was a bit friendlier and/or more intuitive than the cancellation tokens (possibly subjective).
- I ideally wanted to also add a way to add timeouts for requests with the same mechanism.
- I wanted to address the broad problem which is designing an API for greater control over a request lifecycle.

I went with a `Controller` class that can be optionally passed into the `Request` class (it is entirely opt-in which makes the functionality, at least, backwards-compatible by simply not specifying a controller).

I've successfully tested at least the `dart:io` `IOClient` with an external server application that logs the lifecycle state at each point. I have also implemented the same functionality for the `dart:html` `BrowserClient` using `onReadyState` to track the lifecycle but haven't tested the browser side of things yet and haven't written formal unit tests for it.

## Examples

<details>
<summary><b>Partial Timeouts (for each step of the request-response cycle)</b></summary>

<br>

```dart
import 'package:http/http.dart';

Future<void> main() async {
  Client client = Client();

  // Create a request controller, can be passed into multiple requests to
  // cancel them all at once or to set timeouts on all of them at once.
  // Or, one can be created per request.
  final controller = RequestController(
    // Optional timeout for the overall request (entire round trip).
    timeout: Duration(seconds: 5),
    // Optional timeouts for each state of the request.
    // If a state is not specified, it will not timeout.
    partialTimeouts: PartialTimeouts(
      // Timeout for connecting to the server.
      connectTimeout: Duration(seconds: 5),
      // Timeout for the request body to be sent and a response to become
      // available from the server.
      sendTimeout: Duration(seconds: 4),
      // Timeout for processing the response body client-side.
      receiveTimeout: Duration(seconds: 6),
    ),
  );

  final response = await client.get(
    // URL that hangs for 5 seconds before responding.
    Uri.parse('http://localhost:3456/longrequest'),
    controller: controller,
  );

  // Unhandled exception:
  // TimeoutException after 0:00:04.000000: Future not completed
  print(response.body);
}
```

_In this case, the send timeout triggers a `TimeoutException` because no response is received from the server._

Server-side logs:
```
04:29:58.608 PM [Request (/longrequest)]        resume
04:29:58.610 PM [Request (/longrequest)]        readable
04:29:58.610 PM [Request (/longrequest)]        end
04:29:58.610 PM [Request (/longrequest)]        close
04:30:02.618 PM [Response (/longrequest)]       close
```

</details>

<details>
<summary><b>Partial Timeouts (another example)</b></summary>

<br>

```dart
import 'package:http/http.dart';

Future<void> main() async {
  Client client = Client();

  // Create a request controller, can be passed into multiple requests to
  // cancel them all at once or to set timeouts on all of them at once.
  // Or, one can be created per request.
  final controller = RequestController(
    // Optional timeout for the overall request (entire round trip).
    timeout: Duration(seconds: 10),
    // Optional timeouts for each state of the request.
    // If a state is not specified, it will not timeout.
    partialTimeouts: PartialTimeouts(
      // Timeout for connecting to the server.
      connectTimeout: Duration(seconds: 5),
      // Timeout for the request body to be sent and a response to become
      // available from the server.
      sendTimeout: Duration(seconds: 4),
      // Timeout for processing the response body client-side.
      receiveTimeout: Duration(seconds: 6),
    ),
  );

  final response = await client.get(
    // URL that immediately writes headers and a partial body, then hangs for
    // 5 seconds before responding with the remainder.
    Uri.parse('http://localhost:3456/longpoll'),
    controller: controller,
  );

  // Hello, world! (the payload sent immediately)
  // Hello, world! (the payload sent after 5 seconds)
  print(response.body);
}
```

_In this case, none of the timeouts trigger an exception because the response body becomes available immediately, even if it is not complete and the server hangs for only 5 seconds before completing the response, meaning the receive timeout of 6 seconds is not triggered either._

Server-side logs:
```
04:37:36.225 PM [Request (/longpoll)]   resume
04:37:36.226 PM [Request (/longpoll)]   readable
04:37:36.226 PM [Request (/longpoll)]   end
04:37:36.226 PM [Request (/longpoll)]   close
04:37:41.230 PM [Response (/longpoll)]  finish
04:37:41.230 PM [Request (/longpoll)]   resume
04:37:41.231 PM [Response (/longpoll)]  close
```

</details>

<details>
<summary><b>Cancelation Example (cancel during response)</b></summary>

<br>

```dart
import 'package:http/http.dart';

Future<void> main() async {
  Client client = Client();

  // Create a request controller, can be passed into multiple requests to
  // cancel them all at once or to set timeouts on all of them at once.
  // Or, one can be created per request.
  final controller = RequestController();

  final responseFuture = client.get(
    // URL that hangs for 5 seconds before responding.
    Uri.parse('http://localhost:3456/longrequest'),
    controller: controller,
  );

  await Future.delayed(Duration(milliseconds: 500));
  controller.cancel();

  final response = await responseFuture;
  print(response.body);

  // Unhandled exception:
  // CancelledException: Request cancelled
  print(response.body);
}
```

_Cancels after 500ms which is whilst the response is being written. The connection is destroyed so both the client and server clean up immediately after the request is cancelled._

Server-side logs:
```
04:41:11.651 PM [Request (/longrequest)]        resume
04:41:11.651 PM [Request (/longrequest)]        readable
04:41:11.652 PM [Request (/longrequest)]        end
04:41:11.652 PM [Request (/longrequest)]        close
04:41:12.137 PM [Response (/longrequest)]       close
```

</details>

<details>
<summary><b>Cancellation Example (cancelled immediately)</b></summary>

<br>

```dart
import 'package:http/http.dart';

Future<void> main() async {
  Client client = Client();

  // Create a request controller, can be passed into multiple requests to
  // cancel them all at once or to set timeouts on all of them at once.
  // Or, one can be created per request.
  final controller = RequestController();

  final responseFuture = client.get(
    // URL that hangs for 5 seconds before responding.
    Uri.parse('http://localhost:3456/longrequest'),
    controller: controller,
  );

  controller.cancel();

  final response = await responseFuture;
  print(response.body);

  // Unhandled exception:
  // CancelledException: Request cancelled
  print(response.body);
}
```

_The request is cancelled immediately and isn't even logged on the server._

Server-side logs:
```
(none)
```

</details>

---

Server implementation and Dart client example used to test:
https://github.com/SamJakob/cancellable_http_dart_testing

Bump on this. This is a basic networking feature that should be in every networking library
> Bump on this. This is a basic networking feature that should be in every networking library

Double bump.  I'm currently using a mixture of http and dio and it's messy.
> > Bump on this. This is a basic networking feature that should be in every networking library
> 
> Double bump. I'm currently using a mixture of http and dio and it's messy.

https://github.com/fluttercandies/http_client_helper provides a tricky solution. it throws an error to interrupt the request. it's not convenient to use. It needs to wrap the client.
@natebosch @brianquinlan can we get some update on this to celebrate its fifth anniversary
I'm looking to get this implemented - the capability has sat under the surface for so long, it just needs an API to get it out nicely - surely we can find something simple to agree on. More than happy to contribute - I'm relatively new here but have had a look through some of the internals.

Looking at #978. I think it was primarily rejected because:
 * It was too complicated, and tried to introduce too much functionality
 * It caused breaking changes

It's also worth noting that it uses the fetch APIs, with an `AbortController` already initialised for us at the top.

In summary of what's already been discussed:
 * Adding a method to `StreamedResponse`
   * Requires a breaking change to the return type to `CancellableOperation`...
   * ...or requires a custom `Future` implementation
   * Pushes users to the more complex `send` API

 * > One thing that's tricky here, I didn't realize the dart:io implementation causes the done future to complete as an error as opposed to never completing. I'm pretty sure the implementation in the browsers wouldn't do that.  

	Not exactly sure what's meant here. Maybe I should. But I don't see why an `onAbort` couldn't get the returned `Future` to throw.

 * > I really don't think abortOn with a Future type is the right api here... it has a lot of downsides. Specifically since you can't cancel the future at all I think it will be prone to memory leaks, hung processes on exit, and other similar issues.
	
	If it throws, then I think problem solved (from what I understand?). If we can't make that happen, then https://api.flutter.dev/flutter/dart-async/FutureExtensions/ignore.html exists now, maybe we can do something with that. Internally it sets a bitfield flag on the `Future` which has this documentation: "Flag set when an error need not be handled. Set [...] to avoid having to introduce an unnecessary listener. Only relevant until the future is completed.". Or both - complete with an error, and users can ignore nicely now: fixes one of @natebosch's issues?

Regarding https://github.com/dart-lang/http/issues/424#issuecomment-1423276601:

> - Add ``CancelableOperation<StreamedResponse> sendCancelable(BaseRequest) { /* implement with `send` */ }``
> - Add a body to `send` based on `sendCancelable`. Overriding either one is sufficient, but if neither is overridden it's a runtime error, instead of a static error.
> - Migrate incrementally from `@override send` to `@override sendCancelable`.
> - In the breaking release, remove the body from `sendCancellable` so that the override becomes statically mandatory instead of runtime mandatory.

I don't understand why we need to change it to `CancellableOperation`s? We can I guess, but I would be fine with erroring the returned `Future`.

There's always mixins. Would that help? Don't introduce anything to `Client`, but make a new mixin which implements `send` to redirect to an abstract `sendCancellable`. This is if we want to embrace `CancellableOperation`.

---

Either we follow through with what's above. I don't mind implementing that (if I can).

Or, we do something similar but slightly different:
 * No support for "controllers" - it follows similarly to the `onAbort` pattern already discussed instead, we pass a `Future` somewhere (that can be managed manually externally with a `Completer`, for example) which cancels the request on completion
 * No support for (complex) timeouts - the consensus here seems to be that timeouts is somehow more useful, but I don't see why that responsibility couldn't be given to the user (interestingly `Future.timeout` also exists now, but that doesn't do what we need)
 * Doesn't change anything with `Client`
 * Only supports `send`

We add the "abortOn" future/completer to the `BaseRequest` instead. Maybe we can avoid breaking with mixins. Maybe we assume that people only implement `BaseRequest` and not its implementations - otherwise I guess it's still breaking unless we require users to use `CancellableRequest`/`CancellableMultipartRequest`/etc. On abort, we make the `Future` throw. Client implementations can type check to see if the mixin is present, or just completely ignore it if they don't support cancellation.

---

Whatever we do, let's get something done. I understand there's objections to every method, but a method which can do *most* of what *most* people want *relatively easily* seems like a safe bet. And `Future.ignore` is an option for people now.

@natebosch @brianquinlan  I'm happy to work on this. Little bit not sure of what I'm doing, but we can get there :D
Hey @JaffaKetchup 

Thanks for volunteering to work on this!

To avoid breaking changes, we could have:

```dart
abstract mixin class Abortable on BaseRequest {
  final Future<void>? abortOn;
}

class AbortableStreamedRequest extends StreamedRequest ...
class AbortableRequest extends Request ...
```

Client implementations could then check whether the `BaseRequest` implements `AbortableStreamedRequest`. For example, in `BrowserClient`:

```dart
  Future<StreamedResponse> send(BaseRequest request) async {
    if (request is Abortable) {
      unawaited(request.abortOn.whenComplete(() => _abortController.abort());
    }
```

When `package:http` 2.0 is released, we'd merge `Abortable` into `BaseRequest`.

In think that it would be important to have tests in [`http_client_conformance_tests`](https://github.com/dart-lang/http/tree/master/pkgs/http_client_conformance_tests) to test aborting in various scenarios:
1. immediately after `send` returns
2. while streaming the request body
3. before the response future completes
4. while streaming the response
5. after the response stream is completed

Any thoughts @natebosch , @JaffaKetchup 

Hey, that's exactly what I'm thinking, and I've already started :)

Do you think `Cancellable` or `Abortable` is a better name?
I have no creativity:
- `dart:io` and the browser fetch API call it `abort`
- cronet and `NSURLSession` call it `cancel`

So you'd be in good company either way ;-) 
Great. Not familiar with pure Dart web apps, how to do launch pkgs/http/example/main.dart to use `BrowserClient` (for debugging)?


AFAIK, there is no way do that directly. You can run the tests with:
`dart test -p chrome --pause-after-load test/html`

The `--pause-after-load` will give you the chance to set breakpoints if you want.

Fix patch:
diff --git a/pkgs/cronet_http/example/pubspec.yaml b/pkgs/cronet_http/example/pubspec.yaml
index dfaa39a8e7..87c8234218 100644
--- a/pkgs/cronet_http/example/pubspec.yaml
+++ b/pkgs/cronet_http/example/pubspec.yaml
@@ -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:
+  http:
+    path: ../../http/
diff --git a/pkgs/cupertino_http/example/pubspec.yaml b/pkgs/cupertino_http/example/pubspec.yaml
index 38f75b0cf9..0d44ce4477 100644
--- a/pkgs/cupertino_http/example/pubspec.yaml
+++ b/pkgs/cupertino_http/example/pubspec.yaml
@@ -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/
diff --git a/pkgs/http/CHANGELOG.md b/pkgs/http/CHANGELOG.md
index 73e036cae4..ee8bfa73b1 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 07e4394eae..547cb64e1f 100644
--- a/pkgs/http/README.md
+++ b/pkgs/http/README.md
@@ -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.
diff --git a/pkgs/http/lib/http.dart b/pkgs/http/lib/http.dart
index 317a2c17f3..31043f0e32 100644
--- a/pkgs/http/lib/http.dart
+++ b/pkgs/http/lib/http.dart
@@ -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'
diff --git a/pkgs/http/lib/retry.dart b/pkgs/http/lib/retry.dart
index dedba9a9e7..8d8c370d71 100644
--- a/pkgs/http/lib/retry.dart
+++ b/pkgs/http/lib/retry.dart
@@ -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,
@@ -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;
       }
@@ -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));
@@ -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)
@@ -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) {}
diff --git a/pkgs/http/lib/src/abortable.dart b/pkgs/http/lib/src/abortable.dart
new file mode 100644
index 0000000000..dd26a48a00
--- /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 d718e38e8f..616eff9b60 100644
--- a/pkgs/http/lib/src/base_request.dart
+++ b/pkgs/http/lib/src/base_request.dart
@@ -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';
@@ -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.
   ///
diff --git a/pkgs/http/lib/src/browser_client.dart b/pkgs/http/lib/src/browser_client.dart
index acf233448a..0f57d5c38d 100644
--- a/pkgs/http/lib/src/browser_client.dart
+++ b/pkgs/http/lib/src/browser_client.dart
@@ -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';
@@ -49,8 +51,6 @@ 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.
   ///
@@ -58,6 +58,7 @@ class BrowserClient extends BaseClient {
   bool withCredentials = false;
 
   bool _isClosed = false;
+  final _openRequestAbortControllers = <AbortController>[];
 
   /// Sends an HTTP request and asynchronously returns the response.
   @override
@@ -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(
@@ -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;
@@ -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: ')) {
diff --git a/pkgs/http/lib/src/io_client.dart b/pkgs/http/lib/src/io_client.dart
index fe4834bb24..7d64af23b8 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 @@ class IOClient extends BaseClient {
         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 @@ 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);
+        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 @@ class IOClient extends BaseClient {
   ///
   /// 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 52f108a577..cc02ffc028 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 @@ final _pngImageData = base64Decode(
 /// 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 79525421fb..e571574d21 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 @@ class MultipartRequest extends BaseRequest {
     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 c15e55169d..b7e56ab559 100644
--- a/pkgs/http/lib/src/request.dart
+++ b/pkgs/http/lib/src/request.dart
@@ -7,6 +7,7 @@ import 'dart:typed_data';
 
 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 @@ class Request extends BaseRequest {
     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 d10386e263..8a910c54f9 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 @@ class StreamedRequest extends BaseRequest {
     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 bd915cf4f7..2c8eafc94f 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 @@ dependencies:
   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/ok_http/example/pubspec.yaml b/pkgs/ok_http/example/pubspec.yaml
index 235b649c90..873d9aaa5c 100644
--- a/pkgs/ok_http/example/pubspec.yaml
+++ b/pkgs/ok_http/example/pubspec.yaml
@@ -35,3 +35,9 @@ flutter:
   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/


Test patch:
diff --git a/pkgs/http/test/html/client_conformance_test.dart b/pkgs/http/test/html/client_conformance_test.dart
index 4400c6a8a1..51a8134945 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:http_client_conformance_tests/http_client_conformance_tests.dart
 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 da51154c4a..f29be9abec 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 @@ void main() {
     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 cc4b788ebd..149b041a4e 100644
--- a/pkgs/http/test/io/client_conformance_test.dart
+++ b/pkgs/http/test/io/client_conformance_test.dart
@@ -16,5 +16,6 @@ void main() {
     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 0686c9d09c..d7e21cea8b 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_headers_tests.dart';
 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 @@ export 'src/server_errors_test.dart' show testServerErrors;
 //
 /// 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 @@ export 'src/server_errors_test.dart' show testServerErrors;
 /// 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 @@ void testAll(
   bool canSendCookieHeaders = false,
   bool canReceiveSetCookieHeaders = false,
   bool supportsMultipartRequest = true,
+  bool supportsAbort = false,
 }) {
   testRequestBody(clientFactory());
   testRequestBodyStreamed(clientFactory(),
@@ -116,4 +122,8 @@ void testAll(
       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 0000000000..3075d779b0
--- /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 0000000000..eddcd8a671
--- /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 0000000000..3dd1104a31
--- /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 0000000000..bfc691b269
--- /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 2d8d652300..60f8a2fd7f 100644
--- a/pkgs/http_client_conformance_tests/pubspec.yaml
+++ b/pkgs/http_client_conformance_tests/pubspec.yaml
@@ -3,7 +3,6 @@ description: >-
   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 @@ environment:
 
 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