Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions lib-httpx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,6 @@ 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))
.build();

HxClient client = HxClient.newBuilder().config(config).build();
Expand All @@ -280,6 +277,46 @@ 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. 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:

```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
Expand Down
4 changes: 4 additions & 0 deletions lib-httpx/changelog.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# lib-httpx changelog

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 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
Expand Down
21 changes: 14 additions & 7 deletions lib-httpx/src/main/java/io/seqera/http/HxClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@
* By default, requests are retried on:
* <ul>
* <li>HTTP status codes: 429 (Too Many Requests), 500, 502, 503, 504</li>
* <li>Network errors (IOException)</li>
* <li>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</li>
* </ul>
*
* Retry behavior uses exponential backoff with configurable jitter to prevent thundering herd problems.
Expand Down Expand Up @@ -256,7 +258,8 @@ public static HxClient newHxClient() {
* <ul>
* <li>Add JWT authentication header if configured</li>
* <li>Retry on configured HTTP status codes (default: 429, 500, 502, 503, 504)</li>
* <li>Retry on IOException (network errors)</li>
* <li>Retry on IOException (network errors), except a request timeout raised after the
* request was sent - a connect timeout is still retried</li>
* <li>Attempt token refresh on 401 Unauthorized responses</li>
* </ul>
*
Expand Down Expand Up @@ -378,7 +381,8 @@ public <T> CompletableFuture<HttpResponse<T>> sendAsync(HttpRequest request, HxA
* <ul>
* <li>Add JWT authentication header if configured</li>
* <li>Retry on configured HTTP status codes (default: 429, 500, 502, 503, 504)</li>
* <li>Retry on IOException (network errors)</li>
* <li>Retry on IOException (network errors), except a request timeout raised after the
* request was sent - a connect timeout is still retried</li>
* <li>Attempt token refresh on 401 Unauthorized responses</li>
* </ul>
*
Expand All @@ -401,7 +405,8 @@ public <T> CompletableFuture<HttpResponse<T>> sendAsync(HttpRequest request, Htt
* <ul>
* <li>Add JWT authentication header if configured</li>
* <li>Retry on configured HTTP status codes (default: 429, 500, 502, 503, 504)</li>
* <li>Retry on IOException (network errors)</li>
* <li>Retry on IOException (network errors), except a request timeout raised after the
* request was sent - a connect timeout is still retried</li>
* <li>Attempt token refresh on 401 Unauthorized responses</li>
* </ul>
*
Expand Down Expand Up @@ -613,8 +618,10 @@ protected <T> CompletableFuture<HttpResponse<T>> sendWithRetryAsync(
* Determines whether to retry a request based on the exception that occurred.
*
* <p>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
Expand All @@ -624,7 +631,7 @@ protected boolean shouldRetryOnException(Throwable throwable) {
final Predicate condition = config.getRetryCondition();
return condition != null
? condition.test(throwable)
: throwable instanceof IOException;
: HxConfig.defaultRetryCondition(throwable);
}

/**
Expand Down
46 changes: 45 additions & 1 deletion lib-httpx/src/main/java/io/seqera/http/HxConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -42,6 +45,8 @@
* <li>Jitter: 0.25 (25% random variation)</li>
* <li>Backoff multiplier: 2.0 (exponential)</li>
* <li>Retry status codes: 429, 500, 502, 503, 504</li>
* <li>Retry condition: any {@link IOException} except a request timeout raised after the
* request was sent - see {@link #defaultRetryCondition(Throwable)}</li>
* <li>Token refresh timeout: 30 seconds</li>
* </ul>
*
Expand Down Expand Up @@ -69,7 +74,46 @@
*/
public class HxConfig implements Retryable.Config {

private static final Predicate<? extends Throwable> DEFAULT_RETRY_COND = throwable -> throwable instanceof java.io.IOException;
private static final Predicate<? extends Throwable> 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.
*
* <p>{@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.
*
* <p>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. 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.
*
* <p>Callers that want timeouts retried can opt back in with
* {@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
*/
public 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.5.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
*/
Expand Down
60 changes: 60 additions & 0 deletions lib-httpx/src/test/groovy/io/seqera/http/HxConfigTest.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -98,6 +103,61 @@ 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 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()
.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 }
Expand Down
Loading