3838import java .net .http .HttpRequest .Builder ;
3939import java .net .http .HttpResponse ;
4040import java .net .http .HttpResponse .BodyHandlers ;
41+ import java .net .http .HttpTimeoutException ;
4142import java .time .Duration ;
4243import java .util .Arrays ;
4344import java .util .Collection ;
5253import java .util .TreeSet ;
5354import java .util .concurrent .CompletableFuture ;
5455import 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 ;
5561import java .util .function .Function ;
5662import java .util .stream .Collectors ;
5763import java .util .zip .GZIPInputStream ;
5864import java .util .zip .InflaterInputStream ;
5965
6066public 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
0 commit comments