http icon indicating copy to clipboard operation
http copied to clipboard

Design an API to support aborting requests

Open natebosch opened this issue 4 years ago • 35 comments

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

natebosch avatar May 28 '20 19:05 natebosch

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 [email protected] 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

zichangg avatar May 29 '20 03:05 zichangg

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

natebosch avatar May 29 '20 23:05 natebosch

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

grouma avatar Jun 01 '20 17:06 grouma

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

lirantzairi avatar Aug 30 '20 14:08 lirantzairi

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

jakemac53 avatar Aug 31 '20 15:08 jakemac53

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 avatar Aug 31 '20 16:08 natebosch

  • 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 avatar Jan 06 '21 00:01 natebosch

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.

natebosch avatar Jan 06 '21 01:01 natebosch

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?

jakemac53 avatar Jan 06 '21 06:01 jakemac53

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.

aytunch avatar Jan 11 '21 16:01 aytunch

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

fzyzcjy avatar Aug 02 '21 05:08 fzyzcjy

@natebosch – what's the current story here?

kevmoo avatar Sep 13 '21 19:09 kevmoo

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

natebosch avatar Sep 13 '21 20:09 natebosch

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

lirantzairi avatar Jan 03 '22 00:01 lirantzairi

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

fzyzcjy avatar May 30 '22 02:05 fzyzcjy

Putting this on your radar, @brianquinlan

kevmoo avatar May 30 '22 20:05 kevmoo

I'll be following this thread too

subzero911 avatar Oct 09 '22 21:10 subzero911

Any updates? I am willing to PR

fzyzcjy avatar Oct 23 '22 13:10 fzyzcjy

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

subzero911 avatar Oct 24 '22 11:10 subzero911

@subzero911 Is it a "real" abort, i.e. it cancels the underlying HTTP request / TCP stream? Or, the underlying request is still going on?

fzyzcjy avatar Oct 24 '22 12:10 fzyzcjy

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

subzero911 avatar Oct 24 '22 12:10 subzero911

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 avatar Feb 08 '23 21:02 natebosch

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 avatar Feb 08 '23 21:02 natebosch

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

DavidBucienski avatar Feb 12 '23 14:02 DavidBucienski

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

natebosch avatar Feb 16 '23 02:02 natebosch

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?

DavidBucienski avatar Feb 16 '23 23:02 DavidBucienski

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

Yes, it should.

natebosch avatar Feb 17 '23 00:02 natebosch

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.

JonathanPeterCole avatar Feb 17 '23 19:02 JonathanPeterCole

await response.stream.map(showDownloadProgress).takeUntil(cancelFuture).pipe(out);

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

DavidBucienski avatar Feb 17 '23 19:02 DavidBucienski