Skip to content

Commit c294527

Browse files
committed
Enforce Java 11 response body timeouts
1 parent d7ea97b commit c294527

3 files changed

Lines changed: 164 additions & 3 deletions

File tree

java11/src/main/java/feign/http2client/Http2Client.java

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import java.net.http.HttpRequest.Builder;
3939
import java.net.http.HttpResponse;
4040
import java.net.http.HttpResponse.BodyHandlers;
41+
import java.net.http.HttpTimeoutException;
4142
import java.time.Duration;
4243
import java.util.Arrays;
4344
import java.util.Collection;
@@ -52,13 +53,26 @@
5253
import java.util.TreeSet;
5354
import java.util.concurrent.CompletableFuture;
5455
import java.util.concurrent.ConcurrentHashMap;
56+
import java.util.concurrent.Executors;
57+
import java.util.concurrent.ScheduledExecutorService;
58+
import java.util.concurrent.ScheduledFuture;
59+
import java.util.concurrent.TimeUnit;
60+
import java.util.concurrent.atomic.AtomicBoolean;
5561
import java.util.function.Function;
5662
import java.util.stream.Collectors;
5763
import java.util.zip.GZIPInputStream;
5864
import java.util.zip.InflaterInputStream;
5965

6066
public class Http2Client implements Client, AsyncClient<Object> {
6167

68+
private static final ScheduledExecutorService BODY_READ_TIMEOUT_EXECUTOR =
69+
Executors.newSingleThreadScheduledExecutor(
70+
runnable -> {
71+
Thread thread = new Thread(runnable, "feign-http2client-body-timeout");
72+
thread.setDaemon(true);
73+
return thread;
74+
});
75+
6276
private final HttpClient client;
6377

6478
private final Map<Integer, SoftReference<HttpClient>> clients = new ConcurrentHashMap<>();
@@ -109,7 +123,7 @@ public Response execute(Request request, Options options) throws IOException {
109123
throw new IOException(e);
110124
}
111125

112-
return toFeignResponse(request, httpResponse);
126+
return toFeignResponse(request, httpResponse, options);
113127
}
114128

115129
@Override
@@ -125,17 +139,22 @@ public CompletableFuture<Response> execute(
125139
HttpClient clientForRequest = getOrCreateClient(options);
126140
CompletableFuture<HttpResponse<InputStream>> future =
127141
clientForRequest.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofInputStream());
128-
return future.thenApply(httpResponse -> toFeignResponse(request, httpResponse));
142+
return future.thenApply(httpResponse -> toFeignResponse(request, httpResponse, options));
129143
}
130144

131145
protected Response toFeignResponse(Request request, HttpResponse<InputStream> httpResponse) {
146+
return toFeignResponse(request, httpResponse, null);
147+
}
148+
149+
private Response toFeignResponse(
150+
Request request, HttpResponse<InputStream> httpResponse, Options options) {
132151
final OptionalLong length = httpResponse.headers().firstValueAsLong("Content-Length");
133152
final Integer contentLength =
134153
length.isPresent() && length.getAsLong() >= 0 && length.getAsLong() <= Integer.MAX_VALUE
135154
? (int) length.getAsLong()
136155
: null;
137156

138-
InputStream body = httpResponse.body();
157+
InputStream body = withReadTimeout(httpResponse.body(), options);
139158

140159
if (httpResponse.headers().allValues(CONTENT_ENCODING).contains(ENCODING_GZIP)) {
141160
try {
@@ -156,6 +175,110 @@ protected Response toFeignResponse(Request request, HttpResponse<InputStream> ht
156175
.build();
157176
}
158177

178+
private static InputStream withReadTimeout(InputStream body, Options options) {
179+
if (body == null || options == null || options.readTimeout() <= 0) {
180+
return body;
181+
}
182+
return new TimeoutInputStream(body, options.readTimeout(), options.readTimeoutUnit());
183+
}
184+
185+
private static final class TimeoutInputStream extends InputStream {
186+
187+
private final InputStream delegate;
188+
private final long timeout;
189+
private final TimeUnit timeoutUnit;
190+
191+
private TimeoutInputStream(InputStream delegate, long timeout, TimeUnit timeoutUnit) {
192+
this.delegate = delegate;
193+
this.timeout = timeout;
194+
this.timeoutUnit = timeoutUnit;
195+
}
196+
197+
@Override
198+
public int read() throws IOException {
199+
return readWithTimeout(delegate::read);
200+
}
201+
202+
@Override
203+
public int read(byte[] b, int off, int len) throws IOException {
204+
return readWithTimeout(() -> delegate.read(b, off, len));
205+
}
206+
207+
@Override
208+
public int available() throws IOException {
209+
return delegate.available();
210+
}
211+
212+
@Override
213+
public void close() throws IOException {
214+
delegate.close();
215+
}
216+
217+
private int readWithTimeout(BodyRead read) throws IOException {
218+
final AtomicBoolean completed = new AtomicBoolean(false);
219+
final AtomicBoolean timedOut = new AtomicBoolean(false);
220+
final ScheduledFuture<?> timeoutFuture =
221+
BODY_READ_TIMEOUT_EXECUTOR.schedule(
222+
() -> {
223+
if (completed.compareAndSet(false, true)) {
224+
timedOut.set(true);
225+
try {
226+
delegate.close();
227+
} catch (IOException ignored) {
228+
}
229+
}
230+
},
231+
timeout,
232+
timeoutUnit);
233+
234+
try {
235+
final int result = read.read();
236+
if (completed.compareAndSet(false, true)) {
237+
timeoutFuture.cancel(false);
238+
return result;
239+
}
240+
throw timeoutException(null);
241+
} catch (IOException e) {
242+
if (completed.compareAndSet(false, true)) {
243+
timeoutFuture.cancel(false);
244+
}
245+
final HttpTimeoutException timeoutException = findTimeoutException(e);
246+
if (timedOut.get() || timeoutException != null) {
247+
throw timeoutException == null ? timeoutException(e) : timeoutException;
248+
}
249+
throw e;
250+
} catch (RuntimeException e) {
251+
if (completed.compareAndSet(false, true)) {
252+
timeoutFuture.cancel(false);
253+
}
254+
throw e;
255+
}
256+
}
257+
258+
private static HttpTimeoutException timeoutException(IOException cause) {
259+
final HttpTimeoutException exception = new HttpTimeoutException("response timed out");
260+
if (cause != null) {
261+
exception.initCause(cause);
262+
}
263+
return exception;
264+
}
265+
266+
private static HttpTimeoutException findTimeoutException(Throwable throwable) {
267+
Throwable current = throwable;
268+
while (current != null) {
269+
if (current instanceof HttpTimeoutException) {
270+
return (HttpTimeoutException) current;
271+
}
272+
current = current.getCause();
273+
}
274+
return null;
275+
}
276+
}
277+
278+
private interface BodyRead {
279+
int read() throws IOException;
280+
}
281+
159282
private HttpClient getOrCreateClient(Options options) {
160283
if (doesClientConfigurationDiffer(options)) {
161284
// create a new client from the existing one - but with connectTimeout and followRedirect

java11/src/test/java/feign/http2client/test/Http2ClientAsyncTest.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
import java.io.IOException;
6464
import java.lang.reflect.Type;
6565
import java.net.URI;
66+
import java.net.http.HttpTimeoutException;
6667
import java.nio.charset.StandardCharsets;
6768
import java.time.Clock;
6869
import java.time.Instant;
@@ -510,6 +511,23 @@ void doesntRetryAfterResponseIsSent() throws Throwable {
510511
assertThat(exception.getMessage()).contains("timeout reading POST http://");
511512
}
512513

514+
@Test
515+
void timeoutReadingResponseBody() throws Throwable {
516+
server.enqueue(new MockResponse().setBody("foo").setBodyDelay(1, TimeUnit.SECONDS));
517+
518+
final TestInterfaceAsync api =
519+
newAsyncBuilder()
520+
.options(
521+
new Request.Options(500, TimeUnit.MILLISECONDS, 500, TimeUnit.MILLISECONDS, true))
522+
.target("http://localhost:" + server.getPort());
523+
524+
final CompletableFuture<?> cf = api.post();
525+
server.takeRequest();
526+
527+
Throwable exception = assertThrows(FeignException.class, () -> unwrap(cf));
528+
assertThat(exception).hasCauseInstanceOf(HttpTimeoutException.class);
529+
}
530+
513531
@Test
514532
void throwsFeignExceptionIncludingBody() throws Throwable {
515533
server.enqueue(new MockResponse().setBody("success!"));
@@ -1060,6 +1078,11 @@ TestInterfaceAsyncBuilder dismiss404() {
10601078
return this;
10611079
}
10621080

1081+
TestInterfaceAsyncBuilder options(Request.Options options) {
1082+
delegate.options(options);
1083+
return this;
1084+
}
1085+
10631086
TestInterfaceAsyncBuilder queryMapEndcoder(QueryMapEncoder queryMapEncoder) {
10641087
delegate.queryMapEncoder(queryMapEncoder);
10651088
return this;

java11/src/test/java/feign/http2client/test/Http2ClientTest.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,21 @@ void timeoutTest() {
175175
assertThat(exception).hasCauseInstanceOf(HttpTimeoutException.class);
176176
}
177177

178+
@Test
179+
void timeoutReadingResponseBody() {
180+
server.enqueue(new MockResponse().setBody("foo").setBodyDelay(1, TimeUnit.SECONDS));
181+
182+
final TestInterface api =
183+
newBuilder()
184+
.retryer(Retryer.NEVER_RETRY)
185+
.options(
186+
new Request.Options(500, TimeUnit.MILLISECONDS, 500, TimeUnit.MILLISECONDS, true))
187+
.target(TestInterface.class, server.url("/").toString());
188+
189+
FeignException exception = assertThrows(FeignException.class, () -> api.timeout());
190+
assertThat(exception).hasCauseInstanceOf(HttpTimeoutException.class);
191+
}
192+
178193
@Test
179194
void getWithRequestBody() throws Exception {
180195
// MockWebServer rejects GET requests carrying a body ("Request must not have a body"),

0 commit comments

Comments
 (0)