Skip to content

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

Open
jsonbailey wants to merge 3 commits into
mainfrom
jb/sdk-2793/retry-conformance
Open

jsonbailey wants to merge 3 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.0 (launchdarkly/ruby-eventsource#91), which adds SSE::Errors::StreamClosedByServerError. The gemspec now 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
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. HTTP errors give INTERRUPTED.
  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.
  5. Failure logs state the real delay, e.g. HTTP error 401 (invalid SDK key) for streaming connection - will retry in 300.0s.
  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, failure classification, both reset policies, and input validation.
  • impl/data_source/stream.rb: one failure path for every error; the wait on a stop event; record_success on each successful event; reconnect_time: 0 as an Integer (a Float 0.0 becomes NaN inside the gem after about 1024 failures).
  • 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; every wait starts when the run returns.
  • impl/data_source.rb: the FDv1 update sink ignores every status after OFF, so a request still in flight at shutdown cannot report after it.
  • impl/data_system/streaming.rb (FDv2, one line): StreamClosedByServerError reports as NETWORK_ERROR, not UNKNOWN.
  • impl/util.rb: a log message helper that states the delay. The old helper stays for FDv2 and events.
  • interfaces/data_source.rb, ldclient.rb: INTERRUPTED, OFF 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 would bring back the gem's own sleep, which close cannot interrupt. LaunchDarkly does not send that field.

Testing

  • bundle exec rspec spec: 1170 examples, 0 failures. RuboCop: 189 files, no offenses. Run against the gem at 03c7b1a.
  • 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 with the gem at 03c7b1a: 4697 ran, 0 failed.
  • 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 no longer shut down on HTTP or transport failures; they follow the RETRY spec with Impl::RetryState driving exponential backoff, jitter, and separate “normal” vs “unexpected” (e.g. 401/403) delay regimes. OFF is reserved for client shutdown, while failures surface as INTERRUPTED with logs that include the actual next retry delay.

Streaming sets reconnect_time: 0 on ld-eventsource 3.0.0 and waits in the error handler on a stop event so stop can interrupt long backoffs; server-initiated closes use StreamClosedByServerError. Polling drives RepeatingTask via a callable interval from retry state instead of stopping on “unrecoverable” statuses.

RepeatingTask now reads the interval after each run (full wait after the task returns). UpdateSink ignores status updates once OFF, avoiding races from in-flight handlers at shutdown. Contract tests advertise retry-conformance-fdv1-streaming and retry-conformance-fdv1-polling.

Reviewed by Cursor Bugbot for commit e2c2237. 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
end
end

def self.http_error_message(status, context, recoverable_message)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Still used in FDv2? Consider marking deprecated.

reconnect_time: @config.initial_reconnect_delay,
# The SDK waits in the failure handlers instead. This must be an Integer: the SSE client
# multiplies it by a power of two, and a Float 0.0 becomes NaN once that power overflows.
reconnect_time: 0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The mixture of floats and ints throughout the logic is a tad concerning if we anticipate edge cases like this comment describes.

old_status = @current_status

# A poll or stream connection that was still in flight when the data source stopped must not
# report after OFF.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This comment is true, but also perhaps too specific. It doesn't have to be in flight or stopped, just that nothing else can be reported after Off.

MAX_WAIT = 1_000_000_000

# The 4xx statuses that are still normal failures. Every other 4xx is unexpected.
NORMAL_4XX_STATUSES = [400, 408, 429].freeze

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should these be defined where other http error handling is and not tied to retry?


# The longest wait a caller should pass to `Concurrent::Event#wait`, in seconds. A much longer
# wait raises `RangeError`. This is about 31 years, so the bound has no effect in practice.
MAX_WAIT = 1_000_000_000

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This feels silly. Is it just avoiding a thrown error?

end

# Integer exponentiation does not overflow, and a Float product that becomes Infinity is
# still cut down to the ceiling.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"Integer exponentiation does not overflow". What black magic is this? Quick, patent it!

This branch has not been deployed

No deployments
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