dart-lang / http

A composable API for making HTTP requests in Dart.
https://pub.dev/packages/http
BSD 3-Clause "New" or "Revised" License
1.02k stars 351 forks source link

Design an API to support aborting requests #424

Open natebosch opened 4 years ago

natebosch commented 4 years ago

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

zichangg commented 4 years ago

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

natebosch commented 4 years ago

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.

var response = client.get(url);
// something happens
client.abort(response);

Add a named argument to all the methods:

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))

grouma commented 4 years ago

I like the latter assuming a future that completes after the request has completed doesn't cause an issue.

lirantzairi commented 4 years ago

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

jakemac53 commented 4 years ago

I think having an abort method on the response itself is really the ideal design here, for a few reasons:

This does get a bit trickier to implement - you are left with a few not so great options:

natebosch commented 4 years ago

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.

natebosch commented 3 years ago
  • 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.

natebosch commented 3 years ago

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.

jakemac53 commented 3 years ago

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?

aytunch commented 3 years ago

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.

fzyzcjy commented 3 years ago

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

mleonhard commented 3 years ago

Related proposal: HttpClient deadline and retry (dart-lang/sdk#46557)

kevmoo commented 3 years ago

@natebosch – what's the current story here?

natebosch commented 3 years ago

This is still something we plan on looking at but we don't have a concrete plan.

lirantzairi commented 2 years ago

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 🙏🏻

fzyzcjy commented 2 years ago

After two years (and half a year after last comments), is there any updates...? This is very frequently used and wanted feature.

kevmoo commented 2 years ago

Putting this on your radar, @brianquinlan

subzero911 commented 1 year ago

I'll be following this thread too

fzyzcjy commented 1 year ago

Any updates? I am willing to PR

subzero911 commented 1 year ago

Meanwhile, Dio supports abort() since March Proof: https://github.com/flutterchina/dio/pull/1440

fzyzcjy commented 1 year ago

@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 commented 1 year ago

@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.

natebosch commented 1 year ago

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

natebosch commented 1 year ago

I wonder if it would work out to

This expands the API surface area of BaseClient, but I think Client can keep the same API so consumers are not impacted, only implementors.

DavidBucienski commented 1 year ago

@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.

natebosch commented 1 year ago

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

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

DavidBucienski commented 1 year ago

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?

natebosch commented 1 year ago

If the stream is interrupted does http.Client() close the connection with the server?

Yes, it should.

JonathanPeterCole commented 1 year ago

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.

DavidBucienski commented 1 year ago
await response.stream.map(showDownloadProgress).takeUntil(cancelFuture).pipe(out);

I don't see how onPressed inside an IconButton could trigger cancelFuture.

DavidBucienski commented 1 year ago

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.

JonathanPeterCole commented 1 year ago

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 with a reproducible example and I'll look into this. The request future itself should be throwing a CancelledException after the token is cancelled.

edlman commented 1 year ago

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

DavidBucienski commented 1 year ago

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.

SamJakob commented 1 year ago

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 to implement cancellable requests.

https://github.com/SamJakob/dart-http/tree/feat/cancellation

My approach was a little different to the above:

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

Partial Timeouts (for each step of the request-response cycle)
```dart import 'package:http/http.dart'; Future 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 ```
Partial Timeouts (another example)
```dart import 'package:http/http.dart'; Future 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 ```
Cancelation Example (cancel during response)
```dart import 'package:http/http.dart'; Future 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 ```
Cancellation Example (cancelled immediately)
```dart import 'package:http/http.dart'; Future 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) ```

Server implementation and Dart client example used to test: https://github.com/SamJakob/cancellable_http_dart_testing

exaby73 commented 1 month ago

Bump on this. This is a basic networking feature that should be in every networking library