Skip to content

feat: Retry the event push to LaunchDarkly once before giving up - #69

Merged
keelerm84 merged 6 commits into
mainfrom
mk/SDK-2822/event-push-retry
Aug 31, 2026
Merged

feat: Retry the event push to LaunchDarkly once before giving up#69
keelerm84 merged 6 commits into
mainfrom
mk/SDK-2822/event-push-retry

Conversation

@keelerm84

@keelerm84 keelerm84 commented Aug 31, 2026

Copy link
Copy Markdown
Member

Summary

The event push to LaunchDarkly got one attempt. On failure the loop logged and waited a
whole poll interval, so a dropped connection or one unhealthy node answering 503 cost a
full cycle. It now gets the same retry the other LaunchDarkly server SDKs give it: two
attempts one second apart, then give up. Mirrors go-sdk-events/event_sender.go.

Details that carry the behavior:

  • Each attempt builds its own request from bytes.NewReader(pollBytes). client.Do
    reads the body to the end, so reusing one request would send the events once and an
    empty body after that.
  • The delay is a select on the shutdown context, not a time.Sleep, so Ctrl-C does
    not hang for up to a second. Shutdown abandons the retry rather than making one more
    attempt.
  • isHTTPErrorRecoverable is a copy of the same-named function in go-sdk-events:
    among 4xx only 400, 408 and 429 are recoverable, and everything outside 4xx is, so an
    unexpected status costs a retry rather than a batch.
  • The existing hard stop on 401/403 is unchanged and is checked before classification,
    so a rejected SDK key never spends an attempt.
  • Every attempt finishes with its response through the existing drainAndClose,
    including the error path.
  • The delay lives on a Bridge field seeded from EVENT_PUSH_RETRY_DELAY, so tests can
    shorten it instead of each burning a real second.

Both failure log lines report what becomes of the batch -- will retry,
not retryable, this batch is lost, or out of attempts, this batch is lost -- so a
first failure reads differently from a final one. That replaces a separate trailing
"gave up" line, which said a second time what the failure line already said.

This does not make event delivery durable. EventREST.prepareEvents deletes the
EventData__c rows before the bridge has confirmed the push, so a batch that fails both
attempts is gone and no later cycle resends it. The retry converts many transient
failures into successes inside the cycle, which is the point, but closing the durability
gap needs a two-phase drain on the Apex side and is tracked separately.

Two deliberate divergences from the reference. A non-recoverable status does not shut the
daemon down as MustShutDown does there -- only 401/403 stop this process, so a 404 loses
the batch and the loop carries on. And success stays 200 || 202 rather than the whole
2xx range, which is pre-existing behavior left alone; worth noting that a 201 or 204 now
costs a retry before being dropped.

Each behavior was mutation-tested: a single attempt, a body hoisted out of the loop, a
time.Sleep in place of the select, classification removed, and the 401/403 stop removed
each fail only the test that covers them. Verified on Go 1.26 and on the CI-pinned Go
1.15.


Note

Overview
Event push to LaunchDarkly now gets two attempts (original plus one retry after EVENT_PUSH_RETRY_DELAY, default 1s), aligned with other LaunchDarkly server SDKs, instead of failing once and waiting a full poll interval.

Each batch gets a per-batch X-LaunchDarkly-Payload-ID reused on the retry so LaunchDarkly can dedupe when the first attempt succeeded but the response never arrived. Each attempt builds a new POST with bytes.NewReader(pollBytes) so retries do not send an empty body after client.Do consumes the first request’s body.

Retries use isHTTPErrorRecoverable (same rules as go-sdk-events): transient/network-style failures and selected 4xx (400, 408, 429) retry; other 4xx stop after one attempt. 401/403 still stop the daemon with no retry. The inter-attempt wait is a context-aware select, so shutdown during the delay exits without hanging or making another attempt.

Failure logs now include pushDisposition text (will retry, not retryable, this batch is lost, out of attempts, this batch is lost). Delivery is still not durable—Salesforce already deleted the batch when the bridge pushes—so two failed attempts still mean lost events.

Adds bridge/event_push_retry_test.go (integration-style tests through eventLoop) and sets a short eventPushRetryDelay in newTestBridge for existing tests.

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

The push in eventLoop got one attempt. Any failure -- a dropped connection, a
rate limit, one node answering 503 -- cost the batch and a whole poll interval.
It now gets the same budget the other LaunchDarkly server SDKs give it: one
attempt, a one second delay, then a final attempt.

The structure follows the event sender in go-sdk-events. Each attempt builds its
own request, because client.Do reads the body to the end and a reused request
would post nothing. Statuses are classified by isHTTPErrorRecoverable, a copy of
the function of the same name in that package, so among the 4xx statuses only
400, 408 and 429 earn the retry and everything outside 4xx does.

Two things are specific to the bridge. The hard stop on 401 or 403 is unchanged
and spends no attempt, since a rejected SDK key does not improve on a retry. The
delay waits on the bridge context as well as the clock, so a shutdown during it
stops the daemon at once and abandons the retry.

This is not delivery durability. Salesforce deletes the events as it hands them
over, so a batch that fails both attempts is lost, and the loop now logs that
plainly. The retry converts transient failures into successes inside the cycle,
which is what it is for.
newTestBridge left the new field at its zero value, so a test in main_test.go that
reached a recoverable push failure would retry with no delay and race the shutdown
select the same way a zero poll interval does. The retry tests set their own value,
so nothing today depends on this.
Both failure log lines reported the same text on a first failure and a final one, so
the log did not distinguish a transient blip from lost events. The other LaunchDarkly
SDKs annotate their event push failures with the outcome; this does the same.

pushDisposition reports one of three things: a retry follows, the status is not
retryable, or the attempts are spent. Both give-up cases name the batch as lost,
because nothing sends it again -- Salesforce deleted the events as it handed them over.

That annotation replaces the separate "gave up" line the loop logged after the fact.
Every failing path now reports its own outcome, so the trailing line was saying a
second time what the failure line already said, and the flag that drove it is gone
with it.

The attempt budget is now named, since "is a retry left" would otherwise be a second
literal 2 sitting apart from the loop bound.
@keelerm84
keelerm84 requested a review from a team as a code owner August 31, 2026 15:19

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1e1b241. Configure here.

Comment thread bridge/main.go
The retry sent the same batch again with no X-LaunchDarkly-Payload-ID, so LaunchDarkly
had no way to recognize it. That makes the retry unsafe in the one case it cannot see:
an attempt the service accepted whose response never reached the bridge. The retry
cannot tell that from an attempt nobody received, so it sends again either way, and
without the header those events are ingested twice.

One id per batch, generated before the first attempt and repeated by the retry. A fresh
id per attempt would look like a second batch and double-count exactly the same way, so
generating it outside the loop is the fix rather than an incidental detail. This matches
the reference Go SDK's event sender, which builds the header once per payload and reuses
it across both attempts.

Both halves of the property are pinned: the retry repeats its batch's id, and two batches
get different ids -- a second batch reusing the first's id would be discarded as a
duplicate. Verified against no header at all and against a fresh id per attempt.

Reported by Cursor Bugbot on the pull request.
The retry loop kept the fixed string this site used before, which discards what
http.NewRequest said about the URI. #71 replaces that same string on main, and this
branch rewrote the surrounding block, so a merge would have put the two in conflict with
the fixed string as one of the candidate resolutions.

Carrying the change here instead makes the merge order stop mattering: both sides now
agree, so nothing is left for a resolution to drop.
@keelerm84
keelerm84 requested a review from a team August 31, 2026 17:31
Comment thread bridge/main.go Outdated
return "not retryable, this batch is lost"
}

if attempt < EVENT_PUSH_ATTEMPTS-1 {

@tanderson-ld tanderson-ld Aug 31, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Consider making attempt counting start a 1 so this becomes attempt < MAX_EVENT_PUSH_ATTEMPTS.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in d35e50d, and it pairs with the rename nicely.

Attempts now count from 1, so the loop is attempt <= MAX_EVENT_PUSH_ATTEMPTS and pushDisposition's retry test is attempt < MAX_EVENT_PUSH_ATTEMPTS. Both read directly against the maximum. Previously the retry test had to be attempt < EVENT_PUSH_ATTEMPTS-1, so the two sites disagreed about what the constant meant and one carried a -1 correction -- which is what made this worth doing rather than a style preference.

Behavior is unchanged: two attempts, delay before the second only. The retry tests assert exact attempt counts, so a budget altered by the reindexing would have failed them. Green on Go 1.15 (CI) and 1.26, and under -race.

Comment thread bridge/main.go Outdated
// EVENT_PUSH_ATTEMPTS is how many times eventLoop sends one batch of events before
// it abandons them. Two means the original attempt and one retry, matching the
// other LaunchDarkly server SDKs.
EVENT_PUSH_ATTEMPTS = 2

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

MAX_EVENT_PUSH_ATTEMPTS

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Renamed to MAX_EVENT_PUSH_ATTEMPTS in d35e50d.

@tanderson-ld
tanderson-ld self-requested a review August 31, 2026 18:09
Review feedback. EVENT_PUSH_ATTEMPTS becomes MAX_EVENT_PUSH_ATTEMPTS, and attempts count
from 1 rather than 0.

The rename and the reindexing go together. With 0-based attempts, "is a retry left" had to
be written attempt < EVENT_PUSH_ATTEMPTS-1, so the loop bound and the retry test disagreed
about what the constant meant and one of them carried a correction. Counting from 1 makes
the loop attempt <= MAX_EVENT_PUSH_ATTEMPTS and the retry test attempt <
MAX_EVENT_PUSH_ATTEMPTS, both read directly against the maximum.

Behavior is unchanged: two attempts, delay before the second only. The tests assert exact
attempt counts, so a budget changed by the reindexing would fail them.
@keelerm84
keelerm84 merged commit 76f68d1 into main Aug 31, 2026
5 checks passed
@keelerm84
keelerm84 deleted the mk/SDK-2822/event-push-retry branch August 31, 2026 18:34
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