Skip to content

Commit 7a55ce4

Browse files
giampa91rstoyanchev
authored andcommitted
Handle CancellationException in JdkClientHttpRequest
Handle CancellationException in order to throw an HttpTimeoutException when the timeout handler caused the cancellation. See gh-34721 Signed-off-by: giampaolo <[email protected]> fix: use timeoutHandler with a flag isTimeout Closes gh-33973 Signed-off-by: giampaolo <[email protected]>
1 parent 5df9fd4 commit 7a55ce4

File tree

2 files changed

+136
-2
lines changed

2 files changed

+136
-2
lines changed

spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import java.util.concurrent.Executor;
3838
import java.util.concurrent.Flow;
3939
import java.util.concurrent.TimeUnit;
40+
import java.util.concurrent.atomic.AtomicBoolean;
4041

4142
import org.springframework.http.HttpHeaders;
4243
import org.springframework.http.HttpMethod;
@@ -97,12 +98,13 @@ public URI getURI() {
9798
@SuppressWarnings("NullAway")
9899
protected ClientHttpResponse executeInternal(HttpHeaders headers, @Nullable Body body) throws IOException {
99100
CompletableFuture<HttpResponse<InputStream>> responseFuture = null;
101+
TimeoutHandler timeoutHandler = null;
100102
try {
101103
HttpRequest request = buildRequest(headers, body);
102104
responseFuture = this.httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream());
103105

104106
if (this.timeout != null) {
105-
TimeoutHandler timeoutHandler = new TimeoutHandler(responseFuture, this.timeout);
107+
timeoutHandler = new TimeoutHandler(responseFuture, this.timeout);
106108
HttpResponse<InputStream> response = responseFuture.get();
107109
InputStream inputStream = timeoutHandler.wrapInputStream(response);
108110
return new JdkClientHttpResponse(response, inputStream);
@@ -121,7 +123,10 @@ protected ClientHttpResponse executeInternal(HttpHeaders headers, @Nullable Body
121123
Throwable cause = ex.getCause();
122124

123125
if (cause instanceof CancellationException) {
124-
throw new HttpTimeoutException("Request timed out");
126+
if (timeoutHandler != null && timeoutHandler.isTimeout()) {
127+
throw new HttpTimeoutException("Request timed out");
128+
}
129+
throw new IOException("Request was cancelled");
125130
}
126131
if (cause instanceof UncheckedIOException uioEx) {
127132
throw uioEx.getCause();
@@ -136,6 +141,12 @@ else if (cause instanceof IOException ioEx) {
136141
throw new IOException(cause.getMessage(), cause);
137142
}
138143
}
144+
catch (CancellationException ex) {
145+
if (timeoutHandler != null && timeoutHandler.isTimeout()) {
146+
throw new HttpTimeoutException("Request timed out");
147+
}
148+
throw new IOException("Request was cancelled");
149+
}
139150
}
140151

141152
private HttpRequest buildRequest(HttpHeaders headers, @Nullable Body body) {
@@ -233,6 +244,7 @@ public ByteBuffer map(byte[] b, int off, int len) {
233244
private static final class TimeoutHandler {
234245

235246
private final CompletableFuture<Void> timeoutFuture;
247+
private final AtomicBoolean isTimeout = new AtomicBoolean(false);
236248

237249
private TimeoutHandler(CompletableFuture<HttpResponse<InputStream>> future, Duration timeout) {
238250

@@ -241,6 +253,7 @@ private TimeoutHandler(CompletableFuture<HttpResponse<InputStream>> future, Dura
241253

242254
this.timeoutFuture.thenRun(() -> {
243255
if (future.cancel(true) || future.isCompletedExceptionally() || !future.isDone()) {
256+
isTimeout.set(true);
244257
return;
245258
}
246259
try {
@@ -268,6 +281,10 @@ public void close() throws IOException {
268281
}
269282
};
270283
}
284+
285+
public boolean isTimeout() {
286+
return isTimeout.get();
287+
}
271288
}
272289

273290
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package org.springframework.http.client;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.junit.jupiter.api.Assertions.*;
5+
import static org.mockito.Mockito.*;
6+
7+
import java.io.IOException;
8+
import java.io.InputStream;
9+
import java.net.URI;
10+
import java.net.http.HttpClient;
11+
import java.net.http.HttpRequest;
12+
import java.net.http.HttpResponse;
13+
import java.net.http.HttpTimeoutException;
14+
import java.time.Duration;
15+
import java.util.concurrent.*;
16+
17+
import org.junit.jupiter.api.AfterEach;
18+
import org.junit.jupiter.api.BeforeEach;
19+
import org.junit.jupiter.api.Test;
20+
import org.springframework.http.HttpHeaders;
21+
import org.springframework.http.HttpMethod;
22+
23+
class JdkClientHttpRequestTest {
24+
25+
private HttpClient mockHttpClient;
26+
private URI uri = URI.create("http://example.com");
27+
private HttpMethod method = HttpMethod.GET;
28+
29+
private ExecutorService executor;
30+
31+
@BeforeEach
32+
void setup() {
33+
mockHttpClient = mock(HttpClient.class);
34+
executor = Executors.newSingleThreadExecutor();
35+
}
36+
37+
@AfterEach
38+
void tearDown() {
39+
executor.shutdownNow();
40+
}
41+
42+
@Test
43+
void executeInternal_withTimeout_shouldThrowHttpTimeoutException() throws Exception {
44+
Duration timeout = Duration.ofMillis(10);
45+
46+
JdkClientHttpRequest request = new JdkClientHttpRequest(mockHttpClient, uri, method, executor, timeout);
47+
48+
CompletableFuture<HttpResponse<InputStream>> future = new CompletableFuture<>();
49+
50+
when(mockHttpClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
51+
.thenReturn(future);
52+
53+
HttpHeaders headers = new HttpHeaders();
54+
55+
CountDownLatch startLatch = new CountDownLatch(1);
56+
57+
// Cancellation thread waits for startLatch, then cancels the future after a delay
58+
Thread canceller = new Thread(() -> {
59+
try {
60+
startLatch.await();
61+
Thread.sleep(500);
62+
future.cancel(true);
63+
} catch (InterruptedException ignored) {
64+
}
65+
});
66+
canceller.start();
67+
68+
IOException ex = assertThrows(IOException.class, () -> {
69+
startLatch.countDown();
70+
request.executeInternal(headers, null);
71+
});
72+
73+
assertThat(ex)
74+
.isInstanceOf(HttpTimeoutException.class)
75+
.hasMessage("Request timed out");
76+
77+
canceller.join();
78+
}
79+
80+
@Test
81+
void executeInternal_withTimeout_shouldThrowIOException() throws Exception {
82+
Duration timeout = Duration.ofMillis(500);
83+
84+
JdkClientHttpRequest request = new JdkClientHttpRequest(mockHttpClient, uri, method, executor, timeout);
85+
86+
CompletableFuture<HttpResponse<InputStream>> future = new CompletableFuture<>();
87+
88+
when(mockHttpClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
89+
.thenReturn(future);
90+
91+
HttpHeaders headers = new HttpHeaders();
92+
93+
CountDownLatch startLatch = new CountDownLatch(1);
94+
95+
Thread canceller = new Thread(() -> {
96+
try {
97+
startLatch.await();
98+
Thread.sleep(10);
99+
future.cancel(true);
100+
} catch (InterruptedException ignored) {
101+
}
102+
});
103+
canceller.start();
104+
105+
IOException ex = assertThrows(IOException.class, () -> {
106+
startLatch.countDown();
107+
request.executeInternal(headers, null);
108+
});
109+
110+
assertThat(ex)
111+
.isInstanceOf(IOException.class)
112+
.hasMessage("Request was cancelled");
113+
114+
canceller.join();
115+
}
116+
117+
}

0 commit comments

Comments
 (0)