Skip to content

Add a per-request retry-on-exception hook to HxClient (lib-httpx 2.6.0) [release] - #117

Merged
pditommaso merged 3 commits into
masterfrom
feat/per-request-retry-condition
Aug 12, 2026
Merged

Add a per-request retry-on-exception hook to HxClient (lib-httpx 2.6.0) [release]#117
pditommaso merged 3 commits into
masterfrom
feat/per-request-retry-condition

Conversation

@pditommaso

@pditommaso pditommaso commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Why

HxConfig.retryCondition is a Predicate<Throwable>, so it answers the same way for every request. That is not enough for a caller with non-idempotent endpoints: a retry is safe when the request is idempotent or when it provably never reached the server, and only the first of those is visible in the request.

A bare HttpTimeoutException is the case that forces the distinction — it means the response never arrived, not that the request never did, so the same exception warrants opposite answers for a GET and a POST. A Predicate<Throwable> cannot express that on its own.

What

  • Add HxClient.shouldRetryOnException(HttpRequest, Throwable) — the hook the retry policy now consults on both the sync and async send paths.
  • The existing shouldRetryOnException(Throwable) becomes its default implementation, so current overrides and any configured HxConfig.retryCondition keep working unchanged.
  • Document the pattern in the README, including how the response side already covers this through HttpResponse.request().

Compatibility

No API break. The new hook delegates to the old one by default, and a search across platform, wave, nextflow and sched found no consumer overriding shouldRetryOnException(Throwable), so the added indirection is invisible to existing callers.

Note that the 2-arg default calling into the overridable 1-arg method is load-bearing, not incidental: it is the mechanism by which a legacy override still takes effect. Restructuring it to bypass the 1-arg method via a private helper was tried and rejected — it makes should honour a shouldRetryOnException override from a subclass fail, because the retry sites call the 2-arg form. The flip side is that an override of the 1-arg method must not delegate back to the 2-arg form, which would recurse; that is now called out in the 1-arg javadoc.

Release

Bumps lib-httpx to 2.6.0VERSION, changelog.txt and the README dependency coordinate are updated together.

Test plan

./gradlew :lib-httpx:test passes — 22 specs in HxClientRetryIntegrationTest. Four are new:

  1. Timed-out GET is re-sent — a subclass override retries a bare HttpTimeoutException that the default condition gives up on: 3 attempts where the default yields 1.
  2. Reset POST is not re-sent — the same subclass refuses a CONNECTION_RESET_BY_PEER that the default condition would retry: 1 attempt where the default yields 3.
  3. Async path reaches the hook — the same refusal holds through sendAsync, confirming both Retryable construction sites were rewired.
  4. Composition with a configured retryCondition — a subclass that vetoes POSTs but defers to super for everything else, on top of a retryCondition that retries request timeouts. The GET half proves super reaches the configured condition (3 attempts; the default condition alone would stop at 1), the POST half proves the per-request veto still wins.

Backward-compatible delegation of an existing 1-arg override is covered by the pre-existing should honour a shouldRetryOnException override from a subclass spec, which is unchanged and still passes.

🤖 Generated with Claude Code

pditommaso and others added 2 commits August 12, 2026 12:28
retryCondition is a Predicate<Throwable>, so it answers identically for every
request. That is not enough for a caller with non-idempotent endpoints: a retry
is safe when the request is idempotent OR when it provably never reached the
server, and only the first is visible in the request. A bare
HttpTimeoutException forces the distinction — it says the response never
arrived, not that the request never did — so the same exception warrants
opposite answers for a GET and a POST.

Add shouldRetryOnException(HttpRequest, Throwable) and make it the hook the
retry policy consults, on both the sync and async paths. The policy is already
built per call inside sendWithRetry(request, ...), so the request is in scope
and no thread-local plumbing is needed.

The existing shouldRetryOnException(Throwable) becomes its default
implementation, so current overrides and any configured retryCondition keep
working unchanged.

No new hook for the response side: shouldRetryOnResponse(HttpResponse) can
already reach the request via HttpResponse.request(), which is what lets a
subclass also vary the retryable status codes by method — relevant for a 502,
returned after the request was forwarded, versus a 503, returned when there was
nothing to forward it to.

Three tests, each verified to fail when the hook is unwired: a timed-out GET
re-sent 3 times where the default condition stops at 1, a reset POST delivered
once where the default makes 3 attempts, and the same on the async path built
through the wrap-a-built-client recipe the README documents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Paolo Di Tommaso <paolo.ditommaso@gmail.com>
- README: bump the dependency coordinate to 2.6.0, so the snippet matches
  the released VERSION
- README: note that the wrap recipe does not carry over a custom
  tokenStore, and point at the three-arg constructor that does
- HxClient: clarify in the @PARAM that the hook sees the pre-auth request,
  so a subclass keys off method and URI rather than headers

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@seqeralabs seqeralabs deleted a comment from coderabbitai Bot Aug 12, 2026
@pditommaso

Copy link
Copy Markdown
Contributor Author

@claude check and review this PR

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @pditommaso's task in 2m 13s —— View job


Review: per-request retry-on-exception hook

  • Gather context (read diff + surrounding code)
  • Review HxClient retry hook changes
  • Review tests, README, version/changelog
  • Post review

Verdict: looks good — no correctness, security or compatibility problem found. The change is additive and narrowly scoped: one new overload, both retry sites rewired to it, everything else untouched.


What I checked

Both retry paths are covered. Retryable is constructed in exactly two places in the module — sendWithRetry (HxClient.java:484) and sendWithRetryAsync (HxClient.java:557) — and both now pass t -> shouldRetryOnException(request, t). No third path was missed.

No shared-state hazard. The Retryable is built per call, so the captured request is per-invocation; concurrent sends on the same client can't cross-contaminate.

Backward compatibility holds. The default implementation delegates to shouldRetryOnException(Throwable) (HxClient.java:653), which still routes through config.getRetryCondition(). The pre-existing NeverRetryClient spec (HxClientRetryIntegrationTest.groovy:595) exercises exactly that delegation path and is unchanged. The only in-repo consumers of the old method are HxClientTest.groovy:42-45, which call the 1-arg form directly.

Overload resolution is clean. The old this::shouldRetryOnException method reference is gone, replaced by an explicit lambda — so the newly added 2-arg overload can't make the reference ambiguous. Arity differs anyway, but removing the method ref is the right defensive move.

Release hygiene is consistent. VERSION2.6.0, changelog.txt has a matching entry, and the README dependency coordinate is bumped in the same commit — matching the repo's release convention. lib-cloudinfo is the only in-repo dependent and needs no change since nothing is removed.

Docs. The javadoc and README both call out the one real footgun: overriding the new 2-arg hook bypasses a configured retryCondition unless the subclass delegates. The README's wrapper recipe even documents the tokenStore caveat with the 3-arg constructor — good catch, that's the kind of thing that usually bites someone later.


Non-blocking notes

  1. The PR description's test plan doesn't match the tests. It says the three specs cover "the per-request decision, the default delegation, and the interaction with a configured retryCondition." What's actually added is: timed-out GET retried, reset POST not retried, and the async path. Default delegation is covered by the pre-existing spec, and no new spec asserts the new hook composing with a configured retryCondition. Worth either fixing the description or adding the third case — a subclass whose 2-arg override calls super on top of a custom retryCondition would pin down the composition that the javadoc promises.

  2. Recursion footgun, docs-only. A subclass that overrides shouldRetryOnException(Throwable) by delegating to shouldRetryOnException(request, throwable) will recurse infinitely, since the default 2-arg impl calls straight back into the 1-arg. Unlikely, but a one-line "do not delegate the other way" in the 1-arg javadoc would close it.

  3. The hook sees the pre-auth request. Correctly documented as such, and the right call — but it also means the 2-arg hook and shouldRetryOnResponse's HttpResponse.request() (which the README points at as the response-side equivalent) observe different request objects: the latter sees the actual sent request, post-Authorization and post-redirect. Not a bug, just an asymmetry a reader following the README from one paragraph to the next might not expect.

  4. Timing-based spec. 'should let a subclass re-send a timed-out GET…' leans on a 150 ms request timeout against a 1000 ms fixed delay. That's the established pattern in this file, so it's consistent — flagging only as the usual CI-flake surface.


Not verified

I could not run ./gradlew :lib-httpx:test in this environment (the Gradle invocation wasn't permitted), so the "tests pass" claim rests on the PR author's run and CI, not on my own execution. I reviewed the new specs for compile-correctness instead — imports for Fault, ExecutionException, HttpTimeoutException and Duration are all already present at the top of the file, getHttpClient()/getConfig() are public (HxClient.java:732,736), and both HxClient constructors used are protected, which a subclass can reach via super(...) from any package.

I'm not able to submit a formal GitHub approval — this action posts review feedback as a comment only. Treat the above as an approving review; see the FAQ for the formal-review workaround.
• branch feat/per-request-retry-condition

- Add a spec pinning down the composition the javadoc promises: a subclass
  whose 2-arg override calls super on top of a configured retryCondition.
  The GET half proves super reaches the configured condition (3 attempts,
  where the default condition would stop at 1); the POST half proves the
  per-request veto still wins.
- HxClient: warn on the 1-arg hook that an override must not delegate to
  the 2-arg form, since the 2-arg default calls back into this one and
  would recurse without end.
- README: note that the exception hook sees the request as built while
  HttpResponse.request() is the request as sent, so the two request-aware
  hooks are not exact mirror images.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@seqeralabs seqeralabs deleted a comment from coderabbitai Bot Aug 12, 2026
@pditommaso
pditommaso merged commit 8713918 into master Aug 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant