diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpCall.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpCall.java index f7f1bc595..0ea64f00c 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpCall.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpCall.java @@ -32,10 +32,10 @@ import java.net.http.HttpTimeoutException; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; +import java.time.DateTimeException; import java.time.Duration; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; @@ -60,7 +60,7 @@ * The request takes no thread while it waits. Its timeout counts from the * moment it is made and bounds it as a whole, redirects included: the head of * the response by the client's own request timeout, set to what is left, and - * the body read by read, so that one that trickles in ends with it as well. + * an independent deadline through the body, including when no more data arrives. * {@link #abort()} ends it from any thread: a body being read at once, and on * Java 16 and later a request still waiting for its head too; before that, * such a request ends with its timeout. @@ -248,7 +248,23 @@ HttpCall followRedirects() { * passed. */ CompletableFuture send(String contentType, byte[] content, int maxBody, Consumer onHead) { - exchange(uri, contentType, content, maxBody, onHead, 0); + // The client's timeout ends at the response head. Keep a deadline + // through the body and retries, even when the server goes silent. + // Completing this guard removes its timeout from the JDK's timer. + CompletableFuture deadline = new CompletableFuture<>(); + deadline.orTimeout(Math.max(0, remainingNanos()), TimeUnit.NANOSECONDS) + .exceptionally(error -> { + abort(); + return null; + }); + result.whenComplete((response, error) -> deadline.complete(null)); + + try { + exchange(uri, contentType, content, maxBody, onHead, 0); + } + catch (RuntimeException e) { + result.completeExceptionally(e); + } return result; } @@ -369,6 +385,11 @@ private void exchange(URI target, String contentType, byte[] content, int maxBod catch (IOException e) { result.completeExceptionally(e); } + }).exceptionally(error -> { + // A failure while processing a response must reach the caller, + // rather than only the discarded completion stage. + result.completeExceptionally(unwrap(error)); + return null; }); } @@ -466,9 +487,9 @@ static long retryDelayMillis(String value, long nowMillis) { try { long date = ZonedDateTime.parse(field, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant().toEpochMilli(); - return Math.max(0, date - nowMillis); + return Math.max(0, Math.subtractExact(date, nowMillis)); } - catch (DateTimeParseException e) { + catch (DateTimeException | ArithmeticException e) { return -1; } } diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpCallTests.java b/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpCallTests.java index df05b402d..66e050c9a 100644 --- a/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpCallTests.java +++ b/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpCallTests.java @@ -31,6 +31,7 @@ import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpHeaders; +import java.net.http.HttpTimeoutException; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; @@ -41,10 +42,13 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import javax.net.ssl.SSLHandshakeException; @@ -131,6 +135,27 @@ void oversizedAnswerRefused() throws Exception { } } + @Test + void deadlineEndsSilentBody() throws Exception { + try (TestHttpEndpoint endpoint = new TestHttpEndpoint(offer -> "v=0 and the rest", 201)) { + endpoint.stallBodies(); + CountDownLatch head = new CountDownLatch(1); + HttpCall call = new HttpCall(HttpCall.newClient(false), endpoint.uri(), "POST", null, + Duration.ofSeconds(2)); + CompletableFuture response = call.send("application/sdp", new byte[] { 'o' }, + 1024, received -> head.countDown()); + try { + assertTrue(head.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + ExecutionException failed = assertThrows(ExecutionException.class, () -> await(response)); + assertInstanceOf(HttpTimeoutException.class, failed.getCause()); + endpoint.awaitHangUp(TIMEOUT_SECONDS); + } + finally { + call.abort(); + } + } + } + @Test void abortEndsRead() throws Exception { // The server sends the head and half the answer, then waits. An abort @@ -276,6 +301,29 @@ void tunnelRefusesBasicByDefault() throws Exception { } } + @Test + void overflowingRetryDateReturnsResponse() throws Exception { + ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor(); + try (TestHttpEndpoint endpoint = new TestHttpEndpoint(offer -> "come back later", 201)) { + endpoint.answerStatus = 503; + endpoint.answerHeaders = "Retry-After: 31 Dec 999999999 23:59:59 GMT\r\n"; + HttpCall call = post(HttpCall.newClient(false), endpoint.uri()).retry(timer, Set.of(503)); + try { + HttpCall.Response response = await(call.send("application/sdp", new byte[] { 'o' }, 1024, + head -> { })); + assertEquals(503, response.status); + assertEquals("come back later", response.body); + assertEquals(1, endpoint.requests.size()); + } + finally { + call.abort(); + } + } + finally { + timer.shutdownNow(); + } + } + @Test void retryAfterRead() { long now = 1_000_000_000_000L; @@ -289,6 +337,7 @@ void retryAfterRead() { assertEquals(5000, HttpCall.retryDelayMillis(inFive, now)); assertEquals(0, HttpCall.retryDelayMillis(past, now)); assertEquals(Long.MAX_VALUE, HttpCall.retryDelayMillis("99999999999999999999", now)); + assertEquals(-1, HttpCall.retryDelayMillis("31 Dec 999999999 23:59:59 GMT", now)); assertEquals(-1, HttpCall.retryDelayMillis("soon", now)); assertEquals(-1, HttpCall.retryDelayMillis("", now)); assertEquals(-1, HttpCall.retryDelayMillis(null, now));