-
Notifications
You must be signed in to change notification settings - Fork 51
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
feat: timeout http calls and add async headers calls #516
feat: timeout http calls and add async headers calls #516
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm fine with the general approach... I had vague memories of having helper methods to check for cancellation like this, but I'm not sure whether we actually do.
@@ -60,9 +62,12 @@ public RestChannel(RestServiceCollection serviceCollection, string endpoint, Cha | |||
var restMethod = _serviceCollection.GetRestMethod(method); | |||
|
|||
var cancellationTokenSource = new CancellationTokenSource(); | |||
if (options.Deadline.HasValue) | |||
{ | |||
cancellationTokenSource = new CancellationTokenSource(options.Deadline.Value - DateTime.UtcNow); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please add a TODO here to inject an IClock in the future. That doesn't need to block GA though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(We should probably consider what happens if the deadline has already passed, too. If we end up with a TimeSpan with TotalMilliseconds of -1, that ends up as an infinite deadline instead of one just passed. I think we can fix that shortly after GA though.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not an infinite deadline, but yet another exception of a different type. Fixed.
var delayTask = Task.Delay(-1, cancellationToken); | ||
if (delayTask == await Task.WhenAny(addAuthHeadersTask, delayTask).ConfigureAwait(false)) | ||
{ | ||
throw new InvalidOperationException("Timeout was reached when waiting for auth headers to be added for a method {restMethod.FullName}."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Throw OperationCanceledException instead of InvalidOperationException?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I changed to the TimeoutException.
httpResponseMessage = await _httpClient.SendAsync(httpRequest, | ||
HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); | ||
} | ||
catch(TaskCanceledException ex) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can remove this catch block - just let the cancellation propagate.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The TaskCancelledException is extremely uninformative in Core 2.1 -- does not have an inner exception or any reference to a timeout. (At later versions I think they made it wrap a TimeoutException).
For the consistency I think wrapping it into a TimeoutException would be best.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tiny nit: space after catch.
But more importantly, I'm not at all sure about all the uses of TimeoutException here.
I've just tried making an RPC via gRPC, and it failed with a DeadlineExceeded RpcException. I don't know whether that came from the server or whether it was generated locally... would it be reasonable to synthesize that here? (It's easy enough to do.)
@amanda-tarafa any thoughts.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's nowhere else where we use TimeoutException that I recall, so I don't think this would be consitent with anything we already have. In Apiary, we just buble up the cancellation exception regardless of whether it's because of a timeout or something else.
I think here I'd like best to be consistent with the rest of error mapping we are doing, so +1 to throwing an RpcException with a DeadlineExceeded status.
Wether we throw inmediately after we know that the deadline is exceeded, or whether we fake a 503 response, is another thing. I' would lean towards throwing inmediately, but then I think we should modify the GetStatus method (line 145) to take into account the case in which the task is faulted with an RpcException, so we can return the Status contained by that RpcException itself.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We use TimeoutException in Polling, but only there.
I thought we had cunning code in RetryAttempt to avoid sleeping if we know that by the time we've finished sleeping the deadline will have passed, but I can't find that now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm. We do have a slight issue here - while it's tempting to throw away the TaskCanceledException, if this is actually because the caller passed in a CancellationToken and that got canceled, it's weird to change that.
What we could potentially do is use the deadline and current time to work out what to throw - that's pretty weird, but would probably give the right expected semantics.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jskeet The cunning code is here
But what I meant was just to throwing vs. faking a 503 response, not throwing inmediately vs. delay throwing, which is why I think you brought the cunning code up?
@viacheslav-rostovtsev You can include an inner exception on the RpcException via Status.DebugException, we are already doing so on GetStatus method (line 145).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is no caller
, only us. We generate a token from a source we control. The httpClient.SendAsync docs say that TaskCancelled exception happens because The request failed due to timeout.
I would throw an RpcException here, then we can ask the Grpc team to give us a constructor that takes an InnerException, and update the code to wrap a TaskCancelled later.
(actually, I would just throw a TimeoutException, because I think that throwing a GRPC-specific exception in a non-GRPC context creates more confusion than it solves, but I can see the argument for it)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd argue that this is a gRPC context, in terms of the abstraction GAX exposes - we're implementing a gRPC CallInvoker. So it makes sense to talk in gRPC concepts - just like we translate HTTP status codes into gRPC ones.
Even though we generate one CancellationToken, there's another which may be present in the CallOptions... it looks like we're not observing that at the moment, which also needs to be fixed. (The TODO was meant to cover all aspects of CallOptions; a deadline is only part of that.) But we can probably differentiate between the cancellation token we've created (just for the deadline) and one that has been provided by the caller. We can check whether our deadline-oriented cancellation token has been cancelled, and translate that into an RpcException, but leave anything else.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated to account for this, please TAL
var delayInterval = options.Deadline.Value - DateTime.UtcNow; | ||
if (delayInterval.TotalMilliseconds <= 0) | ||
{ | ||
throw new TimeoutException($"The timeout was reached when calling a method `{restMethod.FullName}`"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This to me reads more like an ArgumentException, the conditions under which this exception is thrown are not recoverable, if this exception is thrown is very likely that there's a bug in the calling code and we should signal as such.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, it shouldn't be an ArgumentException - it's just a deadline which we happen to have exceeded already. It might not have been exceeded when the method was called, just a really, really short deadline.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmmm, if we were going to be that strict, then we should continue to inspect the deadline even after the HTTP request returned succesfully, i.e. while we parse the response, etc.
I think a deadline so small that we cannot even do prep for the part of the process that takes the significant amount of time could be considered a bug, and different from a "really small deadline" that some times will not be enough to complete the time consuming part of the process. And I think we could signal that differently.
But I'm happy to disagree on this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not a matter of being strict as such - we're only throwing something because we know the deadline has already passed before we make the RPC; this is only a check to make sure we don't pass an invalid argument to Task.Delay
.
var addAuthHeadersTask = AddAuthHeadersAsync(httpRequest, restMethod); | ||
var delayTask = Task.Delay(-1, cancellationToken); | ||
if (delayTask == await Task.WhenAny(addAuthHeadersTask, delayTask).ConfigureAwait(false)) | ||
{ | ||
throw new TimeoutException($"Timeout was reached when waiting for auth headers to be added for a method `{restMethod.FullName}`."); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would add this logic on AddAuthHeadersAsync passing the cancellation token as a parameter. It's all internal anyway and then this ends up cleaner.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
httpResponseMessage = await _httpClient.SendAsync(httpRequest, | ||
HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); | ||
} | ||
catch(TaskCanceledException ex) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's nowhere else where we use TimeoutException that I recall, so I don't think this would be consitent with anything we already have. In Apiary, we just buble up the cancellation exception regardless of whether it's because of a timeout or something else.
I think here I'd like best to be consistent with the rest of error mapping we are doing, so +1 to throwing an RpcException with a DeadlineExceeded status.
Wether we throw inmediately after we know that the deadline is exceeded, or whether we fake a 503 response, is another thing. I' would lean towards throwing inmediately, but then I think we should modify the GetStatus method (line 145) to take into account the case in which the task is faulted with an RpcException, so we can return the Status contained by that RpcException itself.
9cbac8b
to
8c63f20
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I left these comments earlier but forgot to submit the review...
httpResponseMessage = await _httpClient.SendAsync(httpRequest, | ||
HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); | ||
} | ||
catch(TaskCanceledException ex) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jskeet The cunning code is here
But what I meant was just to throwing vs. faking a 503 response, not throwing inmediately vs. delay throwing, which is why I think you brought the cunning code up?
@viacheslav-rostovtsev You can include an inner exception on the RpcException via Status.DebugException, we are already doing so on GetStatus method (line 145).
var delayInterval = options.Deadline.Value - DateTime.UtcNow; | ||
if (delayInterval.TotalMilliseconds <= 0) | ||
{ | ||
throw new TimeoutException($"The timeout was reached when calling a method `{restMethod.FullName}`"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmmm, if we were going to be that strict, then we should continue to inspect the deadline even after the HTTP request returned succesfully, i.e. while we parse the response, etc.
I think a deadline so small that we cannot even do prep for the part of the process that takes the significant amount of time could be considered a bug, and different from a "really small deadline" that some times will not be enough to complete the time consuming part of the process. And I think we could signal that differently.
But I'm happy to disagree on this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay, I think I'm now happy barring one nit. Would like approval from @amanda-tarafa as well though.
{ | ||
httpResponseMessage = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseContentRead, linkedCts.Token).ConfigureAwait(false); | ||
} | ||
catch(TaskCanceledException) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: space after catch. But actually, we can simplify this anyway:
catch (TaskCanceledException) when (deadline.IsCancellationRequested)
{
throw new RpcException(new Status(StatusCode.DeadlineExceeded, $"The timeout was reached when calling a method `{restMethod.FullName}`"));
}
No need to rethrow explicitly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
throw new RpcException(new Status(StatusCode.DeadlineExceeded, $"Timeout was reached when waiting for auth headers to be added for a method `{restMethod.FullName}`.")); | ||
} | ||
|
||
if (clientCancellationTask == resultTask) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
channelTask may have failed as well, in which case we are not failing, we are just not adding any headers, I guess we should buble the channelTask failure if there was any?
I wonder, why don't you pass the linked token as a parameter here and then do something like:
if (resultTask.IsFaulted)
{
throw resultTask.Exception;
}
And then you catch that the same way you catch the one in the calling _httpClient.SendAsync(...). If you want to keep the different messages you can set a flag after you have added the headers to diferentiate which operation threw.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've implemented a version of this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is looking good to me - but Amanda has been better at spotting problems than I have on this one, so I'll defer to her. Nearly there!
{ | ||
if (channelTask.Exception is object) | ||
{ | ||
throw channelTask.Exception; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we'd want ExceptionDispatchInfo.Capture(channelTask.Exception).Throw();
here, to preserve the stack trace.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
throw channelTask.Exception; | ||
} | ||
|
||
throw new InvalidOperationException($"An unknown error has occurred when adding auth headers to a method `{restMethod.FullName}`."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this happen? Can the task be faulted and the Exception be null? I would have though not.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think so.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The docs agree. Fixed.
if (combinedCancellationTask == resultTask) | ||
{ | ||
throw new TaskCanceledException( | ||
$"The task was cancelled by the caller while waiting for the auth headers to be added for a method `{restMethod.FullName}`."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: By the caller, or because we hit the deadline, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we hit a deadline, we'll wrap this in the exception that says that the timeout was reached. If we did not hit the deadline, there is no need for this or
.
var combinedCancellationTask = Task.Delay(-1, combinedCancellationToken); | ||
var channelTask = _channelAuthInterceptor(context, metadata); | ||
var resultTask = await Task.WhenAny(channelTask, combinedCancellationTask).ConfigureAwait(false); | ||
if (combinedCancellationTask == resultTask) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that if this is the case, then resultTask
will be faulted and resultTask.Exception
will contain a TaskCanceledException
. The only possible state in which combinedCancellationTask may run to completion is faulted.
Or am I missing something?
My point is that I think lines from 135 to 149 may be substituted by:
if(resultTask.IsFaulted)
{
ExceptionDispatchInfo.Capture(resultTask.Exception).Throw();
}
Or what am I missing?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, I think you're right. (I thought before that we'd want to provide a different exception on timeout for auth headers, but in this case I think it's fine for the task cancellation to just propagate.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Task.Delay(-1, combinedCancellationToken)
does not get faulted and does not have an exception when the cancellation gets called on the token. It gets the status: Cancelled and that's it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, then you can change lines from 135 to 149 with:
await resultTask;
This will throw the TaskCancelledException if cancellation happened, or whatever exception happened if the real task failed.
If you get past this line of code, then there was no error or cancellation.
I think that will be cleaner and easier to understand than all the code we have now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, this makes sense. Updated.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM! Thanks, it looks really way simpler now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great - I'll squash and merge.
No description provided.