Fix HxClient.Builder.config() dropping settings, and stop retrying request timeouts (lib-httpx 2.5.0) - #114
Conversation
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>
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>
|
@claude review |
|
Claude finished @pditommaso's task in 3m 18s —— View job Review: lossless
|
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 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>
|
@claude validate the changes after the last review |
|
Claude finished @pditommaso's task in 4m 28s —— View job Validation of the changes made since the last review
Verdict: validated — the changes since my last review hold up. No correctness, security or data-loss issue found. CI is green on this head ( What changed since the last reviewOne commit: What I re-verified1. The original fix is intact. 2. The new default is exactly the delta claimed, no wider.
3. No drift between the two definitions of "the default". This was the failure mode worth checking, since there are now three candidate defaults in play. 4. Sync and async agree. The async path unwraps 5. Blast radius inside this repo. The only in-repo consumer is 6. Release hygiene. Non-blocking notesA. Two checked-in scope statements are now stale — worth fixing before merge. B. An inline comment contradicts the javadoc directly above it. C. A behaviour change shipping as a minor bump. 2.4.0 → 2.5.0 for a change that alters retry behaviour for every consumer on a default config. It is additive at the API level, the changelog leads with D. Async timeout path untested. Both new WireMock tests use E. Carried over from the last review, all still open and all still non-blocking: no debug log on the retry-because-exception branch while What I could not verify
I'm unable to submit a formal GitHub review or approval — this comment is my review feedback. More detail in the FAQ. |
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>
Fixes #113 — both halves of it. #116 was merged into this branch (
460af80), so this PR now carries the two layers that make up lib-httpx 2.5.0:config(HxConfig)silently dropped settings — the reported bug.1.
config()dropped three settingsHxClient.Builderholds anHxConfig.Builder, soconfig(HxConfig)had to hand-transcribe a built config back into a builder. That transcription listed 15 of the 18 fieldsHxConfig.Builder.build()assigns, soretryCondition,tokenRefreshTimeoutandrefreshCookiePolicyreverted to their defaults — silently, and with no delegate onHxClient.Builderfor the first two, making them unsettable on anHxClient.The root cause is the hand-written field list, not those three fields, so this replaces the transcription rather than patching it:
HxConfig.newBuilder(HxConfig)— a copy factory, backed by a privatecopyFrom()placed immediately abovebuild()so the two field lists are adjacent and drift is visible in review.config()reduces to that copy (its two proxy assignments stay:build()writes them back onto the config builder on the no-explicit-client path).shouldRetryOnException(Throwable)is now wired. It wasprotectedand documented as the retry-on-exception decision point, butsendWithRetry/sendWithRetryAsyncpassedconfig.getRetryCondition()straight toRetryable, so nothing outside tests called it. It now delegates to that condition — behaviour-identical for a default config — and overriding it takes effect.config()no longer claims the config "will be used directly" (the sentence the reporter trusted), and now namesproxy(ProxySelector)/authenticator(Authenticator)explicitly, sinceconfig()replaces those too —.proxy(x).config(cfg)discardsxwhencfgcarries no proxy.build()'s proxy paragraph records the propagation delta below.One intended behaviour change: with
config(cfg)and an explicithttpClient(...), proxy settings carried by the config now reachHxConfigand therefore the internal token-refresh clients, where previously they were dropped. The explicitHttpClientis still used verbatim.2. Request timeouts are no longer retried by default
HttpTimeoutException extends IOException, so the default 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 against the pre-change code: a request that times out is contacted 3 of 3 attempts.HxConfig.defaultRetryCondition(Throwable)— public, so callers can compose with it — now reads:A connect timeout is raised before the request is sent, so re-sending is safe and may reach a healthy endpoint — it stays retryable. A post-send timeout is ambiguous: the server may have received the request and still be working on it, so re-sending risks running a non-idempotent operation twice.
HxClient.shouldRetryOnExceptionuses the same method as itsnull-condition fallback, so the documented default and the fallback cannot drift.Opting back in is one line, and composing with the default is also supported:
How other clients default — surveyed from source
isRecoverable:e is SocketTimeoutException && !requestSendStarted)ConnectExceptionnon-retriable)InterruptedIOExceptionis in the default non-retriable list, matched withisInstancejava.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 adopts the first, the narrower change; the budget option is filed as #115. Note the exclusion here is narrower than Apache's — scoped to the
HttpTimeoutExceptionthatjava.net.httpactually raises, soSocketTimeoutExceptionstays retryable.Consumer impact
Surveyed every consumer available locally —
platform,nextflow,sched,wave:HxClient.Builder.config(HxConfig)— its only callers are this repo's own tests. So layer 1 is behaviour-neutral for all of them; where it isn't (ffq-java-sdk), the change is the fix.retryConditionortokenRefreshTimeout— theretryConditionmatches in platform/nextflow are onRetryableandThrottlingExecutor. So all of them do inherit the new timeout default: nf-tower, nf-wave, the registry and plugin-repo clients, sched-client and platform-client stop burning attempts on timeouts. That is the intent.refreshCookiePolicycaller uses theHxClient.Builderdelegate (sched-client,sched-app,platform-client, nf-wave, nf-tower), a path neither layer touches.wavehas no lib-httpx dependency at all.Tests
HxConfigRoundTripTest(new) — reflective: every declaredHxConfigfield survivesnewBuilder(HxConfig)and bothHxClient.Builder.build()paths, for a JWT and a basic-auth fixture. A coverage test asserts each field is moved off its default by some fixture, so a field added later fails until it is covered rather than silently repeating HxClient.Builder.config() silently drops retryCondition, tokenRefreshTimeout and refreshCookiePolicy #113. Verified by temporarily droppingretryConditionfromcopyFrom()(7 failures) and by adding a dummy field (the coverage test failed naming it). Also pins that.proxy(x).config(cfg)discardsx.HxConfigTest— awhere-driven test overdefaultRetryCondition: 10 cases coveringIOException,ConnectException,SocketTimeoutException,FileNotFoundException,HttpConnectTimeoutException(retried) andHttpTimeoutExceptionplus four non-I/O throwables (not retried), each also asserting the wired config agrees with the static rule. Plus the opt-back-in and composition patterns.HxClientRetryIntegrationTest— WireMock:retryConditionrejecting everything throughconfig()contacts the upstream once; a subclass overridingshouldRetryOnExceptioncontacts it once; a request timeout under the default contacts it once; with the opt-in, 3 times. Each fails against the corresponding pre-change code, and no existing test does../gradlew :lib-httpx:build :lib-cloudinfo:testgreen.Release
VERSION2.4.0 → 2.5.0, one changelog section covering both layers led byBEHAVIOUR CHANGE, README dependency snippet bumped, and a "Which failures are retried" section documenting the default, theHttpTimeoutException extends IOExceptiontrap, the precedent, and both escape hatches.[release]marker now has to be on this PR. It previously lived on #116 so that the two stacked PRs would publish exactly once; #116 is merged into this branch, so this PR's merge commit is the only remaining trigger. Without the marker in the merge commit message, 2.5.0 is not published.Not included: #115 — retries are not gated on idempotency (a
POSTis retried like aGET, and 429/500 responses are retried for any method), and there is no overall retry budget.Review
Both layers were reviewed by
claude[bot](approved). All actionable notes applied: theconfig()proxy/authenticator javadoc scope, four stale "Retry on IOException" javadoc bullets, the README example that would have silently reverted the new default,defaultRetryConditionmade public to match what the changelog and doc links promise, and the over-broad Apache precedent scoped. Declined with reasons: restoring a debug log inside the decision predicate, importingObjectsagainst the file's own existing idiom, a pre-existing raw-Predicatecast unchanged by this PR, and adding builder delegates that were explicitly out of scope.Design doc:
docs/superpowers/specs/2026-08-11-hxclient-config-lossless-copy-design.md.🤖 Generated with Claude Code