feat(java): add client builder options for retry backoff schedule#16973
Conversation
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
🔵 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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
🔵 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.
There was a problem hiding this comment.
🔴 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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.
| 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); | ||
| } |
There was a problem hiding this comment.
🔍 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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.
SDK Generation Benchmark ResultsComparing PR branch against median of 5 nightly run(s) on Full benchmark table (click to expand)
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 |
Description
The Java SDK's generated
RetryInterceptorhard-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 existingmaxRetriesoption: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 constructorRetryInterceptorvalidates inputs (throwsIllegalArgumentExceptionfor negative delays or jitter outside[0, 1]) and computes exponential backoff overflow-safely (initialDelayMillis > maxDelayMillis >> retryAttemptguard instead ofinitialDelay * (1L << attempt), which could overflow with user-configurable delays)ClientOptionsGenerator:ClientOptionsgainsOptional<Long> initialRetryDelayMillis/Optional<Long> maxRetryDelayMillis/Optional<Double> retryJitterFactorfields, getters,Buildersetters,from()copying, and passes them to theRetryInterceptorinbuild()AbstractRootClientGenerator: root client builder gains the same three options, forwarded toClientOptions.BuilderinsetRetries(...); the OAuth staged builder (_Builder) also gains the options with forwarding intoken()andcredentials()generators/java/sdk/changes/unreleased/seed/java-sdksnapshotsTesting
./gradlew :sdk:compileJavaandspotlessApplypassjava-sdk— 184/184 fixtures pass (pnpm seed test --generator java-sdk --skip-scripts)Link to Devin session: https://app.devin.ai/sessions/183bf29b96d54b9085aab2ba03ae266e
Requested by: @iamnamananand996