Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for request timeout #521

Draft
wants to merge 11 commits into
base: master
Choose a base branch
from
Draft

Add support for request timeout #521

wants to merge 11 commits into from

Conversation

natebosch
Copy link
Member

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.

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.
@google-cla google-cla bot added the cla: yes label Jan 21, 2021
@natebosch
Copy link
Member Author

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.

Copy link
Contributor

@jakemac53 jakemac53 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The api lgtm

lib/src/browser_client.dart Outdated Show resolved Hide resolved
lib/http.dart Show resolved Hide resolved
lib/src/base_client.dart Outdated Show resolved Hide resolved
lib/src/browser_client.dart Outdated Show resolved Hide resolved
lib/src/browser_client.dart Outdated Show resolved Hide resolved
lib/src/io_client.dart Outdated Show resolved Hide resolved
var stream = request.finalize();

Timer? timer;
late void Function() onTimeout;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can make this nullable instead of late, and do onTimeout?.call(); or onTimeout!() in the timer callback.
That could potentially be cheaper than a late field. (Unless the compiler is being competent about late non-nullable fields and using null as placeholder anyway)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I have a slight preference for late as it matches the intended semantics more closely IMO. Outside of performance concerns do you have a preference one way or the other?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I consider late an unsafe and dynamic feature at the same level of as, well, dynamic. I try to avoid it as much as possible, just as I do dynamic. Late variables have their uses, where it makes things easier, but it never makes them safer.

Using late (and dynamic) allows statically undetectable run-time errors with visible marker at the use position. I prefer to use something nullable and ! when possible because it is more verbose and therefore highlights the assumption at the read (by adding !).

This is a fairly fundamental library, and I think we should always choose safety (and performance) over brevity.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using late here I believe is more about choosing something that matches the underlying semantics/implementation than brevity. I don't feel super strongly either way, but this is always assigned, and I don't agree it is any safer to use ! everywhere - we would not be adding any null checks either way.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm actually on re-reading it I think this is only assigned if timeout is non-null, in which case making it nullable does match the semantics better, so I retract my previous comment and agree it should be nullable.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't taking enough advantage of flow analysis. Turns out we don't need late or ?. If I switch a few lines around then definite assignment analysis takes over.

lib/src/browser_client.dart Outdated Show resolved Hide resolved
@natebosch
Copy link
Member Author

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.

@lrhn
Copy link
Member

lrhn commented Jan 28, 2021

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.

@natebosch
Copy link
Member Author

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) or the headers.

@natebosch
Copy link
Member Author

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.

@natebosch
Copy link
Member Author

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.

@natebosch
Copy link
Member Author

natebosch commented Jan 29, 2021

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.

Copy link
Member

@lrhn lrhn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API LGTM.

/// If [contentTimeout] is not null the request will be aborted if it takes
/// longer than the given duration to receive the entire response. If the
/// timeout occurs before any reply is received from the server the returned
/// future will as an error with a [TimeoutException]. If the timout occurs
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing a "will complete as" here?

I'd usually write "the returned future will complete with a [TimeoutException] error."

/// longer than the given duration to receive the entire response. If the
/// timeout occurs before any reply is received from the server the returned
/// future will as an error with a [TimeoutException]. If the timout occurs
/// after the reply has been started but before the entire body has been read
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comma before "but"? (And then probably also after "read").

Maybe "has been started" -> "has started".

Copy link

@DeD1rk DeD1rk Jul 13, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comma before "but"? (And then probably also after "read").

Don't think so, the '... but ...' is a single description of a condition, whereas a comma would suggest the two parts being separate sentences.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I read "but before the entire body has been read" as parenthetical.
If it's not a parenthetical clause, it deserves to be equally prominent to the main clause, and I'd change but to and.
I'd still have a comma after read in either case.

@@ -117,11 +118,18 @@ abstract class BaseRequest {
/// the request is complete. If you're planning on making multiple requests to
/// the same server, you should use a single [Client] for all of those
/// requests.
Future<StreamedResponse> send() async {
///
/// If [contentTimeout] is not null the request will be aborted if it takes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd usually say "If [contentTimeout] is provided, the ...."
I know you can technically provide a null argument, but users will understand it anyway. After all, you talk about the duration afterwards, and null is not a duration.

///
/// If [contentTimeout] is not null the request will be aborted if it takes
/// longer than the given duration to receive the entire response. If the
/// timeout occurs before any reply is received from the server the returned
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comma after "server"?

@lukacat10
Copy link

lukacat10 commented May 27, 2023

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants