feat: Back off big segment synchronization on a rejected SDK key - #857
Conversation
The big segment synchronizer retried every failure on a flat ten second interval, with no backoff, no jitter and no attempt counter. A service that rejects every request therefore saw each Relay Proxy instance return roughly six times a minute, indefinitely, and a whole fleet scaled that load in step. Add internal/retry, which implements the backoff behavior LaunchDarkly's RETRY specification defines for long-running components, and use it in the synchronizer. A transient failure keeps short delays that grow from ten to thirty seconds. A failure that is unlikely to correct itself soon, such as a rejected SDK key or a certificate problem, moves to delays that grow from five minutes to one hour. Both curves subtract jitter, because a server that closes many connections at once would otherwise make every instance retry in the same instant. The synchronizer still never stops. An operator can make a key valid again without Relay knowing, so it keeps trying and returns to the short delays after sixty seconds of marking the store as synchronized. This removes isHTTPErrorRecoverable and the branch that called it. That branch intended to stop the synchronizer on a rejected key and never ran: poll and connectStream return a *httpStatusError while the branch tested for a value-typed httpStatusError, so the type assertion never matched. The code has been unreachable since it was added in 7.0.0 and no test covered it, so deleting it changes no observed behavior. Its intent is now met by the extended delays, which bound the load without needing a restart to recover. Also fix a defer inside the supervisor's loop, which accumulated timer stops for the life of the goroutine. Operators should expect big segment data to report as potentially stale during an extended backoff. That reporting already exists and is driven by staleness rather than by the cause, so a long wait stays visible.
kinyoklion
left a comment
There was a problem hiding this comment.
🤖 Review written by Claude (AI assistant)
Disclaimer: these comments were generated by Claude on behalf of the reviewer. They propose additional test coverage for behavior this PR already implements correctly. None of them claims to have found a defect in the code; the "request changes" status is only so the suggestions get a look before merge.
Method. On a copy of the PR head (41a668f) I deleted individual lines of the new retry wiring one at a time and ran ./internal/retry/... and ./internal/bigsegments/.... Three deletions leave both packages green, and one existing test fails as a package timeout rather than an assertion if the behavior it pins regresses. Each inline suggestion addresses one of those.
| Suggestion | File | Covers |
|---|---|---|
fastRetryStrategy(options ...retry.Option) + fastRetryConfig() |
sync_test.go |
prerequisite: no-jitter / injected clock for exact waits |
| Drain the recorder while waiting | sync_test.go, TestSyncKeepsRetryingAfterUnauthorized |
fails in 1 s instead of hanging on regression |
TestSyncReturnsToNormalDelaysAfterHealthyPeriod, TestSyncBacksOffExponentiallyAcrossStreamEnds |
sync_test.go |
OnHealthy() in setSynced; nil-branch OnFailure(retry.Normal) |
TestResetRestoresTheNormalCeiling |
retry_test.go |
maxDelay lowered on reset |
Verified with all four applied to the PR head: gofmt -l and go vet clean; both packages pass under -race; the new and modified tests pass 10× under -race; each new test fails on its corresponding deletion and passes on the PR as written. Apply the first suggestion before the third; the others are independent.
| // fastRetryStrategy scales this component's retry binding down so a test does not wait out | ||
| // real delays. The proportions are kept so the curve still doubles and clamps. | ||
| func fastRetryStrategy() *retry.Strategy { | ||
| return retry.NewStrategy(retry.Config{ | ||
| InitialDelay: time.Millisecond, | ||
| NormalCeiling: 3 * time.Millisecond, | ||
| ExtendedInitialDelay: 30 * time.Millisecond, | ||
| ExtendedCeiling: 360 * time.Millisecond, | ||
| ResetThreshold: 6 * time.Millisecond, | ||
| }) | ||
| } |
There was a problem hiding this comment.
Written by Claude (AI assistant) for additional test coverage; not a claimed defect in the code.
Prerequisite for the tests suggested at the end of this file: let fastRetryStrategy accept retry.Options so a test can remove jitter (to assert an exact Will retry in duration) or inject a clock, and expose the config so a test can read ResetThreshold. Existing call sites are unchanged.
| // fastRetryStrategy scales this component's retry binding down so a test does not wait out | |
| // real delays. The proportions are kept so the curve still doubles and clamps. | |
| func fastRetryStrategy() *retry.Strategy { | |
| return retry.NewStrategy(retry.Config{ | |
| InitialDelay: time.Millisecond, | |
| NormalCeiling: 3 * time.Millisecond, | |
| ExtendedInitialDelay: 30 * time.Millisecond, | |
| ExtendedCeiling: 360 * time.Millisecond, | |
| ResetThreshold: 6 * time.Millisecond, | |
| }) | |
| } | |
| // fastRetryStrategy scales this component's retry binding down so a test does not wait out | |
| // real delays. The proportions are kept so the curve still doubles and clamps. Options let a | |
| // test remove jitter or supply a clock when it needs to assert an exact wait. | |
| func fastRetryStrategy(options ...retry.Option) *retry.Strategy { | |
| return retry.NewStrategy(fastRetryConfig(), options...) | |
| } | |
| // fastRetryConfig is the scaled-down binding behind fastRetryStrategy. | |
| func fastRetryConfig() retry.Config { | |
| return retry.Config{ | |
| InitialDelay: time.Millisecond, | |
| NormalCeiling: 3 * time.Millisecond, | |
| ExtendedInitialDelay: 30 * time.Millisecond, | |
| ExtendedCeiling: 360 * time.Millisecond, | |
| ResetThreshold: 6 * time.Millisecond, | |
| } | |
| } |
| // Three attempts show it did not give up after the first rejection. | ||
| for i := 1; i <= 3; i++ { | ||
| helpers.RequireValue(t, requestsCh, time.Second, "expected poll attempt %d", i) | ||
| } | ||
|
|
||
| require.Eventually(t, func() bool { | ||
| return hasLogMessage(mockLog, ldlog.Info, "engaging extended backoff") | ||
| }, time.Second, 10*time.Millisecond, "expected the extended delays to be engaged") |
There was a problem hiding this comment.
Written by Claude (AI assistant) for additional test coverage; not a claimed defect in the code.
This is about how the test fails, not what it asserts. If the behavior it pins ever regressed (the synchronizer staying on the normal 1–3 ms delays), it would issue several hundred polls during the one-second Eventually. RecordingHandler's channel has 100 slots, so the handler blocks, the request never completes, and WithServer's deferred Close waits on it: the package then fails at the 10-minute timeout instead of here. I reproduced that by temporarily reintroducing the value-typed errors.As target: the test as written timed out; with the loop below it failed in 1.0 s with the intended message. On the PR as written the log line is already present after the first 401, so the loop body does not run.
| // Three attempts show it did not give up after the first rejection. | |
| for i := 1; i <= 3; i++ { | |
| helpers.RequireValue(t, requestsCh, time.Second, "expected poll attempt %d", i) | |
| } | |
| require.Eventually(t, func() bool { | |
| return hasLogMessage(mockLog, ldlog.Info, "engaging extended backoff") | |
| }, time.Second, 10*time.Millisecond, "expected the extended delays to be engaged") | |
| // Three attempts show it did not give up after the first rejection. | |
| for i := 1; i <= 3; i++ { | |
| helpers.RequireValue(t, requestsCh, time.Second, "expected poll attempt %d", i) | |
| } | |
| // Keep reading the recorder while waiting for the log line. If a regression left the | |
| // synchronizer on the normal delays it would poll every few milliseconds; an unread | |
| // recorder channel (100 slots) then fills, the handler blocks, and the server's Close | |
| // hangs the package for the full test timeout instead of failing here in one second. | |
| deadline := time.After(time.Second) | |
| for !hasLogMessage(mockLog, ldlog.Info, "engaging extended backoff") { | |
| select { | |
| case <-requestsCh: | |
| case <-deadline: | |
| require.FailNow(t, "expected the extended delays to be engaged") | |
| } | |
| } |
| assert.True(t, hasLogMessage(mockLog, ldlog.Warn, "Synchronization failed")) | ||
| }) | ||
| }) | ||
| } |
There was a problem hiding this comment.
Written by Claude (AI assistant) for additional test coverage; not a claimed defect in the code.
Two integration tests for the parts of the retry wiring that the current tests observe only through log substrings:
- the reset path —
setSynced→OnHealthyreturning the synchronizer to the normal delays after a recovered 401; - the nil-return path — a stream end counting as a normal failure, so consecutive drops double the wait and clamp at the normal ceiling.
Neither currently has a test that fails if the corresponding call in sync.go is removed (I checked by deleting each and running both packages: all green). Both tests pass on this PR (10× under -race) and fail on those deletions with Will retry in 60ms instead of 1ms, and 1ms, 1ms, 1ms instead of 1ms, 2ms, 3ms. Requires the fastRetryStrategy(options...) change suggested above; the helpers are included so the block is self-contained (sync and strings are already imported).
| } | |
| } | |
| // zeroJitter removes jitter so a test can assert an exact "Will retry in" duration. | |
| func zeroJitter() retry.Option { | |
| return retry.WithJitter(func(time.Duration) time.Duration { return 0 }) | |
| } | |
| // steppingClock advances by a fixed step on every read. The supervisor goroutine reads it | |
| // through the retry strategy while the test goroutine drives the servers, so consecutive | |
| // health marks land exactly one step apart regardless of scheduling. | |
| type steppingClock struct { | |
| mu sync.Mutex | |
| t time.Time | |
| step time.Duration | |
| } | |
| func (c *steppingClock) now() time.Time { | |
| c.mu.Lock() | |
| defer c.mu.Unlock() | |
| r := c.t | |
| c.t = c.t.Add(c.step) | |
| return r | |
| } | |
| // retryLines returns the "Will retry in ..." warnings in the order they were logged. | |
| func retryLines(mockLog *ldlogtest.MockLog) []string { | |
| var out []string | |
| for _, line := range mockLog.GetOutput(ldlog.Warn) { | |
| if strings.Contains(line, "Will retry in") { | |
| out = append(out, line) | |
| } | |
| } | |
| return out | |
| } | |
| // drainUpdates consumes the updates channel so notifySegmentsUpdated never blocks the | |
| // supervisor while a test is only interested in the retry timing. | |
| func drainUpdates(ch <-chan UpdatesSummary) { | |
| go func() { | |
| for range ch { | |
| } | |
| }() | |
| } | |
| func TestSyncReturnsToNormalDelaysAfterHealthyPeriod(t *testing.T) { | |
| // After a rejected key is made valid again, ResetThreshold of continuous healthy operation | |
| // (setSynced succeeding) must return the synchronizer to the normal delays. This pins the | |
| // OnHealthy call in setSynced: without it the extended ceiling would persist for the life | |
| // of the process. | |
| mockLog := ldlogtest.NewMockLog() | |
| mockLog.Loggers.SetMinLevel(ldlog.Debug) | |
| defer mockLog.DumpIfTestFailed(t) | |
| patch1 := newPatchBuilder("segment.g1", "1", "").build() | |
| pollHandler, requestsCh := httphelpers.RecordingHandler( | |
| httphelpers.SequentialHandler( | |
| httphelpers.HandlerWithStatus(401), // poll 1: rejected key, extended regime engaged | |
| httphelpers.HandlerWithJSONResponse([]bigSegmentPatch{}, nil), // poll 2: key valid again | |
| httphelpers.HandlerWithJSONResponse([]bigSegmentPatch{}, nil), // poll 3: with the stream open; setSynced follows | |
| ), | |
| ) | |
| sseHandler, sseControl := httphelpers.SSEHandler(nil) | |
| streamHandler, streamRequestsCh := httphelpers.RecordingHandler(sseHandler) | |
| // Each read of the clock advances it by one ResetThreshold, so the second health mark is | |
| // exactly ResetThreshold after the first. | |
| clock := &steppingClock{t: time.Now(), step: fastRetryConfig().ResetThreshold} | |
| httphelpers.WithServer(pollHandler, func(pollServer *httptest.Server) { | |
| httphelpers.WithServer(streamHandler, func(streamServer *httptest.Server) { | |
| storeMock := newBigSegmentStoreMock() | |
| defer storeMock.Close() | |
| segmentSync := newDefaultBigSegmentSynchronizer(sharedtest.MakeBasicHTTPConfig(), storeMock, | |
| pollServer.URL, streamServer.URL, config.EnvironmentID("env-xyz"), testSDKKey, mockLog.Loggers, "") | |
| segmentSync.retryStrategy = fastRetryStrategy(zeroJitter(), retry.WithClock(clock.now)) | |
| defer segmentSync.Close() | |
| segmentSync.Start() | |
| drainUpdates(segmentSync.SegmentUpdatesCh()) | |
| helpers.RequireValue(t, requestsCh, time.Second, "expected poll attempt 1 (401)") | |
| helpers.RequireValue(t, requestsCh, time.Second, "expected poll attempt 2") | |
| helpers.RequireValue(t, requestsCh, time.Second, "expected poll attempt 3") | |
| helpers.RequireValue(t, streamRequestsCh, time.Second, "expected the stream request") | |
| helpers.RequireValue(t, storeMock.syncTimeCh, time.Second, "expected the first health mark") | |
| // A stream event marks the store again, one ResetThreshold later on the stepping clock. | |
| sseControl.Enqueue(*makePatchEvent(patch1)) | |
| requirePatch(t, storeMock, patch1) | |
| helpers.RequireValue(t, storeMock.syncTimeCh, time.Second, "expected the second health mark") | |
| // The upstream now drops the healthy stream: an ordinary failure on the normal curve. | |
| sseControl.EndAll() | |
| require.Eventually(t, func() bool { return len(retryLines(mockLog)) >= 2 }, | |
| time.Second, time.Millisecond) | |
| lines := retryLines(mockLog) | |
| assert.Equal(t, "BigSegmentSynchronizer: Will retry in 30ms", lines[0], | |
| "the first wait is the extended base delay") | |
| assert.Equal(t, "BigSegmentSynchronizer: Will retry in 1ms", lines[1], | |
| "after ResetThreshold of health the next wait is the normal initial delay") | |
| }) | |
| }) | |
| } | |
| func TestSyncBacksOffExponentiallyAcrossStreamEnds(t *testing.T) { | |
| // A stream that ends is reported to the supervisor as a nil error. It must still count as | |
| // a normal failure, so consecutive stream ends double the wait and clamp at the normal | |
| // ceiling rather than retrying on a flat interval. | |
| mockLog := ldlogtest.NewMockLog() | |
| mockLog.Loggers.SetMinLevel(ldlog.Debug) | |
| defer mockLog.DumpIfTestFailed(t) | |
| pollHandler := httphelpers.HandlerWithJSONResponse([]bigSegmentPatch{}, nil) | |
| sseHandler, sseControl := httphelpers.SSEHandler(nil) | |
| streamHandler, streamRequestsCh := httphelpers.RecordingHandler(sseHandler) | |
| httphelpers.WithServer(pollHandler, func(pollServer *httptest.Server) { | |
| httphelpers.WithServer(streamHandler, func(streamServer *httptest.Server) { | |
| storeMock := newBigSegmentStoreMock() | |
| defer storeMock.Close() | |
| segmentSync := newDefaultBigSegmentSynchronizer(sharedtest.MakeBasicHTTPConfig(), storeMock, | |
| pollServer.URL, streamServer.URL, config.EnvironmentID("env-xyz"), testSDKKey, mockLog.Loggers, "") | |
| // Each cycle produces exactly one health mark before the stream ends, and a failure | |
| // ends the healthy period, so no reset can interfere with the doubling. | |
| segmentSync.retryStrategy = fastRetryStrategy(zeroJitter()) | |
| defer segmentSync.Close() | |
| segmentSync.Start() | |
| drainUpdates(segmentSync.SegmentUpdatesCh()) | |
| for i := 1; i <= 3; i++ { | |
| helpers.RequireValue(t, streamRequestsCh, time.Second, "expected stream connection %d", i) | |
| helpers.RequireValue(t, storeMock.syncTimeCh, time.Second, "expected the store to be marked synchronized") | |
| sseControl.EndAll() | |
| } | |
| require.Eventually(t, func() bool { return len(retryLines(mockLog)) >= 3 }, | |
| time.Second, time.Millisecond) | |
| assert.Equal(t, []string{ | |
| "BigSegmentSynchronizer: Will retry in 1ms", | |
| "BigSegmentSynchronizer: Will retry in 2ms", | |
| "BigSegmentSynchronizer: Will retry in 3ms", // clamped at NormalCeiling | |
| }, retryLines(mockLog)[:3]) | |
| }) | |
| }) | |
| } |
| // if something ever does. | ||
| s := NewStrategy(testConfig(), noJitter()) | ||
| assert.Equal(t, time.Second, s.NextWait()) | ||
| } |
There was a problem hiding this comment.
Written by Claude (AI assistant) for additional test coverage; not a claimed defect in the code.
TestHealthyOperationResetsOnlyAfterTheThreshold checks that a reset restores the base delay, but nothing checks that it also lowers maxDelay: removing s.maxDelay = s.cfg.NormalCeiling from OnHealthy leaves both packages green. This pins the post-reset ceiling (fails with 32s instead of 4s on that deletion; passes on the PR as written).
| } | |
| } | |
| func TestResetRestoresTheNormalCeiling(t *testing.T) { | |
| // A reset must lower maxDelay back to the normal ceiling, not only restore the base delay. | |
| // Otherwise the normal curve after a recovery would keep doubling up to the extended | |
| // ceiling instead of clamping at NormalCeiling. | |
| clock := &fakeClock{t: time.Now()} | |
| s := NewStrategy(testConfig(), noJitter(), WithClock(clock.now)) | |
| require.True(t, s.OnFailure(Unexpected)) | |
| s.OnHealthy() | |
| clock.advance(30 * time.Second) | |
| s.OnHealthy() | |
| require.False(t, s.InExtendedRegime()) | |
| for range 6 { | |
| s.OnFailure(Normal) | |
| } | |
| assert.Equal(t, 4*time.Second, s.NextWait(), "after a reset the normal ceiling clamps the curve again") | |
| } |
| // signal that the synchronizer is healthy. Enough of these in a row returns the retry | ||
| // state to the normal delays. A successful HTTP response alone is not enough, because | ||
| // the store is what readers depend on. | ||
| s.retryStrategy.OnHealthy() |
There was a problem hiding this comment.
Written by Claude (AI assistant); coverage note only, not a claimed defect in the code.
The call on this line is correct. Noting only that no current test fails if it is removed (both packages stay green). TestSyncReturnsToNormalDelaysAfterHealthyPeriod, suggested in sync_test.go, covers it.
| // sync returns a nil error when the stream ends, or when an out-of-order patch | ||
| // makes it restart. The connection is gone either way, so this is an ordinary | ||
| // transient failure and gets the normal delays. | ||
| s.retryStrategy.OnFailure(retry.Normal) |
There was a problem hiding this comment.
Written by Claude (AI assistant); coverage note only, not a claimed defect in the code.
Same coverage note: this branch is right, but deleting it leaves both packages' tests green, because TestSyncRetryIfStreamFails matches Will retry in \S+ and never observes a second wait. TestSyncBacksOffExponentiallyAcrossStreamEnds, suggested in sync_test.go, pins the doubling.
kinyoklion
left a comment
There was a problem hiding this comment.
The suggested edits were done as requested changes, but I approve, and leave them if you are interested in additional test coverage.
A review pointed out that three lines could be deleted with both packages staying green. Each deletion is confirmed here, and each is now covered by exactly one test. The reset in OnHealthy lowered maxDelay as well as the base delay, but only the base delay was checked, so the normal curve after a recovery could have kept doubling to the extended ceiling. The OnHealthy call in setSynced had no coverage at all, so nothing verified that marking the store synchronized is what returns the synchronizer to the normal delays. The nil error from a stream that ends counted as an ordinary failure, but the existing test matched the delay with a pattern and never observed a second wait, so the doubling went unchecked. The 401 test now drains the request recorder while it waits for a log line. If the extended delays ever stopped engaging, the synchronizer would poll every few milliseconds, fill the recorder channel, block its handler, and hang the package until the test timeout rather than failing in one second. Two departures from the suggested tests. The clock is advanced by the test rather than on every read, so an assertion does not depend on how often the implementation reads the clock. The delays are parsed back out of the log lines and compared as durations, which keeps the assertions about time rather than about log formatting.
41a668f to
f7fce0b
Compare
🤖 I have created a release *beep* *boop* --- ## [8.22.0](v8.21.0...v8.22.0) (2026-09-16) ### Features * Adopt go-server-sdk v7.17.0 so an upstream 401 retries ([#856](#856)) ([e152af7](e152af7)) * Back off big segment synchronization on a rejected SDK key ([#857](#857)) ([6ede85b](6ede85b)) * Classify transport failures as normal, including certificate failures ([#873](#873)) ([c3b6850](c3b6850)) * Keep retrying a rejected auto-configuration key ([#866](#866)) ([4bf3bb9](4bf3bb9)) * Report the auto-configuration stream's health in the status resource ([#870](#870)) ([5c1e3ee](5c1e3ee)) ### Bug Fixes * **autoconfig:** refresh stored environment defaults on update (SEC-9484) ([#842](#842)) ([e04e3e2](e04e3e2)) * **deps:** bump golang.org/x/crypto to v0.55.0 for CVE-2026-56854 ([#848](#848)) ([360d624](360d624)) * **deps:** bump golang.org/x/crypto to v0.56.0 for CVE-2026-78662 and CVE-2026-56855 ([#854](#854)) ([6d5d346](6d5d346)) * **deps:** bump supported Go versions to 1.27.0 and 1.26.7 ([#837](#837)) ([9b4fb46](9b4fb46)) * **deps:** bump supported Go versions to 1.27.1 and 1.26.8 ([#852](#852)) ([4b06ed6](4b06ed6)) * emit Vary: Origin on CORS responses (SEC-9501) ([#844](#844)) ([78a8f05](78a8f05)) * Report an incomplete DynamoDB auto-config cache write ([#874](#874)) ([0964dc0](0964dc0)) * **security:** redact all credential-bearing URL components in status dbServer ([#846](#846)) ([20effc5](20effc5)) * **streams:** treat a nil replay result as no event instead of panicking ([#845](#845)) ([7d7eace](7d7eace)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Summary
The big segment synchronizer retried every failure on a flat ten-second interval, with no backoff, no jitter, and no attempt counter. A service rejecting every request therefore saw each Relay Proxy instance return roughly six times a minute, indefinitely, and a fleet scaled that load in step. That is the retry storm LaunchDarkly's RETRY specification exists to prevent, and RETRY lists
relay-proxyin its applies-to.Adds
internal/retry, which implements the RETRY backoff algorithm for long-running components, and uses it in the synchronizer:400,408,429, any5xx, ordinary network errors) keeps short delays, growing 10s -> 20s -> 30s and clamping at 30s.401,403, other4xx, TLS or certificate validation) moves to delays growing 5m -> 10m -> ... -> 1h and clamping at 1h.401and a503does not fall back to the fast curve.The synchronizer still never stops. An operator can make a rejected key valid again without Relay knowing, so it keeps trying and returns to the short delays after 60 seconds of continuously marking the store as synchronized. That signal is deliberately stronger than "the last request returned 200" -- marking the store is what this component exists to do, and it is what readers depend on.
internal/retryis shared on purpose: the auto-config stream (SDK-3061) and event forwarding (SDK-3067) need the same classification, and each will bind its own timings.Please look closely at the deleted branch
This removes
isHTTPErrorRecoverableand the branch that called it. That branch has never executed.pollandconnectStreamreturn&httpStatusError{...}, while the supervisor tested for a value-typedhttpStatusError:I verified the assertion misses at runtime rather than only by reading, and confirmed the mismatch is present in the revision that introduced the code in 7.0.0, so there is no release in which it worked. No test covered it. Deleting it therefore changes no observed behavior -- big segment sync already retried a
401forever, just at a punishing cadence.Its intent was sound: the comment said such a status "should not make us permanently stop sending requests," and stopping was one way to avoid hammering an invalid key. The extended curve is a better expression of the same intent, since it bounds the load without needing a process restart to recover. So this is not discarding a feature; it is implementing one for the first time.
The
v9line carries the identical defect and will need the same fix.Other fixes carried along
syncSupervisorcalleddefer timer.Stop()inside itsforloop, so deferred stops accumulated for the life of the goroutine. The permanent-stop path also returned without closingsegmentUpdatesChan, which only the shutdown path does; removing the path makes that moot.Operator-visible change
Big segment data will report as
potentiallyStaleduring an extended backoff, and a deployment runningbigSegmentsStaleAsDegradedwill see Relay report degraded during an auth outage. That reporting already exists, is driven byLastSynchronizedOnagainstbigSegmentsStaleThreshold(default 5 minutes) rather than by the cause, and so keeps a long wait visible. I believe reporting degraded is correct here, but it is a behavior change and belongs in the release notes.Not addressed
Relay's auth gate keying on the SDK client's construction error, the
/statuspayload omitting the HTTP status code, and the two remaining components with pre-RETRY handling are tracked separately under the same epic.Note
Overview
Adds
internal/retry, a shared RETRY-spec backoff helper (exponential delays, jitter, normal vs extended curves, health-based reset), and wires it into the big segment synchronizer instead of a fixed ~10s retry.Transient failures (5xx, most network issues, 400/408/429) back off on a short curve (10s → 30s cap). Unexpected failures (e.g. 401/403, TLS cert errors) switch to a long curve (5m → 1h cap) while still retrying forever so a fixed SDK key can recover without restart. Successful
setSyncedmarks drive return to normal delays after 60s of health. Logs now include “Will retry in …” with classified failure levels.Removes
isHTTPErrorRecoverableand the supervisor branch that was meant to stop on unrecoverable 4xx but never ran (*httpStatusErrorvs value-type assert). Fixesdefer timer.Stop()accumulating in the retry loop.New unit and integration tests cover classification, backoff curves, 401 vs 503, reset after recovery, and stream-end doubling.
Reviewed by Cursor Bugbot for commit f7fce0b. Bugbot is set up for automated code reviews on this repo. Configure here.