Skip to content

feat(java): add client builder options for retry backoff schedule#16973

Merged
iamnamananand996 merged 5 commits into
mainfrom
devin/1783541534-java-retry-backoff-options
Jul 9, 2026
Merged

feat(java): add client builder options for retry backoff schedule#16973
iamnamananand996 merged 5 commits into
mainfrom
devin/1783541534-java-retry-backoff-options

Conversation

@iamnamananand996

@iamnamananand996 iamnamananand996 commented Jul 8, 2026

Copy link
Copy Markdown
Member

Description

The Java SDK's generated RetryInterceptor hard-codes its exponential-backoff timings (INITIAL_RETRY_DELAY = 1000ms, MAX_RETRY_DELAY = 60000ms, JITTER_FACTOR = 0.2). This PR makes the backoff schedule configurable at runtime via client-builder options, mirroring the existing maxRetries option:

Client client = Client.builder()
    .maxRetries(3)
    .initialRetryDelayMillis(500)
    .maxRetryDelayMillis(30000)
    .retryJitterFactor(0.1)
    .build();

Defaults are unchanged, and the existing RetryInterceptor(int maxRetries) constructor is kept, so this is fully backwards compatible.

Changes Made

  • RetryInterceptor.java (core template): constants become instance fields; new constructor
    RetryInterceptor(int maxRetries, Optional<Long> initialRetryDelayMillis, Optional<Long> maxRetryDelayMillis, Optional<Double> jitterFactor)
    falling back to the previous defaults when empty
  • RetryInterceptor validates inputs (throws IllegalArgumentException for negative delays or jitter outside [0, 1]) and computes exponential backoff overflow-safely (initialDelayMillis > maxDelayMillis >> retryAttempt guard instead of initialDelay * (1L << attempt), which could overflow with user-configurable delays)
  • ClientOptionsGenerator: ClientOptions gains Optional<Long> initialRetryDelayMillis / Optional<Long> maxRetryDelayMillis / Optional<Double> retryJitterFactor fields, getters, Builder setters, from() copying, and passes them to the RetryInterceptor in build()
  • AbstractRootClientGenerator: root client builder gains the same three options, forwarded to ClientOptions.Builder in setRetries(...); the OAuth staged builder (_Builder) also gains the options with forwarding in token() and credentials()
  • Changelog entry under generators/java/sdk/changes/unreleased/
  • Regenerated seed/java-sdk snapshots
  • Updated README.md generator (if applicable)

Testing

  • ./gradlew :sdk:compileJava and spotlessApply pass
  • Seed snapshots regenerated for java-sdk — 184/184 fixtures pass (pnpm seed test --generator java-sdk --skip-scripts)
  • Unit tests added/updated
  • Manual testing completed

Link to Devin session: https://app.devin.ai/sessions/183bf29b96d54b9085aab2ba03ae266e
Requested by: @iamnamananand996


Open in Devin Review

@iamnamananand996 iamnamananand996 self-assigned this Jul 8, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@nitpickybot nitpickybot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review Summary

Adds configurable retry backoff options to the Java SDK's RetryInterceptor and plumbs them through ClientOptions and the root client builder. Implementation is consistent and backwards compatible. Two concerns: potential overflow in the exponential backoff shift that the new configurable initial delay may exacerbate, and missing input validation for jitter factor / delay values. Also worth confirming the Optional import exists in the RetryInterceptor template.

  • 🟡 1 warning(s)
  • 🔵 2 suggestion(s)

// Fall back to exponential backoff, with symmetric jitter
long baseDelay = INITIAL_RETRY_DELAY.toMillis() * (1L << retryAttempt); // 2^retryAttempt
long cappedDelay = Math.min(baseDelay, MAX_RETRY_DELAY.toMillis());
long baseDelay = initialRetryDelay.toMillis() * (1L << retryAttempt); // 2^retryAttempt

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 warning

initialRetryDelay.toMillis() * (1L << retryAttempt) can overflow to a negative value with a large initialRetryDelay and/or high retryAttempt, making Math.min pick the negative product. Now that the initial delay is user-configurable, this is easier to hit. Consider capping before multiplying or using Math.multiplyExact with fallback to maxRetryDelay.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d389605 — the exponential backoff now uses an overflow-safe check (initialDelayMillis > maxDelayMillis >> retryAttempt, with a shift-width guard) instead of multiplying, so large delays/attempts cap at maxRetryDelay rather than overflowing.

this.initialRetryDelay =
initialRetryDelayMillis.map(Duration::ofMillis).orElse(DEFAULT_INITIAL_RETRY_DELAY);
this.maxRetryDelay = maxRetryDelayMillis.map(Duration::ofMillis).orElse(DEFAULT_MAX_RETRY_DELAY);
this.jitterFactor = jitterFactor.orElse(DEFAULT_JITTER_FACTOR);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion

No validation on the incoming values. The javadocs promise retryJitterFactor is "between 0 and 1" and delays are positive, but nothing enforces it — a negative jitter or maxRetryDelay < initialRetryDelay will silently misbehave. Consider validating or at least clamping.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added validation in f3b640e — the constructor now throws IllegalArgumentException for negative delays or a jitter factor outside [0, 1].

int maxRetries,
Optional<Long> initialRetryDelayMillis,
Optional<Long> maxRetryDelayMillis,
Optional<Double> jitterFactor) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion

Confirm java.util.Optional is imported in this template — the previous version used only Optional internally (in method bodies), but the new constructor signature references it too. If it wasn't imported before, generated code won't compile.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Open in Devin Review

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Retry backoff options are silently unavailable when using the OAuth staged builder pattern

The new retry backoff options are not added to the OAuth staged builder (_Builder at AbstractRootClientGenerator.java:2078-2291), so users who configure the client via Client.builder().initialRetryDelayMillis(500).token("...") cannot set these options before choosing an auth method.

Impact: Users of OAuth client credentials auth cannot customize retry backoff timing, even though the feature is advertised as available on the client builder.

Staged builder is missing fields, methods, and forwarding for the three new retry backoff options

The _Builder staged builder class is a standalone class (not extending the main builder) that acts as an intermediate stage. It already includes maxRetries as a field (AbstractRootClientGenerator.java:2093-2098), a setter method (AbstractRootClientGenerator.java:2162-2170), and forwards it in token() (AbstractRootClientGenerator.java:2230-2232) and credentials() (AbstractRootClientGenerator.java:2270-2272).

However, the three new fields (initialRetryDelayMillis, maxRetryDelayMillis, retryJitterFactor) are not added to _Builder at all — no fields, no setter methods, and no forwarding in token() or credentials(). This creates an inconsistency where maxRetries is configurable in the staged builder but the new backoff options are not.

The staged builder is used when useStagedBuilder is true, which occurs when OAuth client credentials are present, the builder is not extensible, and endpoint security is not enabled.

(Refers to lines 2093-2098)

Prompt for agents
The _Builder staged builder class in AbstractRootClientGenerator.java (around line 2078-2291) needs to be updated to include the three new retry backoff fields, matching the pattern already used for maxRetries:

1. Add three new fields to _Builder (after the maxRetries field around line 2098):
   - initialRetryDelayMillis (Optional<Long>, initialized to Optional.empty())
   - maxRetryDelayMillis (Optional<Long>, initialized to Optional.empty())
   - retryJitterFactor (Optional<Double>, initialized to Optional.empty())

2. Add three new setter methods to _Builder (after the maxRetries method around line 2170):
   - initialRetryDelayMillis(long) returning builderStageClassName
   - maxRetryDelayMillis(long) returning builderStageClassName
   - retryJitterFactor(double) returning builderStageClassName

3. In the token() method (around line 2230-2232), add forwarding for the three new fields after the maxRetries forwarding, following the same if-present pattern.

4. In the credentials() method (around line 2270-2272), add the same forwarding for the three new fields.

Follow the exact same pattern as maxRetries for all four changes.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5d6e4b6 — the OAuth staged builder (_Builder) now has initialRetryDelayMillis/maxRetryDelayMillis/retryJitterFactor fields and setters, forwarded in both token() and credentials(), following the same pattern as maxRetries.

Comment on lines +28 to 38
public RetryInterceptor(
int maxRetries,
Optional<Long> initialRetryDelayMillis,
Optional<Long> maxRetryDelayMillis,
Optional<Double> jitterFactor) {
this.maxRetries = maxRetries;
this.initialRetryDelay =
initialRetryDelayMillis.map(Duration::ofMillis).orElse(DEFAULT_INITIAL_RETRY_DELAY);
this.maxRetryDelay = maxRetryDelayMillis.map(Duration::ofMillis).orElse(DEFAULT_MAX_RETRY_DELAY);
this.jitterFactor = jitterFactor.orElse(DEFAULT_JITTER_FACTOR);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 No input validation on configurable retry backoff parameters

The RetryInterceptor constructor at generators/java/sdk/src/main/resources/RetryInterceptor.java:28-38 accepts the new parameters without any validation. Negative values for initialRetryDelayMillis or maxRetryDelayMillis would produce negative sleep durations (which Thread.sleep treats as a no-op for negative values, but Duration.ofMillis with a negative value is valid). A jitterFactor greater than 1.0 could cause addSymmetricJitter to produce negative delays (e.g., factor=3.0 gives multiplier range of -0.5 to 2.5). Similarly, the generated builder methods in ClientOptionsGenerator.java:784-822 and AbstractRootClientGenerator.java:623-650 don't validate inputs. Whether validation belongs in generated code is a design decision, but the javadoc at line 645 says "between 0 and 1" without enforcing it.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — leaving validation out for now to match the existing behavior of maxRetries/timeout, which also accept unvalidated values. Out-of-range inputs degrade gracefully (negative delays behave as zero via Thread.sleep; defaults are used when unset). Happy to add range clamping/validation in a follow-up if maintainers prefer.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-07-09T05:18:32Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
java-sdk square 218s (n=5) 272s (n=5) 206s -12s (-5.5%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-07-09T05:18:32Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-07-09 08:38 UTC

@willkendall01 willkendall01 self-requested a review July 9, 2026 14:57

@willkendall01 willkendall01 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm!

@iamnamananand996 iamnamananand996 merged commit 77fd607 into main Jul 9, 2026
79 checks passed
@iamnamananand996 iamnamananand996 deleted the devin/1783541534-java-retry-backoff-options branch July 9, 2026 14:58
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.

4 participants