From 3e5478b58a6e8e01c26fae1b0dacdae03996e14f Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Tue, 11 Aug 2026 21:35:25 +0200 Subject: [PATCH 1/4] Stop retrying request timeouts by default HttpTimeoutException extends IOException, so the default retry condition retried request timeouts and the real bound on a call was maxAttempts x timeout rather than timeout - with the default of 5 attempts, a 2 minute timeout was a 10 minute worst case. Such a timeout is also ambiguous: the server may have received the request and still be working on it, so re-sending risks running a non-idempotent operation twice. The default condition now retries any IOException except an HttpTimeoutException that is not an HttpConnectTimeoutException - a connect timeout is raised before the request is sent, so re-sending is safe. This matches OkHttp, which recovers from a socket timeout only while the request has not been sent, and Apache HttpClient 5, which treats the whole InterruptedIOException family as non-retriable. The rule moves into HxConfig.defaultRetryCondition(Throwable), which HxClient.shouldRetryOnException also uses as its fallback when the configured condition is null, so the two cannot drift. Callers that want timeouts retried can opt back in with retryCondition(t -> t instanceof IOException). Co-Authored-By: Claude Opus 5 (1M context) --- lib-httpx/README.md | 36 +++++++++-- lib-httpx/VERSION | 2 +- lib-httpx/changelog.txt | 6 ++ .../main/java/io/seqera/http/HxClient.java | 8 ++- .../main/java/io/seqera/http/HxConfig.java | 43 ++++++++++++- .../http/HxClientRetryIntegrationTest.groovy | 60 +++++++++++++++++++ .../groovy/io/seqera/http/HxConfigTest.groovy | 42 +++++++++++++ 7 files changed, 188 insertions(+), 9 deletions(-) diff --git a/lib-httpx/README.md b/lib-httpx/README.md index 1d2489eb..1b7e71b3 100644 --- a/lib-httpx/README.md +++ b/lib-httpx/README.md @@ -10,7 +10,7 @@ Add the dependency to your `build.gradle`: ```gradle dependencies { - implementation 'io.seqera:lib-httpx:2.5.0' + implementation 'io.seqera:lib-httpx:2.6.0' } ``` @@ -259,9 +259,8 @@ HxConfig config = HxConfig.newBuilder() .jitter(0.5) .multiplier(2.0) .retryStatusCodes(Set.of(429, 500, 502, 503, 504)) - // which failures are retried - defaults to any IOException, which includes - // HttpTimeoutException; narrow it to bound a call by its timeout - .retryCondition(t -> t instanceof IOException && !(t instanceof HttpTimeoutException)) + // which failures are retried - see "Which failures are retried" below + .retryCondition(t -> t instanceof IOException) .build(); HxClient client = HxClient.newBuilder().config(config).build(); @@ -280,6 +279,35 @@ HxConfig relaxed = HxConfig.newBuilder(config) Alternatively, subclass `HxClient` and override `shouldRetryOnException(Throwable)` to decide independently of the configuration. +### Which failures are retried + +By default a request is retried when it fails with an `IOException` — connection reset, connection +refused, `HttpConnectTimeoutException` — **except** an `HttpTimeoutException` raised after the request +was sent, which is not retried. + +That exception to the rule matters because `HttpTimeoutException extends IOException`: a plain +"retry any `IOException`" rule retries request timeouts, which makes the real bound on a call +`maxAttempts × timeout` instead of `timeout` — with the default of 5 attempts, a 2 minute timeout is a +10 minute worst case. A timeout that elapses after the request was sent is also ambiguous: the server +may have received the request and be processing it, so re-sending can duplicate a non-idempotent +operation. `HttpConnectTimeoutException` is a subclass raised *before* the request is sent, so it stays +retryable. + +This matches the defaults of comparable clients — OkHttp recovers from a socket timeout only while the +request has not been sent, and Apache HttpClient 5 treats the whole `InterruptedIOException` family as +non-retriable. + +To retry request timeouts anyway, say so explicitly: + +```java +HxConfig config = HxConfig.newBuilder() + .retryCondition(t -> t instanceof IOException) // includes HttpTimeoutException + .build(); +``` + +Note that retries are **not** gated on the request method: a `POST` is retried like a `GET`. Until that +changes, prefer idempotent endpoints, or narrow `retryCondition` for calls with side effects. + ### Integration with Existing Retry Configuration ```java diff --git a/lib-httpx/VERSION b/lib-httpx/VERSION index 437459cd..e70b4523 100644 --- a/lib-httpx/VERSION +++ b/lib-httpx/VERSION @@ -1 +1 @@ -2.5.0 +2.6.0 diff --git a/lib-httpx/changelog.txt b/lib-httpx/changelog.txt index 2a16a17e..ee8afe0b 100644 --- a/lib-httpx/changelog.txt +++ b/lib-httpx/changelog.txt @@ -1,5 +1,11 @@ # lib-httpx changelog +2.6.0 - 11 Aug 2026 +- BEHAVIOUR CHANGE: the default retry condition no longer retries an HttpTimeoutException raised after the request was sent, so a call is bounded by its timeout instead of maxAttempts x timeout (#113) +- HttpConnectTimeoutException, raised before the request is sent, remains retryable +- Callers that want the previous behaviour can opt back in with retryCondition(t -> t instanceof IOException) +- Add HxConfig.defaultRetryCondition(Throwable), also used as the fallback when retryCondition is set to null + 2.5.0 - 11 Aug 2026 - Fix HxClient.Builder.config(HxConfig) silently dropping retryCondition, tokenRefreshTimeout and refreshCookiePolicy (#113) - Add HxConfig.newBuilder(HxConfig) creating a builder pre-populated with all the settings of an existing config diff --git a/lib-httpx/src/main/java/io/seqera/http/HxClient.java b/lib-httpx/src/main/java/io/seqera/http/HxClient.java index 929aa1e4..664f979c 100644 --- a/lib-httpx/src/main/java/io/seqera/http/HxClient.java +++ b/lib-httpx/src/main/java/io/seqera/http/HxClient.java @@ -613,8 +613,10 @@ protected CompletableFuture> sendWithRetryAsync( * Determines whether to retry a request based on the exception that occurred. * *

Delegates to the configured {@link HxConfig#getRetryCondition()}, which by default - * retries on IOException - typically network-level issues such as connection timeouts, - * connection refused, etc. Override to decide independently of the configuration. + * retries network-level IOExceptions such as connection resets and connect timeouts, but + * not a request timeout that elapsed after the request was sent - see + * {@link HxConfig#defaultRetryCondition(Throwable)}. Override to decide independently of + * the configuration. * * @param throwable the exception that occurred during the request * @return true if the request should be retried, false otherwise @@ -624,7 +626,7 @@ protected boolean shouldRetryOnException(Throwable throwable) { final Predicate condition = config.getRetryCondition(); return condition != null ? condition.test(throwable) - : throwable instanceof IOException; + : HxConfig.defaultRetryCondition(throwable); } /** diff --git a/lib-httpx/src/main/java/io/seqera/http/HxConfig.java b/lib-httpx/src/main/java/io/seqera/http/HxConfig.java index 4af30a9f..f072ee18 100644 --- a/lib-httpx/src/main/java/io/seqera/http/HxConfig.java +++ b/lib-httpx/src/main/java/io/seqera/http/HxConfig.java @@ -17,10 +17,13 @@ package io.seqera.http; +import java.io.IOException; import java.net.Authenticator; import java.net.CookiePolicy; import java.net.ProxySelector; import java.net.http.HttpClient; +import java.net.http.HttpConnectTimeoutException; +import java.net.http.HttpTimeoutException; import java.time.Duration; import java.util.Set; import java.util.function.Predicate; @@ -42,6 +45,8 @@ *

  • Jitter: 0.25 (25% random variation)
  • *
  • Backoff multiplier: 2.0 (exponential)
  • *
  • Retry status codes: 429, 500, 502, 503, 504
  • + *
  • Retry condition: any {@link IOException} except a request timeout raised after the + * request was sent - see {@link #defaultRetryCondition(Throwable)}
  • *
  • Token refresh timeout: 30 seconds
  • * * @@ -69,7 +74,43 @@ */ public class HxConfig implements Retryable.Config { - private static final Predicate DEFAULT_RETRY_COND = throwable -> throwable instanceof java.io.IOException; + private static final Predicate DEFAULT_RETRY_COND = HxConfig::defaultRetryCondition; + + /** + * The default retry-on-exception rule: retry I/O failures, but not a request timeout that + * elapsed after the request was sent. + * + *

    {@link HttpTimeoutException} extends {@link IOException}, so a plain "retry any + * IOException" rule retries request timeouts and the real bound on a call becomes + * {@code maxAttempts × timeout} instead of {@code timeout}. Such a timeout is also + * ambiguous - the server may have received the request and be processing it - so re-sending + * risks duplicating a non-idempotent operation. {@link HttpConnectTimeoutException} is a + * subclass raised before the request is sent and stays retryable. + * + *

    This mirrors the defaults of other clients: OkHttp recovers from a socket timeout only + * when the request has not been sent, and Apache HttpClient 5 treats the whole + * {@code InterruptedIOException} family as non-retriable. + * + *

    Callers that want timeouts retried can opt back in with + * {@code retryCondition(t -> t instanceof IOException)}. + * + * @param throwable the exception raised while sending the request + * @return true if the request should be retried, false otherwise + */ + static boolean defaultRetryCondition(Throwable throwable) { + if (!(throwable instanceof IOException)) + return false; + if (throwable instanceof HttpTimeoutException) + // Retry only a connect timeout. It is raised while establishing the connection, so the + // request never reached the server: nothing ran, re-sending cannot duplicate anything, + // and the next attempt may pick a healthy endpoint. A bare HttpTimeoutException instead + // means the request timeout elapsed after the request was sent - the server may well + // have received it and still be working on it, so re-sending risks running a + // non-idempotent operation twice, and only pushes the worst case out to + // maxAttempts x timeout without making an answer more likely. + return throwable instanceof HttpConnectTimeoutException; + return true; + } private Duration delay = Duration.ofMillis(500); private Duration maxDelay = Duration.ofSeconds(30); diff --git a/lib-httpx/src/test/groovy/io/seqera/http/HxClientRetryIntegrationTest.groovy b/lib-httpx/src/test/groovy/io/seqera/http/HxClientRetryIntegrationTest.groovy index 0b8eee75..840b4e77 100644 --- a/lib-httpx/src/test/groovy/io/seqera/http/HxClientRetryIntegrationTest.groovy +++ b/lib-httpx/src/test/groovy/io/seqera/http/HxClientRetryIntegrationTest.groovy @@ -22,6 +22,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.* import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse +import java.net.http.HttpTimeoutException import java.time.Duration import java.util.concurrent.ExecutionException import java.util.function.Predicate @@ -620,6 +621,65 @@ class HxClientRetryIntegrationTest extends Specification { wireMockServer.verify(1, getRequestedFor(urlEqualTo('/api/subclass-no-retry'))) } + def 'should not retry a request timeout by default'() { + given: 'default retry condition, 3 attempts' + def config = HxConfig.newBuilder() + .maxAttempts(3) + .delay(Duration.ofMillis(50)) + .build() + def client = HxClient.newBuilder().config(config).build() + + and: 'server always responds slower than the request timeout' + wireMockServer.stubFor(get(urlEqualTo('/api/slow-default')) + .willReturn(aResponse().withStatus(200).withFixedDelay(1000))) + + and: + def request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:${wireMockServer.port()}/api/slow-default")) + .timeout(Duration.ofMillis(150)) + .GET() + .build() + + when: + client.send(request, HttpResponse.BodyHandlers.ofString()) + + then: + thrown(HttpTimeoutException) + + and: 'the call is bounded by its timeout, not by maxAttempts x timeout' + wireMockServer.verify(1, getRequestedFor(urlEqualTo('/api/slow-default'))) + } + + def 'should retry a request timeout when the caller opts back in'() { + given: 'the pre-2.6.0 rule supplied explicitly' + def config = HxConfig.newBuilder() + .maxAttempts(3) + .delay(Duration.ofMillis(50)) + .retryCondition({ Throwable t -> t instanceof IOException } as Predicate) + .build() + def client = HxClient.newBuilder().config(config).build() + + and: 'server always responds slower than the request timeout' + wireMockServer.stubFor(get(urlEqualTo('/api/slow-optin')) + .willReturn(aResponse().withStatus(200).withFixedDelay(1000))) + + and: + def request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:${wireMockServer.port()}/api/slow-optin")) + .timeout(Duration.ofMillis(150)) + .GET() + .build() + + when: + client.send(request, HttpResponse.BodyHandlers.ofString()) + + then: + thrown(HttpTimeoutException) + + and: 'all 3 attempts are made' + wireMockServer.verify(3, getRequestedFor(urlEqualTo('/api/slow-optin'))) + } + /** * Subclass overriding the documented retry-on-exception extension point */ diff --git a/lib-httpx/src/test/groovy/io/seqera/http/HxConfigTest.groovy b/lib-httpx/src/test/groovy/io/seqera/http/HxConfigTest.groovy index 825dd060..c98f73fa 100644 --- a/lib-httpx/src/test/groovy/io/seqera/http/HxConfigTest.groovy +++ b/lib-httpx/src/test/groovy/io/seqera/http/HxConfigTest.groovy @@ -17,8 +17,13 @@ package io.seqera.http +import java.net.ConnectException import java.net.CookiePolicy +import java.net.SocketTimeoutException +import java.net.http.HttpConnectTimeoutException +import java.net.http.HttpTimeoutException import java.time.Duration +import java.util.function.Predicate import io.seqera.util.retry.Retryable import spock.lang.Specification @@ -98,6 +103,43 @@ class HxConfigTest extends Specification { config.retryCondition.test(new Exception('generic exception')) == false } + def 'should apply the default retry condition - #exception.class.simpleName is retried: #expected'() { + expect: + HxConfig.defaultRetryCondition(exception) == expected + + and: 'the condition wired into a default config agrees with the static rule' + HxConfig.newBuilder().build().retryCondition.test(exception) == expected + + where: + exception || expected + // I/O failures are retried + new IOException('io error') || true + new ConnectException('connection refused') || true + new SocketTimeoutException('socket timeout') || true + new FileNotFoundException('not found') || true + // HttpConnectTimeoutException extends HttpTimeoutException: raised before the + // request was sent, so re-sending is safe + new HttpConnectTimeoutException('connect timeout') || true + // HttpTimeoutException extends IOException, which is why a plain + // 'instanceof IOException' rule retried it - the server may have processed the request + new HttpTimeoutException('request timeout') || false + // non-I/O failures are never retried + new RuntimeException('runtime error') || false + new IllegalArgumentException('invalid arg') || false + new NullPointerException('null pointer') || false + new Exception('generic exception') || false + } + + def 'should allow opting back in to retrying request timeouts'() { + when: 'the pre-2.6.0 rule is supplied explicitly' + def config = HxConfig.newBuilder() + .retryCondition({ Throwable t -> t instanceof IOException } as Predicate) + .build() + + then: + config.retryCondition.test(new HttpTimeoutException('request timed out')) == true + } + def 'should create config with custom retry condition'() { given: def customCondition = { throwable -> throwable instanceof IllegalArgumentException } From 564a6a55a686d5016f875da6882c106532596bc7 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Tue, 11 Aug 2026 21:40:21 +0200 Subject: [PATCH 2/4] Ship the retry-condition change as part of 2.5.0 Fold the changelog entries into the 2.5.0 section rather than opening a 2.6.0, so the two stacked changes publish as a single release. Co-Authored-By: Claude Opus 5 (1M context) --- lib-httpx/README.md | 2 +- lib-httpx/VERSION | 2 +- lib-httpx/changelog.txt | 4 +--- .../groovy/io/seqera/http/HxClientRetryIntegrationTest.groovy | 2 +- lib-httpx/src/test/groovy/io/seqera/http/HxConfigTest.groovy | 2 +- 5 files changed, 5 insertions(+), 7 deletions(-) diff --git a/lib-httpx/README.md b/lib-httpx/README.md index 1b7e71b3..73f04f51 100644 --- a/lib-httpx/README.md +++ b/lib-httpx/README.md @@ -10,7 +10,7 @@ Add the dependency to your `build.gradle`: ```gradle dependencies { - implementation 'io.seqera:lib-httpx:2.6.0' + implementation 'io.seqera:lib-httpx:2.5.0' } ``` diff --git a/lib-httpx/VERSION b/lib-httpx/VERSION index e70b4523..437459cd 100644 --- a/lib-httpx/VERSION +++ b/lib-httpx/VERSION @@ -1 +1 @@ -2.6.0 +2.5.0 diff --git a/lib-httpx/changelog.txt b/lib-httpx/changelog.txt index ee8afe0b..ae0077fd 100644 --- a/lib-httpx/changelog.txt +++ b/lib-httpx/changelog.txt @@ -1,12 +1,10 @@ # lib-httpx changelog -2.6.0 - 11 Aug 2026 +2.5.0 - 11 Aug 2026 - BEHAVIOUR CHANGE: the default retry condition no longer retries an HttpTimeoutException raised after the request was sent, so a call is bounded by its timeout instead of maxAttempts x timeout (#113) - HttpConnectTimeoutException, raised before the request is sent, remains retryable - Callers that want the previous behaviour can opt back in with retryCondition(t -> t instanceof IOException) - Add HxConfig.defaultRetryCondition(Throwable), also used as the fallback when retryCondition is set to null - -2.5.0 - 11 Aug 2026 - Fix HxClient.Builder.config(HxConfig) silently dropping retryCondition, tokenRefreshTimeout and refreshCookiePolicy (#113) - Add HxConfig.newBuilder(HxConfig) creating a builder pre-populated with all the settings of an existing config - Wire HxClient.shouldRetryOnException(Throwable) into the retry policy, so overriding it now takes effect; it delegates to HxConfig.retryCondition, unchanged for a default config diff --git a/lib-httpx/src/test/groovy/io/seqera/http/HxClientRetryIntegrationTest.groovy b/lib-httpx/src/test/groovy/io/seqera/http/HxClientRetryIntegrationTest.groovy index 840b4e77..73054333 100644 --- a/lib-httpx/src/test/groovy/io/seqera/http/HxClientRetryIntegrationTest.groovy +++ b/lib-httpx/src/test/groovy/io/seqera/http/HxClientRetryIntegrationTest.groovy @@ -651,7 +651,7 @@ class HxClientRetryIntegrationTest extends Specification { } def 'should retry a request timeout when the caller opts back in'() { - given: 'the pre-2.6.0 rule supplied explicitly' + given: 'the pre-2.5.0 rule supplied explicitly' def config = HxConfig.newBuilder() .maxAttempts(3) .delay(Duration.ofMillis(50)) diff --git a/lib-httpx/src/test/groovy/io/seqera/http/HxConfigTest.groovy b/lib-httpx/src/test/groovy/io/seqera/http/HxConfigTest.groovy index c98f73fa..d8d4d5db 100644 --- a/lib-httpx/src/test/groovy/io/seqera/http/HxConfigTest.groovy +++ b/lib-httpx/src/test/groovy/io/seqera/http/HxConfigTest.groovy @@ -131,7 +131,7 @@ class HxConfigTest extends Specification { } def 'should allow opting back in to retrying request timeouts'() { - when: 'the pre-2.6.0 rule is supplied explicitly' + when: 'the pre-2.5.0 rule is supplied explicitly' def config = HxConfig.newBuilder() .retryCondition({ Throwable t -> t instanceof IOException } as Predicate) .build() From f083ed35ded6d1ad15f7fced493f2ba7c35d42a4 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Tue, 11 Aug 2026 21:54:06 +0200 Subject: [PATCH 3/4] Align the retry javadoc and README example with the new default Four public-facing javadoc bullets still described the old rule - the class-level "Network errors (IOException)" and the identical bullet on send/sendAsync - so they contradicted the default they document. The README's headline "Custom Retry Configuration" example set retryCondition(t -> t instanceof IOException), which is the opt-back-in: copy-pasting it silently restored the maxAttempts x timeout worst case. Dropped it - the new default is the recommended behaviour, and the opt-in stays documented under "Which failures are retried". Raised in review of #116. Co-Authored-By: Claude Opus 5 (1M context) --- lib-httpx/README.md | 2 -- .../src/main/java/io/seqera/http/HxClient.java | 13 +++++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/lib-httpx/README.md b/lib-httpx/README.md index 73f04f51..bf50df1c 100644 --- a/lib-httpx/README.md +++ b/lib-httpx/README.md @@ -259,8 +259,6 @@ HxConfig config = HxConfig.newBuilder() .jitter(0.5) .multiplier(2.0) .retryStatusCodes(Set.of(429, 500, 502, 503, 504)) - // which failures are retried - see "Which failures are retried" below - .retryCondition(t -> t instanceof IOException) .build(); HxClient client = HxClient.newBuilder().config(config).build(); diff --git a/lib-httpx/src/main/java/io/seqera/http/HxClient.java b/lib-httpx/src/main/java/io/seqera/http/HxClient.java index 41497337..bc1015ab 100644 --- a/lib-httpx/src/main/java/io/seqera/http/HxClient.java +++ b/lib-httpx/src/main/java/io/seqera/http/HxClient.java @@ -60,7 +60,9 @@ * By default, requests are retried on: *

      *
    • HTTP status codes: 429 (Too Many Requests), 500, 502, 503, 504
    • - *
    • Network errors (IOException)
    • + *
    • Network errors (IOException), except a request timeout raised after the request was + * sent - such a timeout is not retried because the server may already be processing the + * request; a connect timeout ({@code HttpConnectTimeoutException}) is still retried
    • *
    * * Retry behavior uses exponential backoff with configurable jitter to prevent thundering herd problems. @@ -256,7 +258,8 @@ public static HxClient newHxClient() { *
      *
    • Add JWT authentication header if configured
    • *
    • Retry on configured HTTP status codes (default: 429, 500, 502, 503, 504)
    • - *
    • Retry on IOException (network errors)
    • + *
    • Retry on IOException (network errors), except a request timeout raised after the + * request was sent - a connect timeout is still retried
    • *
    • Attempt token refresh on 401 Unauthorized responses
    • *
    * @@ -378,7 +381,8 @@ public CompletableFuture> sendAsync(HttpRequest request, HxA *
      *
    • Add JWT authentication header if configured
    • *
    • Retry on configured HTTP status codes (default: 429, 500, 502, 503, 504)
    • - *
    • Retry on IOException (network errors)
    • + *
    • Retry on IOException (network errors), except a request timeout raised after the + * request was sent - a connect timeout is still retried
    • *
    • Attempt token refresh on 401 Unauthorized responses
    • *
    * @@ -401,7 +405,8 @@ public CompletableFuture> sendAsync(HttpRequest request, Htt *
      *
    • Add JWT authentication header if configured
    • *
    • Retry on configured HTTP status codes (default: 429, 500, 502, 503, 504)
    • - *
    • Retry on IOException (network errors)
    • + *
    • Retry on IOException (network errors), except a request timeout raised after the + * request was sent - a connect timeout is still retried
    • *
    • Attempt token refresh on 401 Unauthorized responses
    • *
    * From 1fbe5daaf26929e6de3844229ce65ca1815c34f1 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Tue, 11 Aug 2026 21:56:39 +0200 Subject: [PATCH 4/4] Make defaultRetryCondition public and scope the Apache precedent The changelog advertised HxConfig.defaultRetryCondition(Throwable) as added API and two published doc comments linked to it, but it was package-private: consumers could neither call it nor follow those links, and composing with the default was impossible. Make it public, document the composition pattern, and cover it with a test. The cited Apache HttpClient 5 precedent was stated wider than this rule implements - Apache treats the whole InterruptedIOException family as non-retriable, while SocketTimeoutException stays retryable here. Say so, in both the javadoc and the README. Raised in review of #116. Co-Authored-By: Claude Opus 5 (1M context) --- lib-httpx/README.md | 13 ++++++++++++- lib-httpx/changelog.txt | 2 +- .../src/main/java/io/seqera/http/HxConfig.java | 9 ++++++--- .../groovy/io/seqera/http/HxConfigTest.groovy | 18 ++++++++++++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/lib-httpx/README.md b/lib-httpx/README.md index bf50df1c..bac1f093 100644 --- a/lib-httpx/README.md +++ b/lib-httpx/README.md @@ -293,7 +293,18 @@ retryable. This matches the defaults of comparable clients — OkHttp recovers from a socket timeout only while the request has not been sent, and Apache HttpClient 5 treats the whole `InterruptedIOException` family as -non-retriable. +non-retriable. The exclusion here is narrower than Apache's: it is scoped to the `HttpTimeoutException` +that `java.net.http` raises for a request timeout, so a `SocketTimeoutException` — an +`InterruptedIOException` the JDK client does not normally surface — stays retryable. + +The rule is available as `HxConfig.defaultRetryCondition(Throwable)`, so a caller can extend it instead +of restating it: + +```java +HxConfig config = HxConfig.newBuilder() + .retryCondition(t -> HxConfig.defaultRetryCondition(t) || t instanceof MyTransientException) + .build(); +``` To retry request timeouts anyway, say so explicitly: diff --git a/lib-httpx/changelog.txt b/lib-httpx/changelog.txt index ae0077fd..d061616c 100644 --- a/lib-httpx/changelog.txt +++ b/lib-httpx/changelog.txt @@ -4,7 +4,7 @@ - BEHAVIOUR CHANGE: the default retry condition no longer retries an HttpTimeoutException raised after the request was sent, so a call is bounded by its timeout instead of maxAttempts x timeout (#113) - HttpConnectTimeoutException, raised before the request is sent, remains retryable - Callers that want the previous behaviour can opt back in with retryCondition(t -> t instanceof IOException) -- Add HxConfig.defaultRetryCondition(Throwable), also used as the fallback when retryCondition is set to null +- Add public HxConfig.defaultRetryCondition(Throwable) so callers can compose with the default rule; it is also the fallback when retryCondition is set to null - Fix HxClient.Builder.config(HxConfig) silently dropping retryCondition, tokenRefreshTimeout and refreshCookiePolicy (#113) - Add HxConfig.newBuilder(HxConfig) creating a builder pre-populated with all the settings of an existing config - Wire HxClient.shouldRetryOnException(Throwable) into the retry policy, so overriding it now takes effect; it delegates to HxConfig.retryCondition, unchanged for a default config diff --git a/lib-httpx/src/main/java/io/seqera/http/HxConfig.java b/lib-httpx/src/main/java/io/seqera/http/HxConfig.java index f072ee18..6ba1e414 100644 --- a/lib-httpx/src/main/java/io/seqera/http/HxConfig.java +++ b/lib-httpx/src/main/java/io/seqera/http/HxConfig.java @@ -89,15 +89,18 @@ public class HxConfig implements Retryable.Config { * *

    This mirrors the defaults of other clients: OkHttp recovers from a socket timeout only * when the request has not been sent, and Apache HttpClient 5 treats the whole - * {@code InterruptedIOException} family as non-retriable. + * {@code InterruptedIOException} family as non-retriable. The exclusion here is narrower + * than Apache's - it is scoped to the {@link HttpTimeoutException} that {@code java.net.http} + * raises for a request timeout, so a {@code SocketTimeoutException} stays retryable. * *

    Callers that want timeouts retried can opt back in with - * {@code retryCondition(t -> t instanceof IOException)}. + * {@code retryCondition(t -> t instanceof IOException)}, or compose with this rule, for + * example {@code retryCondition(t -> HxConfig.defaultRetryCondition(t) || t instanceof MyEx)}. * * @param throwable the exception raised while sending the request * @return true if the request should be retried, false otherwise */ - static boolean defaultRetryCondition(Throwable throwable) { + public static boolean defaultRetryCondition(Throwable throwable) { if (!(throwable instanceof IOException)) return false; if (throwable instanceof HttpTimeoutException) diff --git a/lib-httpx/src/test/groovy/io/seqera/http/HxConfigTest.groovy b/lib-httpx/src/test/groovy/io/seqera/http/HxConfigTest.groovy index d8d4d5db..481e25c0 100644 --- a/lib-httpx/src/test/groovy/io/seqera/http/HxConfigTest.groovy +++ b/lib-httpx/src/test/groovy/io/seqera/http/HxConfigTest.groovy @@ -130,6 +130,24 @@ class HxConfigTest extends Specification { new Exception('generic exception') || false } + def 'should allow composing with the default retry condition'() { + when: 'the documented composition pattern - the default plus one extra failure' + def config = HxConfig.newBuilder() + .retryCondition({ Throwable t -> + HxConfig.defaultRetryCondition(t) || t instanceof IllegalStateException + } as Predicate) + .build() + + then: 'the extra failure is retried' + config.retryCondition.test(new IllegalStateException('transient')) == true + + and: 'the default rule still applies to everything else' + config.retryCondition.test(new IOException('io error')) == true + config.retryCondition.test(new HttpConnectTimeoutException('connect timeout')) == true + config.retryCondition.test(new HttpTimeoutException('request timeout')) == false + config.retryCondition.test(new RuntimeException('runtime error')) == false + } + def 'should allow opting back in to retrying request timeouts'() { when: 'the pre-2.5.0 rule is supplied explicitly' def config = HxConfig.newBuilder()