Skip to content

feat: Retry indefinitely after a data source failure instead of stopping permanently - #448

Merged
jsonbailey merged 12 commits into
mainfrom
jb/sdk-2793/retry-conformance
Oct 15, 2026
Merged

jsonbailey merged 12 commits into
mainfrom
jb/sdk-2793/retry-conformance

Conversation

@jsonbailey

@jsonbailey jsonbailey commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Note

This requires ld-eventsource 3.0.1. 3.0.0 (launchdarkly/ruby-eventsource#91) adds SSE::Errors::StreamClosedByServerError, and 3.0.1 (launchdarkly/ruby-eventsource#95) hardens the reconnect loop. The gemspec pins it exactly.

BEGIN_COMMIT_OVERRIDE
feat: Retry indefinitely after a data source failure instead of stopping permanently
fix: Warn and use the documented default for an invalid poll interval or initial reconnect delay
fix: Measure a repeating task's interval from when the task returns
fix: Log normal HTTP errors from a data source at warn instead of error
fix: Update ld-eventsource to 3.0.1
END_COMMIT_OVERRIDE

Requirements

  • I have added test coverage for new or changed functionality
  • I have followed the repository's pull request submission guidelines
  • I have validated my changes against all supported platform versions (local runs on Ruby 4.0 only; CI covers the rest once the gem is released)

Related issues

Describe the solution you've provided

The FDv1 streaming and polling data sources now follow the RETRY spec. No HTTP or transport failure stops a data source.

Each failure is normal or unexpected:

Failure Class
400, 408, 429, any 5xx normal
Any other 4xx, including 401, 403 unexpected
Network, TLS, timeout, bad content type, bad JSON, server closes the stream normal
Streaming Polling
Normal delay initial_reconnect_delay (1s), doubling to 30s poll_interval (flat)
Extended delay (after an unexpected failure) 5 min, doubling to 1 h 5 min, doubling to 1 h, never below poll_interval
Jitter subtract up to half subtract up to half
Reset 60s of healthy operation two successful polls in a row

The SDK owns the reconnect delay. The SSE client gets reconnect_time: 0, and the SDK waits inside its error handler on an event that stop sets, so stop ends even an hour-long wait at once. A server close arrives in the error handler as SSE::Errors::StreamClosedByServerError.

Behavior changes (for release notes)

  1. A bad or revoked SDK key no longer fails fast. LDClient.new and postfork wait the full wait_for_sec and return uninitialized, and the SDK keeps retrying.
  2. FDv1 reports OFF only after shutdown, and no status follows it. HTTP errors give INTERRUPTED, or keep INITIALIZING before the first success. A health check that treats OFF as "SDK key rejected" no longer fires: use initialized? and the status's last_error instead.
  3. A rejected SDK key logs at error about once an hour, not once.
  4. A server stream close logs a warning and backs off. A normal stream rotation by the server now shows as an INTERRUPTED then VALID status change.
  5. Failure logs state the real delay, e.g. HTTP error 401 (invalid SDK key) for streaming connection - will retry in 300.0s. Unexpected HTTP errors (401, 403, other 4xx) log at error. Normal ones (5xx, 429, 408, 400) now log at warn, as in the Go and Python SDKs.
  6. An invalid poll_interval or initial_reconnect_delay logs a warning and uses the default.
  7. Every repeating task (polling, big-segment status, store availability check, FDv2 timer) now waits its full interval after the run returns, not the interval minus the run time.

What changed

  • impl/retry_state.rb (new): the delay calculation, both reset policies, and input validation. It has no HTTP knowledge.
  • impl/data_source/stream.rb: one failure path for every error, including bad JSON (one failure, one INVALID_DATA status); the wait on a stop event, which also runs if logging or reporting raises; stop wakes a waiting handler first and always reports OFF; only data events count as healthy operation; reconnect_time: 0 as an Integer.
  • impl/data_source/polling.rb: the retry state drives the task interval; the stop-on-unexpected-status branch is gone.
  • impl/repeating_task.rb: the interval can be a callable, and every wait starts when the run returns. A callable that raises or returns something other than a number of zero or more is logged and waits 1s.
  • impl/util.rb: HTTP status classification, the failure log helpers, and TIMEOUT_MAX. http_error_recoverable? uses the shared classification, so FDv2 and events do not change.
  • impl/data_source.rb: the FDv1 update sink ignores every status after OFF.
  • impl/data_system/streaming.rb (FDv2, one line): StreamClosedByServerError reports as NETWORK_ERROR, not UNKNOWN.
  • interfaces/data_source.rb, ldclient.rb: INITIALIZING, INTERRUPTED, OFF, update_status and initialized? docs.
  • contract-tests/service.rb: declares retry-conformance-fdv1-streaming and retry-conformance-fdv1-polling.

Known gaps / out of scope

  • FDv2 still stops on an unexpected status, and the event processor still disables itself on one. Both are SDK-2776.
  • A server retry: field makes the gem add its own wait on top of the SDK's (RETRY §1.11 is not implemented). LaunchDarkly does not send that field.

Testing

  • bundle exec rspec spec: 1237 examples, 0 failures. RuboCop: 192 files, no offenses. Run against ld-eventsource 3.0.1, after merging main.
  • Contract tests with -enable-long-running-tests, run out of band:
    • Red, on unchanged main with the two capabilities declared: 13 failures, exactly the 9 streaming and 4 polling conformance tests.
    • Green, on this branch at 3a91fee with ld-eventsource 3.0.1: 4697 ran, 0 failed. (1386bda after it is a refactor with no behavior change.)
  • CI does not pass -enable-long-running-tests, so the conformance tests do not run in CI. Declaring the capabilities also turns off the legacy "do not retry" tests.

Describe alternatives you've considered

Retry profiles inside the gem (the Go and Java approach). Rejected: the algorithm would then exist twice, in the gem for streaming and in the SDK for polling.


Note

Overview
FDv1 streaming and polling now follow the RETRY spec: failures no longer permanently stop the data source. A new RetryState drives exponential backoff with jitter (normal vs extended regimes for unexpected 4xx like 401/403), and both paths keep reporting INTERRUPTED/INITIALIZING while retrying instead of OFF for bad SDK keys.

Streaming owns reconnect timing (reconnect_time: 0 on the SSE client, wait in handle_failure on a stop-wakable event). Polling uses a callable interval on RepeatingTask so backoff applies between polls. OFF is final in the FDv1 update sink; docs and initialized? text reflect that invalid keys mean “wait full timeout, stay uninitialized, keep retrying.”

Also bumps ld-eventsource to 3.0.1 (server stream close as StreamClosedByServerError), tightens HTTP error logging (unexpected at error, normal at warn with explicit retry delay), validates invalid poll/reconnect delays, and declares FDv1 retry contract-test capabilities.

Reviewed by Cursor Bugbot for commit 1386bda. Bugbot is set up for automated code reviews on this repo. Configure here.

…ing permanently

The FDv1 streaming and polling data sources follow the RETRY spec. No HTTP
or transport failure stops them. An unexpected status such as 401 moves
them to a longer backoff, and the SDK keeps retrying. OFF is reported only
after shutdown.
@jsonbailey
jsonbailey marked this pull request as ready for review September 28, 2026 15:00
@jsonbailey
jsonbailey requested a review from a team as a code owner September 28, 2026 15:00
Comment thread lib/ldclient-rb/impl/util.rb
Comment thread lib/ldclient-rb/impl/data_source/stream.rb
Comment thread lib/ldclient-rb/impl/data_source.rb Outdated
Comment thread lib/ldclient-rb/impl/retry_state.rb Outdated
Comment thread lib/ldclient-rb/impl/retry_state.rb Outdated
Comment thread lib/ldclient-rb/impl/retry_state.rb Outdated
tanderson-ld added a commit that referenced this pull request Oct 6, 2026
## Summary

Replaces wall-clock time with monotonic time everywhere the SDK measures
an interval, duration, or deadline. Wall clocks (`Time.now`, epoch
millis) remain only for values that leave the process or are compared
against externally supplied absolute times. Implements SDK-3104.

A wall-clock step (NTP correction, manual clock set, container/VM time
resync, the time correction when a frozen environment thaws) previously
could cause:

- **`RepeatingTask`** (polling, event flush, big-segment status polling,
store-status polling): an immediate re-run on a forward step, or up to
interval-plus-step of silence on a backward step.
- **`ExpiringCache`** (big-segments membership cache, persistent-store
read-through cache): entries served past their TTL after a backward
step; whole-cache eviction (thundering herd against Redis/DynamoDB) on a
forward step. A backward step could also strand genuinely expired
entries behind the eviction scan's early exit until capacity pressure
removed them.
- **Migration op latency:** inflated values on a forward step; silently
*dropped* measurements on a backward step (the tracker discards
negatives), skewing old/new latency comparisons.
- **FDv2 fallback/recovery verdicts** (60s/10s/300s windows): a step
inside the window could trigger or suppress synchronizer failover.
- **Stream-init diagnostics:** FDv1 sent *negative* `durationMillis`
after a backward step; FDv2 clamped negatives but still inflated forward
steps.

## Changes

- `Impl::Util.monotonic_seconds`
(`Process.clock_gettime(CLOCK_MONOTONIC, :float_second)`) is the single
monotonic seam — now the only clock read in `lib/` besides
`Util.current_time_millis`.
- `RepeatingTask` measures each run monotonically; fixed-rate semantics
unchanged.
- `ExpiringCache` stamps and evicts on an injectable monotonic clock
(`clock:` kwarg). This also makes the eviction scan's "stop at first
unexpired entry" early exit a true invariant (stamps are now
non-decreasing in insertion order).
- `Migrations::Executor` measures operation latency monotonically.
- `StatusProviderV2` keeps a monotonic twin of `state_since`, stamped
under the same write lock on state change, exposed via the atomic
`status_and_seconds_in_state` (one read-lock acquisition returns
`[status, seconds]`, so a verdict can never pair a stale state with a
fresh duration). Public `status.state_since` remains a wall-clock
`Time`. Also fixed here: a `nil` state from a custom synchronizer
previously fell into the change path and could permanently disable FDv2
fallback/recovery; it is now ignored, matching the FDv1 sink.
- FDv2 `fallback_condition` / `recovery_condition` are pure functions of
`(status, seconds_in_state)`, with a truth-table spec pinning the
60/10/300s thresholds and the strict-`>` boundary semantics.
- FDv1/FDv2 stream-init `durationMillis` is measured monotonically
(integer ms); the reported `timestamp` stays wall-clock. The monotonic
stamp is also the "attempt in flight" sentinel, so lifecycle and
measurement share one typed value.

**Deliberately unchanged** (wall clock is correct): analytics event
`creationDate`, diagnostic `creationDate`/`dataSinceDate`,
`Status#state_since`, `ErrorInfo#time`, `debugEventsUntilDate`
comparisons (server-supplied absolute dates), and big-segments staleness
— `last_up_to_date` is written by another process, so only a wall clock
is comparable across hosts; it remains sensitive to host-to-host skew,
which no local clock choice can fix.

## Behavioral notes

**Not a breaking change.** All modified classes are `Impl`-private;
additions are optional kwargs and a `@private` accessor; behavior under
a stable clock is identical. Three nuances:

1. **Suspend semantics.** Linux `CLOCK_MONOTONIC` does not advance
during system suspend, so TTLs and the FDv2 state windows now measure
*process-visible* time. We considered `CLOCK_BOOTTIME` and chose
`CLOCK_MONOTONIC` everywhere for cross-platform consistency (macOS/JRuby
lack BOOTTIME, and macOS's monotonic clock already counts sleep).
Notably, this costs nothing in AWS Lambda: a frozen execution
environment pauses the entire microVM with no guest-visible suspend, so
BOOTTIME and MONOTONIC behave identically there. The practical
consequence in warm Lambdas is that TTLs become cumulative *active* time
— persistent-store/big-segments cache entries can live longer in wall
time than the configured TTL under sporadic short invocations (the old
wall-clock behavior instead re-fetched everything at thaw, since the
thaw time-sync is a forward step). If that ever matters in practice,
dual-stamping entries (wall + monotonic, expire on whichever elapses
first) is the known escape hatch.
2. **Application test suites that time-travel.** Apps using Timecop (or
similar) to drive SDK-internal timing — e.g. jumping `Time.now` to
expire the SDK's caches — will find the SDK no longer follows the wall
clock. Timecop's opt-in `Timecop.mock_process_clock = true` with the
offset forms (`Timecop.travel(3600)`) still works, since it mocks
`Process.clock_gettime`. Our own `ExpiringCache` specs needed exactly
this rewrite.
3. **Measurement/report split.** Where a wall timestamp and a monotonic
duration appear side by side (stream inits), `timestamp +
durationMillis` will not equal the wall end time if a step occurred
mid-measurement. The duration is the trustworthy value.

One correction to the originating audit's framing: a Ruby `Time` is an
absolute epoch instant (the zone affects display only), so `Time`
subtraction was never DST-affected — Ruby's actual exposure was
NTP/manual/VM clock steps. The fixes are identical either way.

## Review

A three-agent review (general / security / adversarial) ran against
these changes: no Critical or High findings. All seven adversarial
attack vectors failed against production code — including an exhaustive
4096-sequence brute force showing the wall/monotonic stamps cannot
diverge through any `update_status` input sequence, and a 3.1M-sample
race probe of the status/duration pairing (zero violations, zero
negative durations). Each new regression test was verified to fail
against the pre-fix implementation. Review-driven hardening is included
here (atomic accessor, nil-state guard, condition truth table, spec
Timecop hygiene).

Two pre-existing issues surfaced by the review are intentionally out of
scope and will be ticketed separately: no minimum gap between
`RepeatingTask` runs when a task overruns its interval (reachable via an
unvalidated `status_poll_interval`), and interval type validation.

## Relationship to #448

The `RepeatingTask` rewrite in
#448 removes the
elapsed-time measurement entirely (fixed-delay semantics), which
supersedes this PR's `repeating_task.rb` change. The fix here is
deliberate insurance on main's current code: whichever PR lands second
should resolve the conflict in #448's favor for that one file. No other
files overlap.

## Precedent

Same change-class as the .NET fixes in
launchdarkly/dotnet-core#339 (propagated by
launchdarkly/dotnet-core#343).

## Testing

Full suite passes (1118 examples; the one failure,
`launchdarkly-server-sdk_spec.rb`'s `Bundler.require` test, is
pre-existing and environment-dependent — it shells out to a bare `ruby`
outside the bundle — and touches nothing in this change). New regression
tests simulate wall-clock steps for the scheduler, cache, migrator,
status provider, and both stream-init paths. rubocop clean on all
changed files.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> Wall-clock steps (NTP, manual set, VM resync) could skew polling
intervals, cache TTLs, migration latency, FDv2 synchronizer
failover/recovery, and stream-init diagnostics. This PR routes **all
internal interval and duration measurement** through new
`Impl::Util.monotonic_seconds`, while keeping wall time for
outward-facing timestamps like `Status#state_since`.
> 
> **`StatusProviderV2`** tracks a monotonic twin of state entry time,
exposes atomic `status_and_seconds_in_state`, and ignores `nil` state
updates. **FDv2** uses that duration for the 60s / 10s / 300s fallback
and recovery thresholds instead of `Time.now - state_since`. **FDv1 and
FDv2 streaming** compute diagnostic `durationMillis` from monotonic
stamps; reported init timestamps stay wall-clock. **`ExpiringCache`**,
**`RepeatingTask`**, and **migration `Executor` latency** also switch to
monotonic measurement (cache accepts an optional injectable `clock:` for
tests).
> 
> Regression specs cover wall-clock manipulation via Timecop, FDv2
condition boundaries, and global `Timecop.return` after each example.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
49aa78a. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
… logging

- Count a bad stream payload as one failure with one INVALID_DATA status.
- Wake a waiting handler before closing the stream on stop, and always
  report OFF, also before start or when the close raises.
- Always wait before a reconnect, also when logging or reporting raises.
- Keep a repeating task alive when its interval cannot be read.
- Move HTTP status classification to Util, and log normal HTTP errors at
  warn and unexpected ones at error.
- Count only data events as healthy stream operation.
- Document that no status is reported after OFF.
…o Util

A callable interval may return any number of zero or more. Anything else is logged at error and waits a short fallback. TIMEOUT_MAX lives in Util, next to the other shared helpers, because it bounds any event wait, not only retries.
…formance

# Conflicts:
#	lib/ldclient-rb/impl/repeating_task.rb
#	spec/impl/data_source/stream_spec.rb
#	spec/impl/data_system/streaming_synchronizer_spec.rb
#	spec/impl/repeating_task_spec.rb
@jsonbailey
jsonbailey merged commit fa29bf8 into main Oct 15, 2026
10 checks passed
@jsonbailey
jsonbailey deleted the jb/sdk-2793/retry-conformance branch October 15, 2026 14:53
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.

2 participants