Stop retrying request timeouts by default (lib-httpx 2.5.0) [release] - #116
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
|
@claude review |
…ry-condition-timeouts
|
Claude finished @pditommaso's task in 3m 34s —— View job Review — Stop retrying request timeouts by default (lib-httpx 2.5.0)
Verdict: looks correct — approve. The change is surgical (one root cause, one named rule), tested from both sides, and the escape hatch is documented. Everything below is a nit, none of it blocking. What I verifiedThe rule itself is right. No drift between "the documented default" and "the fallback". All three sites now resolve to the same method:
That was the actual latent hazard here (two copies of the rule), and it's closed. The method-ref assignment to The exception reaches the condition unwrapped. Worth confirming since the sync path goes through Nits1. 2. Stale javadoc bullets still say the old rule. 3. The README's headline config example now demonstrates the opt-out. 4. The Apache precedent is stated slightly wider than the rule implements. 5. Test timing is tight but should hold. 150 ms request timeout against a Behaviour change / release mechanicsThe On minor-vs-major: I'd keep 2.5.0. The changelog leads with One thing the doc gets right that's easy to get wrong: retries are not gated on request method, so the reason for excluding post-send timeouts (a non-idempotent |
There was a problem hiding this comment.
Approving — the new default retry rule is correct against the JDK exception hierarchy (HttpConnectTimeoutException extends HttpTimeoutException extends IOException), and the rule now lives in one place used by both HxConfig's default and HxClient.shouldRetryOnException's null fallback, so the documented default and the fallback can't drift. The sync path unwraps ExecutionException (HxClient.java:597-606), so the predicate sees the real HttpTimeoutException — confirmed by the WireMock pair (1 contact by default, 3 with the opt-in).
Non-blocking nits, detailed in my comment on the PR:
HxConfig.defaultRetryConditionis package-private, but the changelog advertises it as added API and the public javadoc{@link}s it — consumers can't call or compose with it. Consider making it public.- Stale javadoc bullets still describe the old rule: HxClient.java:63, :259, :381, :404.
- README.md:262-263 — the "Custom Retry Configuration" example now demonstrates
.retryCondition(t -> t instanceof IOException), i.e. the opt-back-in; copy-pasting it silently reverts this fix. - The Apache precedent is stated as "the whole InterruptedIOException family", but
SocketTimeoutExceptionis still retried (unreachable via java.net.http, so not wrong — just wider than the rule). - 150ms-vs-1000ms test margins are fine but are the first thing to widen if they flake.
Keeping it at 2.5.0 seems right: no public surface removed or repurposed, changelog leads with BEHAVIOUR CHANGE, one-line escape hatch, and 2.3.0 set the precedent for retry-behaviour changes in a minor. The one step not enforced by code is the [release] marker staying off #114 at merge time.
I could not run ./gradlew :lib-httpx:test in this environment, so CI is the source of truth for the green claim.
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
460af80
into
fix/hxclient-config-lossless-copy
The design doc's "Out of scope" section still said changing the default retryCondition was deferred, but it shipped on this branch via #116 and releases in 2.5.0 - the doc is checked in, so it would have read as wrong next to the code. Record the rule as implemented and list what remains out: the builder delegates, and the idempotency/retry-budget work in #115. The inline comment on build()'s explicit-httpClient branch claimed proxy settings are "neither applied to it nor propagated to the internal token refresh clients". Since copyFrom() retains a config-carried proxy, they do reach the refresh clients on that path - which is the behaviour change this release advertises, and which the javadoc above already stated correctly. Both new timeout tests used send(). Added the sendAsync counterpart, which pins the ExecutionException unwrapping that makes the async path agree: verified it fails (3 requests) under the pre-2.5.0 rule. Raised in review of #114. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…quest timeouts (lib-httpx 2.5.0) (#114) [release] * Add design doc for lossless HxClient.Builder.config() Records the diagnosis of issue #113, the measured blast radius across the consumers visible locally, and the chosen fix: a copy factory on HxConfig plus a reflective round-trip guard, with shouldRetryOnException wired into the retry policy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Fix HxClient.Builder.config() silently dropping HxConfig settings config(HxConfig) rebuilt the configuration field by field, and the list had drifted from HxConfig.Builder.build(): retryCondition, tokenRefreshTimeout and refreshCookiePolicy never made the trip and reverted to their defaults. Two of them have no delegate on HxClient.Builder, so they were effectively unsettable on an HxClient. Add HxConfig.newBuilder(HxConfig), backed by a copyFrom() placed next to build() so the two field lists can be reviewed together, and reduce config() to that copy. A reflective round-trip test asserts every declared HxConfig field survives both build() paths, and fails on a field no fixture covers - so a field added later cannot silently repeat this. Also wire shouldRetryOnException(Throwable) into the retry policy: it was documented as the retry-on-exception decision point but nothing called it, since sendWithRetry/sendWithRetryAsync used config.getRetryCondition() directly. It now delegates to that condition, which is behaviour-identical for a default config and makes overriding it take effect. One intended behaviour change: proxy settings carried by a configuration passed to config() now reach the internal token refresh clients also when an explicit HttpClient is supplied. Fixes #113 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Document that config() also replaces proxy() and authenticator() The javadoc warned only about "HxConfig-specific builder methods" being discarded, but config() overwrites the builder's proxy selector and authenticator too - so .proxy(x).config(cfg) silently loses x when cfg carries no proxy. Name them explicitly, and pin the behaviour with a test. Raised in review of #114. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Stop retrying request timeouts by default (#116) * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * Correct the stale scope statements and cover the async timeout path The design doc's "Out of scope" section still said changing the default retryCondition was deferred, but it shipped on this branch via #116 and releases in 2.5.0 - the doc is checked in, so it would have read as wrong next to the code. Record the rule as implemented and list what remains out: the builder delegates, and the idempotency/retry-budget work in #115. The inline comment on build()'s explicit-httpClient branch claimed proxy settings are "neither applied to it nor propagated to the internal token refresh clients". Since copyFrom() retains a config-carried proxy, they do reach the refresh clients on that path - which is the behaviour change this release advertises, and which the javadoc above already stated correctly. Both new timeout tests used send(). Added the sendAsync counterpart, which pins the ExecutionException unwrapping that makes the async path agree: verified it fails (3 requests) under the pre-2.5.0 rule. Raised in review of #114. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements the second half of #113's Impact section, which #114 deliberately left out of scope.
The problem
HttpTimeoutException extends IOException, so the default retry condition (t instanceof IOException) retried request timeouts. The real bound on a call was thereforemaxAttempts × timeout, nottimeout— with the default of 5 attempts, a 2 minute timeout was a 10 minute worst case. Verified on the pre-change code: a request that times out is contacted 3 of 3 attempts.Retrying such a timeout is also ambiguous — the server may have received the request and still be working on it, so re-sending can run a non-idempotent operation twice.
The change
HxConfig.defaultRetryCondition(Throwable)now retries anyIOExceptionexcept anHttpTimeoutExceptionthat is not anHttpConnectTimeoutException:A connect timeout is raised before the request is sent, so re-sending is safe and may reach a healthy endpoint — it stays retryable. The rule lives in one named method that
HxClient.shouldRetryOnExceptionalso uses as its fallback when the configured condition isnull, so the documented default and the fallback cannot drift.Why this shape — how other clients default
Surveyed from source, not docs:
isRecoverable:e is SocketTimeoutException && !requestSendStarted)ConnectExceptionnon-retriable)InterruptedIOExceptionis in the default non-retriable list and the check usesisInstance, soSocketTimeoutExceptionis coveredjava.net.httpIOException)ApiCallTimeoutException(whole call) is deliberately not retryable — an overall budget terminates the sequenceTwo coherent designs exist — exclude post-send timeouts (OkHttp, Apache, JDK), or retry them under an absolute overall budget (AWS, gRPC). lib-httpx did neither. This PR adopts the first, which is the narrower change; the budget option is filed as #115.
Release: both layers ship as 2.5.0
This change is folded into 2.5.0 together with #114 —
VERSIONstays at2.5.0here and the changelog entries join that section, led byBEHAVIOUR CHANGE.That requires the two PRs to publish once, from this one.
publish.shskips any version already in the repo, so if #114 merged with a[release]marker it would publish 2.5.0 without this change, and this change could then never be published under 2.5.0. To avoid that, the[release]marker lives only on this PR: merge #114 first (no publish), then this one (publishes 2.5.0 with both layers).It is still a behaviour change — a call that previously succeeded on a retry after a timeout now fails on the first one. That's consistent with how this repo has treated retry-behaviour changes before (2.3.0 changed retry timing by honouring
Retry-After), but if you'd rather it be a major, both the version and the marker are one-line changes.No consumer in
platform,nextflow,schedorwavesets aretryCondition, so all of them inherit the new default when they upgrade. That is the intent, but it is worth knowing: their timeout-heavy calls (nf-tower, nf-wave, the registry and plugin-repo clients, sched-client, platform-client) will stop burning attempts on timeouts.Opting back in is one line:
Tests
HxConfigTest— awhere-driven test overHxConfig.defaultRetryCondition: 10 cases coveringIOException,ConnectException,SocketTimeoutException,FileNotFoundException,HttpConnectTimeoutException(all retried),HttpTimeoutException(not retried) and four non-I/O throwables. Each case also asserts the condition wired into a defaultHxConfigagrees with the static rule.HxClientRetryIntegrationTest— WireMock pair: a request timeout under the default condition contacts the upstream once; withretryCondition(t -> t instanceof IOException)it contacts it 3 times. The first fails against the pre-change code, the second passes on both — together they pin the change and its escape hatch../gradlew :lib-httpx:build :lib-cloudinfo:testgreen.Docs
README gains a "Which failures are retried" section explaining the default, the
HttpTimeoutException extends IOExceptiontrap, the OkHttp/Apache precedent, and how to opt back in. It also notes that retries are not gated on request method — aPOSTis retried like aGET— which is filed separately as #115.🤖 Generated with Claude Code