dart-lang/http

Design an API to support aborting requests

natebosch opened this issue ยท 36 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.

Future<Socket> detachSocket() async => _inner.detachSocket();
(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

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

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.

@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: cfug/dio#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. 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

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. dart-lang/stream_transform#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.

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

Partial Timeouts (for each step of the request-response cycle)
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
Partial Timeouts (another example)
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
Cancelation Example (cancel during response)
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
Cancellation Example (cancelled immediately)
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)

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