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

Use only one request timeout mechanism in JdkClientHttpRequest #33090

Closed
wants to merge 3 commits into from

Conversation

cfredri4
Copy link
Contributor

Previously, a timeout was set both on HttpRequest, and used on httpClient.sendAsync().get(). This leads to inconsistent behaviour depending on which timeout gets triggered first. This change changes so that timeout is only set on the HttpRequest.

Previously, a timeout was set both on HttpRequest, and used on httpClient.sendAsync().get(). This leads to inconsistent behaviour depending on which timeout gets triggered first.
This change changes so that timeout is only set on the HttpRequest.
@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Jun 24, 2024
@poutsma poutsma self-assigned this Jun 24, 2024
@poutsma poutsma removed their assignment Jul 3, 2024
@snicoll
Copy link
Member

snicoll commented Jul 8, 2024

This was added in 6.1.3, see #31911

@snicoll snicoll added the in: web Issues in web modules (web, webmvc, webflux, websocket) label Jul 8, 2024
@cfredri4
Copy link
Contributor Author

cfredri4 commented Jul 8, 2024

@snicoll thanks I was not aware this issue existed with HttpClient. But according to JDK-8258397 then the current implementation with additional timeout on CompletableFuture will not help because it fetches an InputStream?

A possibility for the caller is to make use of the CompletableFuture API (get/join will accept a timeout, or CF::orTimeout can be called).
IIRC - in that case, it will still be the responsibility of the caller to cancel the request. We might want to reexamine and possibility change that.
The disadvantage here is that some of our BodyHandlers (ofPublisher, ofInputStream) will return immediately - so the CF API won't help in this case.

It feels like there are two options; introducing a separate timeout where the input stream is consumed (this would impact other client types too, maybe in a good way of they have similar behavior?), or accepting the behavior that the existing timeout does not account for the body being received.

@rstoyanchev
Copy link
Contributor

rstoyanchev commented Jul 8, 2024

Indeed, the comment in JDK-8258397 refers to the BodyHandlers#ofInputStream which we use. Looking at ResponseSubscribers.HttpResponseInputStream, the getBody method there returns CompletableFuture.completedStage(this), i.e. an already completed future that can't be cancelled. We could presumably wrap HttpResponse.BodySubscribers.ofInputStream(), intercept CompletionStage<InputStream> getBody() to get access to the InputStream which would allow us to actually cancel by closing the InputStream after a timeout exception. That would be a potential workaround while the JDK issue is not resolved.

That said I'm not sure I follow the original comment about the inconsistency. @cfredri4 can you elaborate?

@cfredri4
Copy link
Contributor Author

cfredri4 commented Jul 8, 2024

My comment about inconsistency was a hypothetical one. I just happened across the code and reacted to that the same timeout was set twice in two different ways.
Now it's clear that my worry was a non-issue but instead an entirely different problem has emerged. 😅

@cfredri4
Copy link
Contributor Author

cfredri4 commented Jul 9, 2024

A suggestion; use the non-async send without timeout, capture the input stream before returning it, and schedule a separate timer to interrupt the sending thread+cancel input stream on timeout?
If reasonable I can prepare a PR.

@rstoyanchev
Copy link
Contributor

I suppose we don't gain anything from the Future, and only using it as a timer. What scheduling did you have in mind to replace that with?

@cfredri4
Copy link
Contributor Author

cfredri4 commented Jul 9, 2024

I haven't looked into it yet but initial thought is to use the common scheduler used for CompletableFuture timeouts/delayedExecutor. A guess is that it's the same or similar to what's used by HttpClient internally so it should be no overall change in behavior.

@rstoyanchev
Copy link
Contributor

Sure, give that a try.

@cfredri4
Copy link
Contributor Author

After looking some more into this.
The non-async send() just calls sendAsync() which in term returns a custom CompletableFuture which can be cancelled to cancel the request.
HttpClient keeps track of its own timeouts which are then ran by the selector thread.

We don't want to schedule multiple timeouts so that means no timeout on HttpRequest, or on the request future get(), and instead our custom timeout needs to cancel the request future if not already finished.
Other than that we then just need to cancel the timeout when the body is read completely.
Having a custom scheduler feels like overkill and the common scheduler should be ok enough since our timeout task will just trigger cancellation/close a stream (i.e. basically no work).
The CompletableFuture delayedExecutor does not cancel nicely (it doesn't remove the scheduled future from the delayed queue) but it can be done with completeOnTimeout instead.

I've pushed an initial draft proposal to my branch.
Can you review and provide feedback?

@rstoyanchev rstoyanchev self-assigned this Jul 11, 2024
Copy link
Contributor

@rstoyanchev rstoyanchev left a comment

Choose a reason for hiding this comment

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

Thanks for the changes so far.

I think this is a direction we could work towards. I would like to suggest a little refactoring. Create an inner class, e.g. TimeoutHandler that manages the timeout future internally, and exposes a method to wrap the response InputStream. That would keep the executeInternal method simpler, and separate out the timeout concern, so that eventually when the JDK provides a fix, it's easier to see what code we no longer need.

It would be good to have some tests as well possibly in RestClientIntegrationTests.

A few more specific comments below.

CompletableFuture<Void> timeoutFuture = new CompletableFuture<Void>()
.completeOnTimeout(null, this.timeout.toMillis(), TimeUnit.MILLISECONDS);
timeoutFuture.thenRun(() -> {
if (!responsefuture.cancel(true) && !responsefuture.isCompletedExceptionally()) {
Copy link
Contributor

@rstoyanchev rstoyanchev Jul 11, 2024

Choose a reason for hiding this comment

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

Shouldn't this be checking if the task was cancelled successfully, i.e. that cancel() returns true?

timeoutFuture.thenRun(() -> {
if (!responsefuture.cancel(true) && !responsefuture.isCompletedExceptionally()) {
try {
responsefuture.resultNow().body().close();
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we really be sure there is a body, I imagine the timeout could happen before the server responds with status and headers.

public JdkClientHttpResponse(int statusCode, java.net.http.HttpHeaders headers, InputStream body) {
this.statusCode = statusCode;
this.headers = adaptHeaders(headers);
this.body = body != null ? body : InputStream.nullInputStream();
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we could have constructors like these:

public JdkClientHttpResponse(HttpResponse<InputStream> response) {
    this(response, response.getInputStream());
}

public JdkClientHttpResponse(HttpResponse<InputStream> response, InputStream body) {

}

That would keep the logic mostly as it was before and avoid the full qualified java.net.http.HttpHeaders type.

@cfredri4
Copy link
Contributor Author

Cool, I'll sort it out after summer vacation.

@rstoyanchev rstoyanchev added type: enhancement A general enhancement and removed status: waiting-for-triage An issue we've not yet triaged or decided on labels Jul 12, 2024
@rstoyanchev rstoyanchev added this to the 6.2.x milestone Jul 12, 2024
@rstoyanchev rstoyanchev modified the milestones: 6.2.x, 6.2.0-RC2 Sep 19, 2024
rstoyanchev pushed a commit that referenced this pull request Sep 25, 2024
Previously, a timeout was set both on HttpRequest, and used on
httpClient.sendAsync().get(). This leads to inconsistent behaviour
depending on which timeout gets triggered first.

See gh-33090
@rstoyanchev
Copy link
Contributor

I've gone ahead and completed this, but if you have a chance, please take a look.

@rstoyanchev rstoyanchev changed the title Only set timeout one way in JdkClientHttpRequest Use only one request timeout mechanism in JdkClientHttpRequest Sep 25, 2024
@cfredri4 cfredri4 deleted the patch-1 branch October 27, 2024 07:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: web Issues in web modules (web, webmvc, webflux, websocket) type: enhancement A general enhancement
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants