One way to solve this is to set a timeout on the time taken to receive the whole body. That's what M A's solution does. As you've noticed, you should close the stream if timeout evaluates, so the connection is released properly instead of hanging in background. A more general approach is to implement a BodySubscriber
that completes itself exceptionally when it is not completed by upstream within the timeout. This affords not having to spawn a thread just for timed waiting or close the stream. Here's an appropriate implementation.
class TimeoutBodySubscriber<T> implements BodySubscriber<T> {
private final BodySubscriber<T> downstream;
private final Duration timeout;
private Subscription subscription;
/** Make sure downstream isn't called after we receive an onComplete or onError. */
private boolean done;
TimeoutBodySubscriber(BodySubscriber<T> downstream, Duration timeout) {
this.downstream = downstream;
this.timeout = timeout;
}
@Override
public CompletionStage<T> getBody() {
return downstream.getBody();
}
@Override
public synchronized void onSubscribe(Subscription subscription) {
this.subscription = requireNonNull(subscription);
downstream.onSubscribe(subscription);
// Schedule an error completion to be fired when timeout evaluates
CompletableFuture.delayedExecutor(timeout.toMillis(), TimeUnit.MILLISECONDS)
.execute(this::onTimeout);
}
private synchronized void onTimeout() {
if (!done) {
done = true;
downstream.onError(new HttpTimeoutException("body completion timed out"));
// Cancel subscription to release the connection, so it doesn't keep hanging in background
subscription.cancel();
}
}
@Override
public synchronized void onNext(List<ByteBuffer> item) {
if (!done) {
downstream.onNext(item);
}
}
@Override
public synchronized void onError(Throwable throwable) {
if (!done) {
done = true;
downstream.onError(throwable);
}
}
@Override
public synchronized void onComplete() {
if (!done) {
done = true;
downstream.onComplete();
}
}
static <T> BodyHandler<T> withBodyTimeout(BodyHandler<T> handler, Duration timeout) {
return responseInfo -> new TimeoutBodySubscriber<>(handler.apply(responseInfo), timeout);
}
}
It can be used as follows:
Duration timeout = Duration.ofSeconds(10);
HttpResponse<Stream<String>> httpResponse = httpClient
.send(httpRequest, TimeoutBodySubscriber.withTimeout(HttpResponse.BodyHandlers.ofLines(), timeout));
Another approach is to use a read timeout. This is more flexible as the response isn't timed-out as long as the server remains active (i.e. keeps sending stuff). You'll need a BodySubscriber
that completes itself exceptionally if it doesn't receive its next requested signal within the timeout. This is slightly more complex to implement. You can use Methanol if you're fine with a dependency. It implements read timeouts as described.
Duration timeout = Duration.ofSeconds(3);
HttpResponse<Stream<String>> httpResponse = httpClient
.send(httpRequest, MoreBodyHandlers.withReadTimeout(HttpResponse.BodyHandlers.ofLines(), timeout));
Another strategy is to use a combination of both: time out as soon as the server becomes inactive or the body takes too long to complete.