Skip to content

Commit dd7b0cd

Browse files
authored
feat: server SDK RETRY-spec conformance in FDv1 streaming and polling (#200)
## Summary Implements RETRY-spec conformance in the Java server SDK's FDv1 streaming and polling data sources ([SDK-2789](https://launchdarkly.atlassian.net/browse/SDK-2789)). This PR is scoped to the server SDK only; the classifier helpers it consumes ship separately in #204. **Behavioral change.** HTTP responses that today cause an FDv1 data source to permanently stop (notably 401 / 403 / other 4xx) and TLS / certificate validation failures are no longer terminal. Streaming enters an extended-regime backoff (5 min initial → 1 hr max, doubling); polling continues at its configured cadence but engages the extended-regime wait after an UNEXPECTED failure. Either regime returns to normal after 60 s of continuous healthy operation (streaming) or two consecutive successful polls (polling). **Scope.** FDv1 streaming and polling under `lib/sdk/server/`. FDv2, event delivery, and other network callers are unchanged. ## What changed - **`PollingStrategy`** — New state-machine encapsulation with `onFailure(class)` / `onSuccess()` / `nextWait()`. State: `n` (formula input), `initialDelay`, `maxDelay`, `priorPollWasSuccessful`. Wait floor is `max(pollInterval, T − J)`; two consecutive successes reset from extended → normal. - **`PollingProcessor`** — Rewired to a self-driven loop that consults `strategy.nextWait()` between attempts. The `State.OFF` permanent-stop path is removed; the state stays `INITIALIZING` or `INTERRUPTED` with a `lastError`. - **`StreamProcessor`** — Consumes okhttp-eventsource's new multi-strategy retry API from [launchdarkly/okhttp-eventsource#110](launchdarkly/okhttp-eventsource#110). On `UNEXPECTED` classification it calls `activateRetryDelayStrategy` on the underlying `EventSource` to switch into the extended-regime `RetryDelayStrategy`. The library's built-in healthy-op reset returns the SDK to normal-regime timing after 60 s of continuous connectivity. - **`DataSourceStatusProvider` docs** — `State.INITIALIZING`, `State.OFF`, `State.INTERRUPTED`, and `getStateSince` OFF-case Javadocs updated to reflect the new semantics (no HTTP-error → OFF transition). Aligned with the Go server SDK's parallel doc adjustments. - **`LDClient` constructor Javadoc** — Wording tightened so a "wrong SDK key" scenario is described as ongoing retry in the background, not as an "unsuccessful initialization" that reads as terminal. - **Contract-test service** — Declares `retry-conformance-fdv1-streaming` and `retry-conformance-fdv1-polling` capabilities. The classifier this SDK depends on — `FailureClass` and `HttpErrors.classify*` helpers — ships in #204. ## Testing - **Unit tests** — Full suite green. New coverage: `PollingStrategyTest` (strategy state machine), plus extended-regime timing-observation tests in `StreamProcessorTest`. Existing 401 / 403 tests were rewritten to assert extended-regime retry rather than permanent stop. - **Contract tests via [sdk-test-harness#404](launchdarkly/sdk-test-harness#404 — All 7 RETRY-conformance test cases pass end-to-end at production timing (5-minute extended-initial delay). Total wall clock ~12 min via parallel shards. ## Test plan for reviewers - [x] Verify `PollingStrategy` transition semantics: normal → extended fires exactly once per `UNEXPECTED` failure; two consecutive successful polls fully reset (`n = 0`, delay bounds back to normal, `inExtended` cleared so a subsequent `UNEXPECTED` re-triggers the transition). - [ ] Verify `StreamProcessor.handleError` ordering: classifier → regime switch → `updateStatus(INTERRUPTED, …)`, unconditionally returning true so the eventsource keeps retrying. - [x] Review the `DataSourceStatusProvider` Javadoc changes for accuracy vs. the current state machine. - [x] Code comments deliberately describe *current behavior* only — no spec section refs, no historical framing ("previously", "no longer"), no cross-SDK references. Confirm you'd expect a reader to find that acceptable. ## Dependencies (why CI is red) Two unreleased upstream artifacts: - **`launchdarkly-java-sdk-internal ≥ 1.11.0`** — Must be released before this PR can build against Maven Central. Ships via #204 + its release-please chore. - **`okhttp-eventsource ≥ 5.0.0`** — Must be released before this PR can build against Maven Central. Ships via [launchdarkly/okhttp-eventsource#110](launchdarkly/okhttp-eventsource#110). Once both are released, bump their versions in `lib/sdk/server/build.gradle` and CI will go green. Locally, the branch builds against `mavenLocal()` snapshots of both. [SDK-2789]: https://launchdarkly.atlassian.net/browse/SDK-2789?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > **FDv1 data sources no longer treat HTTP auth and other “unexpected” failures as terminal.** Streaming and polling keep retrying in the background with classified backoff instead of moving to `State.OFF` (e.g. 401/403). > > **Polling** switches from fixed-rate scheduling to a self-driven loop backed by new **`PollingStrategy`**: normal cadence at `pollInterval`, extended exponential backoff (default 5 min → 1 hr cap, jitter) after `FailureClass.UNEXPECTED`, reset after two consecutive successes. > > **Streaming** adopts **okhttp-eventsource 5.x** multi-regime `RetryDelayStrategy`: normal reconnect caps at 30s; on unexpected failures the SDK activates an extended strategy (5 min → 1 hr) and relies on a 60s healthy-connection threshold to return to normal timing. > > **Public docs and tests** align with the new model: `DataSourceStatusProvider` Javadoc and `LDClient` init wording no longer describe HTTP errors as permanent shutdown; contract-test service advertises retry-conformance capabilities; unit/e2e tests assert continued retry and extended-regime behavior. Dependencies bump **`launchdarkly-java-sdk-internal`** and **`okhttp-eventsource`** for `HttpErrors` classification and strategy APIs. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 50513bc. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> BEGIN_COMMIT_OVERRIDE feat: conform FDv1 streaming and polling data sources to the RETRY specification HTTP responses that previously caused a data source to permanently stop (notably 401, 403, and other 4xx), and TLS certificate validation failures no longer terminate it. Streaming enters an extended backoff regime starting at 5 minutes and doubling to a 1 hour ceiling; polling continues at its configured interval but engages the extended regime after an unexpected failure. Streaming returns to normal backoff after 60 seconds of continuous healthy operation; polling returns to its normal cadence after two consecutive successful polls. Two consequences are visible to applications. DataSourceStatusProvider.State.OFF is now reached only by explicit shutdown, not by an HTTP error, so applications monitoring for OFF to detect an invalid SDK key will no longer see it. And because an invalid SDK key no longer short circuits initialization, the LDClient constructor waits out the full startWait timeout rather than returning as soon as the 401 arrives. END_COMMIT_OVERRIDE
1 parent 717908b commit dd7b0cd

13 files changed

Lines changed: 922 additions & 170 deletions

File tree

lib/sdk/server/build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,10 @@ ext.versions = [
7171
"guava": "32.0.1-jre",
7272
"jackson": "2.11.2",
7373
"launchdarklyJavaSdkCommon": "2.3.0",
74-
"launchdarklyJavaSdkInternal": "1.10.0",
74+
"launchdarklyJavaSdkInternal": "1.11.1",
7575
"launchdarklyLogging": "1.1.0",
7676
"okhttp": "4.12.0", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource
77-
"okhttpEventsource": "4.2.0",
77+
"okhttpEventsource": "5.0.0",
7878
"reactorCore":"3.3.22.RELEASE",
7979
"slf4j": "1.7.36",
8080
"snakeyaml": "2.4",

lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ public class TestService {
4444
"server-side-polling",
4545
"polling-gzip",
4646
"fdv1-fallback",
47-
"instance-id"
47+
"instance-id",
48+
"retry-conformance-fdv1-streaming",
49+
"retry-conformance-fdv1-polling"
4850
};
4951

5052
static final Gson gson = new GsonBuilder().serializeNulls().create();

lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@ public DataSource build(ClientContext context) {
146146
streamUri,
147147
payloadFilter,
148148
initialReconnectDelay,
149+
StreamProcessor.DEFAULT_EXTENDED_INITIAL_RECONNECT_DELAY,
150+
StreamProcessor.DEFAULT_EXTENDED_STREAM_MAX_RETRY_DELAY,
151+
StreamProcessor.DEFAULT_RETRY_RESET_INTERVAL,
149152
logger);
150153
}
151154

@@ -196,6 +199,7 @@ public DataSource build(ClientContext context) {
196199
context.getDataSourceUpdateSink(),
197200
ClientContextImpl.get(context).sharedExecutor,
198201
pollInterval,
202+
PollingProcessor.DEFAULT_EXTENDED_INITIAL_DELAY,
199203
logger);
200204
}
201205

lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/LDClient.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ private static DataModel.Segment getSegment(DataStore store, String key) {
158158
* constructor will not throw an exception for any error condition that could only be
159159
* detected after making a request to LaunchDarkly (such as an SDK key that is simply
160160
* wrong despite being valid ASCII, so it is invalid but not illegal); those are logged
161-
* and treated as an unsuccessful initialization, as described above.
161+
* and the SDK will keep retrying in the background as described above.
162162
*
163163
* @param sdkKey the SDK key for your LaunchDarkly environment
164164
* @param config a client configuration object

lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java

Lines changed: 62 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import com.google.common.annotations.VisibleForTesting;
44
import com.launchdarkly.logging.LDLogger;
5+
import com.launchdarkly.sdk.internal.http.FailureClass;
6+
import com.launchdarkly.sdk.internal.http.HttpErrors;
57
import com.launchdarkly.sdk.internal.http.HttpErrors.HttpErrorException;
68
import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo;
79
import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind;
@@ -21,33 +23,38 @@
2123
import java.util.concurrent.TimeUnit;
2224
import java.util.concurrent.atomic.AtomicBoolean;
2325

24-
import static com.launchdarkly.sdk.internal.http.HttpErrors.checkIfErrorIsRecoverableAndLog;
25-
import static com.launchdarkly.sdk.internal.http.HttpErrors.httpErrorDescription;
26-
2726
final class PollingProcessor implements DataSource {
2827
private static final String ERROR_CONTEXT_MESSAGE = "on polling request";
2928
private static final String WILL_RETRY_MESSAGE = "will retry at next scheduled poll interval";
29+
static final Duration DEFAULT_EXTENDED_INITIAL_DELAY = Duration.ofMinutes(5);
3030

3131
@VisibleForTesting final FeatureRequestor requestor;
3232
private final DataSourceUpdateSink dataSourceUpdates;
3333
private final ScheduledExecutorService scheduler;
3434
@VisibleForTesting final Duration pollInterval;
35+
private final PollingStrategy strategy;
3536
private final AtomicBoolean initialized = new AtomicBoolean(false);
37+
// task tracks the currently pending poll; null when we haven't started yet
38+
// or when we've been closed.
39+
private ScheduledFuture<?> task;
40+
// isClosed is set once in close().
41+
private volatile boolean isClosed = false;
3642
private final CompletableFuture<Void> initFuture;
37-
private volatile ScheduledFuture<?> task;
3843
private final LDLogger logger;
3944

4045
PollingProcessor(
4146
FeatureRequestor requestor,
4247
DataSourceUpdateSink dataSourceUpdates,
4348
ScheduledExecutorService sharedExecutor,
4449
Duration pollInterval,
50+
Duration extendedInitialDelay,
4551
LDLogger logger
4652
) {
4753
this.requestor = requestor; // note that HTTP configuration is applied to the requestor when it is created
4854
this.dataSourceUpdates = dataSourceUpdates;
4955
this.scheduler = sharedExecutor;
5056
this.pollInterval = pollInterval;
57+
this.strategy = new PollingStrategy(pollInterval, extendedInitialDelay);
5158
this.initFuture = new CompletableFuture<>();
5259
this.logger = logger;
5360
}
@@ -59,34 +66,50 @@ public boolean isInitialized() {
5966

6067
@Override
6168
public void close() throws IOException {
62-
logger.info("Closing LaunchDarkly PollingProcessor");
63-
requestor.close();
64-
65-
// Even though the shared executor will be shut down when the LDClient is closed, it's still good
66-
// behavior to remove our polling task now - especially because we might be running in a test
67-
// environment where there isn't actually an LDClient.
6869
synchronized (this) {
70+
if (isClosed) {
71+
return;
72+
}
73+
isClosed = true;
6974
if (task != null) {
7075
task.cancel(true);
7176
task = null;
7277
}
7378
}
79+
logger.info("Closing LaunchDarkly PollingProcessor");
80+
requestor.close();
81+
dataSourceUpdates.updateStatus(State.OFF, null);
82+
initFuture.complete(null);
7483
}
7584

7685
@Override
7786
public Future<Void> start() {
78-
logger.info("Starting LaunchDarkly polling client with interval: {} milliseconds",
79-
pollInterval.toMillis());
80-
8187
synchronized (this) {
82-
if (task == null) {
83-
task = scheduler.scheduleAtFixedRate(this::poll, 0L, pollInterval.toMillis(), TimeUnit.MILLISECONDS);
88+
if (!isClosed && task == null) {
89+
logger.info("Starting LaunchDarkly polling client with interval: {} milliseconds",
90+
pollInterval.toMillis());
91+
task = scheduler.schedule(this::poll, 0L, TimeUnit.MILLISECONDS);
8492
}
8593
}
86-
8794
return initFuture;
8895
}
89-
96+
97+
private void scheduleNext(Duration delay) {
98+
synchronized (this) {
99+
if (isClosed) {
100+
return;
101+
}
102+
task = scheduler.schedule(this::poll, delay.toMillis(), TimeUnit.MILLISECONDS);
103+
}
104+
}
105+
106+
private void tryUpdateStatus(State newState, ErrorInfo newError) {
107+
if (isClosed) {
108+
return;
109+
}
110+
dataSourceUpdates.updateStatus(newState, newError);
111+
}
112+
90113
private void poll() {
91114
try {
92115
// If we already obtained data earlier, and the poll request returns a cached response, then we don't
@@ -96,40 +119,44 @@ private void poll() {
96119
FullDataSet<ItemDescriptor> allData = requestor.getAllData(!alreadyInited);
97120
if (allData == null) {
98121
// This means it was cached, and alreadyInited was true
99-
dataSourceUpdates.updateStatus(State.VALID, null);
122+
tryUpdateStatus(State.VALID, null);
100123
} else {
101124
if (dataSourceUpdates.init(allData)) {
102-
dataSourceUpdates.updateStatus(State.VALID, null);
125+
tryUpdateStatus(State.VALID, null);
103126
if (!initialized.getAndSet(true)) {
104127
logger.info("Initialized LaunchDarkly client.");
105128
initFuture.complete(null);
106129
}
107130
}
108131
}
132+
strategy.onSuccess();
109133
} catch (HttpErrorException e) {
110-
ErrorInfo errorInfo = ErrorInfo.fromHttpError(e.getStatus());
111-
boolean recoverable = checkIfErrorIsRecoverableAndLog(logger, httpErrorDescription(e.getStatus()),
112-
ERROR_CONTEXT_MESSAGE, e.getStatus(), WILL_RETRY_MESSAGE);
113-
if (recoverable) {
114-
dataSourceUpdates.updateStatus(State.INTERRUPTED, errorInfo);
115-
} else {
116-
dataSourceUpdates.updateStatus(State.OFF, errorInfo);
117-
initFuture.complete(null); // if client is initializing, make it stop waiting; has no effect if already inited
118-
if (task != null) {
119-
task.cancel(true);
120-
task = null;
121-
}
134+
FailureClass failureClass = HttpErrors.classifyAndLogHttpFailure(
135+
logger, e.getStatus(), ERROR_CONTEXT_MESSAGE, WILL_RETRY_MESSAGE);
136+
tryUpdateStatus(State.INTERRUPTED, ErrorInfo.fromHttpError(e.getStatus()));
137+
if (strategy.onFailure(failureClass)) {
138+
logger.info("Classified failure as UNEXPECTED; engaging extended backoff.");
122139
}
123140
} catch (IOException e) {
124-
checkIfErrorIsRecoverableAndLog(logger, e.toString(), ERROR_CONTEXT_MESSAGE, 0, WILL_RETRY_MESSAGE);
125-
dataSourceUpdates.updateStatus(State.INTERRUPTED, ErrorInfo.fromException(ErrorKind.NETWORK_ERROR, e));
141+
FailureClass failureClass = HttpErrors.classifyAndLogTransportFailure(
142+
logger, e, ERROR_CONTEXT_MESSAGE, WILL_RETRY_MESSAGE);
143+
tryUpdateStatus(State.INTERRUPTED, ErrorInfo.fromException(ErrorKind.NETWORK_ERROR, e));
144+
if (strategy.onFailure(failureClass)) {
145+
logger.info("Classified failure as UNEXPECTED; engaging extended backoff.");
146+
}
126147
} catch (SerializationException e) {
127148
logger.error("Polling request received malformed data: {}", e.toString());
128-
dataSourceUpdates.updateStatus(State.INTERRUPTED, ErrorInfo.fromException(ErrorKind.INVALID_DATA, e));
149+
tryUpdateStatus(State.INTERRUPTED, ErrorInfo.fromException(ErrorKind.INVALID_DATA, e));
150+
strategy.onFailure(FailureClass.NORMAL);
129151
} catch (Exception e) {
130152
logger.error("Unexpected error from polling processor: {}", e.toString());
131153
logger.debug(e.toString(), e);
132-
dataSourceUpdates.updateStatus(State.INTERRUPTED, ErrorInfo.fromException(ErrorKind.UNKNOWN, e));
154+
tryUpdateStatus(State.INTERRUPTED, ErrorInfo.fromException(ErrorKind.UNKNOWN, e));
155+
strategy.onFailure(FailureClass.NORMAL);
156+
} finally {
157+
// Regardless of poll outcome, schedule the next attempt per strategy.
158+
Duration wait = strategy.nextWait();
159+
scheduleNext(wait);
133160
}
134161
}
135162
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package com.launchdarkly.sdk.server;
2+
3+
import com.launchdarkly.sdk.internal.http.FailureClass;
4+
5+
import java.time.Duration;
6+
import java.util.Random;
7+
8+
/**
9+
* Retry-timing state machine for the polling data source. Selects a per-attempt
10+
* delay based on prior outcomes:
11+
* <ul>
12+
* <li>Normal regime: successive attempts wait {@code pollInterval}. No backoff
13+
* is applied because {@code initialDelay} and {@code maxDelay} both equal
14+
* {@code pollInterval}.</li>
15+
* <li>Extended regime: entered on an {@link FailureClass#UNEXPECTED} failure.
16+
* Waits start at {@code extendedInitialInterval} (floored at
17+
* {@code pollInterval}) and double each attempt, clamped to
18+
* {@link #EXTENDED_MAX_DELAY}.</li>
19+
* <li>Healthy-op reset: two consecutive successful polls return the strategy
20+
* to the normal regime.</li>
21+
* </ul>
22+
* <p>
23+
* The formula input {@code n} in {@code T = initialDelay * 2^(n-1)} resets to
24+
* zero whenever the delay bounds change (regime transition), so the first
25+
* attempt in the new regime uses the new initial delay directly.
26+
* <p>
27+
* All state is owned by the polling loop's own thread (currently the shared
28+
* ScheduledExecutorService in {@link PollingProcessor}). No external synchronization
29+
* is required as long as this invariant holds.
30+
*/
31+
final class PollingStrategy {
32+
static final Duration EXTENDED_MAX_DELAY = Duration.ofHours(1);
33+
34+
private final Duration normalInterval;
35+
private final Duration extendedInitialInterval;
36+
private final Random rng;
37+
38+
private int n;
39+
private boolean priorPollWasSuccessful;
40+
private boolean inExtended;
41+
private Duration initialDelay;
42+
private Duration maxDelay;
43+
44+
PollingStrategy(Duration normalInterval, Duration extendedInitialInterval) {
45+
this(normalInterval, extendedInitialInterval, new Random());
46+
}
47+
48+
// Visible for testing; deterministic seed injectable so jitter is reproducible.
49+
PollingStrategy(Duration normalInterval, Duration extendedInitialInterval, Random rng) {
50+
this.normalInterval = normalInterval;
51+
this.extendedInitialInterval = extendedInitialInterval;
52+
this.rng = rng;
53+
// Normal regime at construction: both initialDelay and maxDelay equal the
54+
// customer-configured pollInterval (there's no backoff in the normal
55+
// regime — successive normal-failure retries stay at pollInterval).
56+
this.initialDelay = normalInterval;
57+
this.maxDelay = normalInterval;
58+
}
59+
60+
/**
61+
* Advance state after a poll failure. Returns {@code true} exactly once per
62+
* transition from the normal regime into the extended regime; the caller
63+
* can use the return value to emit an operator-visible log at the moment of
64+
* transition without re-firing on every subsequent UNEXPECTED failure while
65+
* already in extended regime.
66+
* <p>
67+
* On the transition, set {@code n = 1} and swap in the extended bounds, so
68+
* the first extended wait uses {@code extendedInitialInterval} directly
69+
* (the "reset n when delays change" invariant). On any other failure,
70+
* increment n so the delay doubles.
71+
* <p>
72+
* Extended-regime bounds are floored at the customer-configured
73+
* {@code pollInterval} — the wait never drops below that.
74+
*/
75+
boolean onFailure(FailureClass failureClass) {
76+
this.priorPollWasSuccessful = false;
77+
if (failureClass == FailureClass.UNEXPECTED && !this.inExtended) {
78+
this.inExtended = true;
79+
this.n = 1;
80+
this.initialDelay = max(extendedInitialInterval, normalInterval);
81+
this.maxDelay = max(EXTENDED_MAX_DELAY, normalInterval);
82+
return true;
83+
}
84+
this.n++;
85+
return false;
86+
}
87+
88+
/**
89+
* Advance state after a poll success. After two successes in a row, n resets
90+
* to zero and delay bounds revert to the normal regime. A single success
91+
* sets a "prior succeeded" flag; any intervening failure clears it.
92+
* <p>
93+
* The reset also clears {@code inExtended} so a subsequent UNEXPECTED
94+
* failure re-transitions into the extended regime (with the transition
95+
* detected exactly once, per {@link #onFailure(FailureClass)}'s contract).
96+
*/
97+
void onSuccess() {
98+
if (this.priorPollWasSuccessful) {
99+
this.n = 0;
100+
this.inExtended = false;
101+
this.initialDelay = normalInterval;
102+
this.maxDelay = normalInterval;
103+
}
104+
this.priorPollWasSuccessful = true;
105+
}
106+
107+
/**
108+
* Compute the delay before the next poll attempt:
109+
* {@code T = initialDelay * 2^(n-1)}, clamped to {@code maxDelay}. Jitter
110+
* {@code J} is uniform in {@code [0, T/2]}. Final wait is
111+
* {@code max(pollInterval, T - J)} — the wait never drops below the
112+
* customer-configured {@code pollInterval}.
113+
*/
114+
Duration nextWait() {
115+
if (this.n <= 0) {
116+
return normalInterval;
117+
}
118+
long initialMs = initialDelay.toMillis();
119+
long maxMs = maxDelay.toMillis();
120+
double factor = Math.pow(2, this.n - 1);
121+
long tMs = (long) Math.min(initialMs * factor, (double) maxMs);
122+
long jitterMs = 0;
123+
long halfT = tMs / 2;
124+
if (halfT > 0) {
125+
jitterMs = (rng.nextLong() % halfT + halfT) % halfT;
126+
}
127+
long waitMs = tMs - jitterMs;
128+
long floorMs = normalInterval.toMillis();
129+
if (waitMs < floorMs) {
130+
waitMs = floorMs;
131+
}
132+
return Duration.ofMillis(waitMs);
133+
}
134+
135+
private static Duration max(Duration a, Duration b) {
136+
return a.compareTo(b) >= 0 ? a : b;
137+
}
138+
139+
// Accessors for observability / testing.
140+
141+
int getN() { return n; }
142+
Duration getInitialDelay() { return initialDelay; }
143+
Duration getMaxDelay() { return maxDelay; }
144+
boolean getPriorPollWasSuccessful() { return priorPollWasSuccessful; }
145+
}

0 commit comments

Comments
 (0)