Skip to content

feat: Refuse a malformed credential payload without losing the environment - #897

Merged
keelerm84 merged 12 commits into
feat/concurrent-keys-v9from
mk/SDK-3202/malformed-payloads
Oct 6, 2026
Merged

keelerm84 merged 12 commits into
feat/concurrent-keys-v9from
mk/SDK-3202/malformed-payloads

Conversation

@keelerm84

@keelerm84 keelerm84 commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Summary

Phase 4 of forward-porting concurrent multi-key support from v8 (#817) to v9. Stacked on #896; GitHub retargets it to feat/concurrent-keys-v9 as the stack merges.

A credential payload that parses as JSON but cannot produce a usable accepted set is now refused before MessageReceiver.Upsert records its version.

The ordering is the whole point

Upsert deduplicates by version. A refused payload that had already advanced the version would make LaunchDarkly's replay of that same payload a no-op, leaving the environment serving credentials it should have replaced with no way back short of restarting the process.

TestReplayOfTheSameVersionIsAppliedAfterARefusal is the test that matters, and it is control-verified: move the validation back after the Upsert and it fails.

What happens to a refused payload

The environment keeps the credentials it already had. The stream reconnects, because the service cannot learn that relay refused the payload and would otherwise send nothing further. Well-formed environments in the same put are applied as usual.

persistPut substitutes each refused environment's last-good cached entry before writing, so a bad payload does not drop those environments from the auto-config cache, or empty it when every environment in the put was refused. If the prior snapshot cannot be read there is no safe cache to assemble, so it is left untouched rather than rewritten from partial data.

Backoff on repeated unusable events

The stream now moves to the extended retry delays after a second consecutive unusable event. This closes the second Medium from the review of #817: the malformed-payload reconnect goes through stream.Restart(), which bypasses the retry strategy, so it reconnected on the short delays indefinitely.

Escalating on the first event was the obvious implementation and it is wrong — it broke TestStreamStatusRecordsInvalidDataError, and rightly so, since one corrupt event would have cost minutes of recovery. One bad event may be a single corruption and is worth asking again at once; a second means the reconnect brought the same payload back. The counter resets on any event relay can use.

This applies to JSON-malformed events as well as credential-malformed ones, which is slightly beyond the ticket but the same defect class, and treating only one of them specially would be arbitrary.

Testing note

The last-good cache preservation is covered by a recording cache fake at the stream-manager level, rather than the Redis-backed test v8 added for it. That exercises the substitution logic without a Redis dependency, and internal/autoconfigcache already has its own store round-trip tests. Happy to add the integration-level version if it is wanted.

Also removes StreamManager.lastKnownEnvs, dead since #401 removed its last read in June 2024.

Part of SDK-3202, under epic SDK-3188.


Note

Overview
Auto-config PUT/PATCH handling now validates environment credentials with validateCredentialPayload before MessageReceiver.Upsert, so a refused payload never records a version that would make a replayed fix a no-op.

Refused credentials keep the environment’s previous keys. A wholly unusable event still restarts the SSE stream; a partial PUT applies healthy environments, stays connected, and surfaces INVALID_DATA while the stream remains VALID. persistPut writes last-good cache entries for refused envs (and applies them in-process); cache reads that fail skip the write. Relay only calls ReceivedAllEnvironments when something is actually being served (including cache recovery).

Stream status moves through markValidWithError (generation-safe), and usable events can ActivateProfile the normal retry curve after extended backoff. Removes unused lastKnownEnvs. Adds stream-manager and relay end-to-end tests for these paths.

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

@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.

Stale Bugbot comment from a previous run.

Comment thread internal/autoconfig/stream_manager.go
Comment thread internal/autoconfig/stream_manager.go Outdated
Comment on lines +877 to +881
// The ordering is the whole point. Upsert deduplicates by version, so a rejected payload that had
// already advanced the version would make LaunchDarkly's replay of that same payload a no-op: the
// environment would keep serving credentials it should have replaced, with no path back short of
// restarting the process. Validating first leaves the version where it was, so the replay that
// follows the reconnect is applied.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: this comment is hard to follow. I think we are simply stating that we want to preserve the order so we know which cred are valided last?

@keelerm84
keelerm84 force-pushed the mk/SDK-3202/malformed-payloads branch 3 times, most recently from d903d4b to be4cd5a Compare September 28, 2026 18:44

@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.

Stale Bugbot comment from a previous run.

Comment thread internal/autoconfig/stream_manager.go Outdated
Comment thread internal/autoconfig/stream_manager.go Outdated
@keelerm84
keelerm84 force-pushed the mk/SDK-3202/malformed-payloads branch from 274abb6 to 7fd2caf Compare September 29, 2026 13:51
@keelerm84
keelerm84 added this pull request to stack #900 September 29, 2026 14:13
@keelerm84
keelerm84 force-pushed the mk/SDK-3202/malformed-payloads branch from 7fd2caf to d27ad4f Compare September 29, 2026 14:17
@keelerm84
keelerm84 requested a review from joker23 September 29, 2026 15:01
@keelerm84
keelerm84 force-pushed the mk/SDK-3202/malformed-payloads branch 2 times, most recently from a967559 to 686e873 Compare September 29, 2026 16:07
@keelerm84
keelerm84 force-pushed the mk/SDK-3202/malformed-payloads branch from 686e873 to 7a25d3d Compare October 1, 2026 14:58

@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.

Stale Bugbot comment from a previous run.

Comment thread internal/autoconfig/stream_manager.go
@keelerm84
keelerm84 force-pushed the mk/SDK-3202/malformed-payloads branch from 7a25d3d to e32b69e Compare October 1, 2026 15:34

@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.

There are 2 total unresolved issues (including 1 from previous review).

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 e32b69e. Configure here.

Comment thread internal/autoconfig/stream_manager.go
Base automatically changed from mk/SDK-3199/anchor-reanchor to feat/concurrent-keys-v9 October 5, 2026 15:09
…nment

Phase 4 of forward-porting concurrent multi-key support from v8 (#817).

A credential payload that parses but cannot produce a usable accepted set is now
refused before MessageReceiver.Upsert records its version. The ordering is the
point: Upsert deduplicates by version, so a refused payload that had already
advanced the version would make LaunchDarkly's replay of it a no-op, leaving the
environment serving credentials it should have replaced with no way back short of
restarting the process.

A refused environment keeps the credentials it had. The stream reconnects,
because the service cannot learn that relay refused the payload and would
otherwise send nothing further. Well-formed environments in the same put are
applied as usual.

persistPut substitutes each refused environment's last-good cached entry, so a
bad payload does not drop those environments from the auto-config cache, or empty
it when every environment in the put was refused. If the prior snapshot cannot be
read there is no safe cache to assemble, so it is left alone.

The stream also now moves to the extended retry delays after a second consecutive
unusable event, rather than reconnecting on the short delays indefinitely. One
bad event may be a single corruption and is worth asking again at once; a second
means the reconnect brought the same payload back. This closes a finding from the
review of #817.

Also removes StreamManager.lastKnownEnvs, dead since #401 removed its last read.
…ecovers

Activating the extended retry profile was one-way. eventsource clears an
activated profile only after a stretch of healthy operation, and it measures
that stretch from a timestamp stamped as the event was delivered, so the reset
can never fire on a restart that an event itself caused. Nothing in relay
activated the default profile either, and the stream is subscribed once per
process, so the first pair of refused payloads left every later reconnect on
delays of five minutes to an hour, even after the stream had been healthy in
between. That also defeated the threshold's own intent, because the next
isolated refusal inherited the extended curve instead of reconnecting at once.

A delivered, usable event is the only proof the stream works, and it already
resets the consecutive-refusal counter. Report it and drop back to the short
curve at the same point.

Also add the tests that would have caught this. Nothing asserted the threshold,
the backoff, or the status transition, so lowering malformedBackoffThreshold to
one, raising it, or deleting the profile activation entirely all left the
package green.
A put that refused one environment restarted the whole stream, and a second
consecutive one moved every environment onto the extended delays. Because the
counter only resets on an event relay can use, and because the service replays
the same put on each new connection while the healthy environments dedupe away,
the escalation climbed without bound. One environment's data could stop
auto-configuration for every environment in the process for up to an hour.

Reconnecting only helps when the event carried nothing usable. A refused
environment comes back identical on a new connection, so for a put that applied
at least one environment the reconnect buys nothing and costs every healthy
environment its updates while the stream is down. Report how many environments
applied, and restart only when none did.

A partial refusal still records the invalid-data error, so the refusal stays
visible, but it leaves the state VALID: the connection is genuinely working.
The generation a handler reads when an event arrives exists so a success that
event would report cannot bury a connection failure recorded while it was still
being handled. The partial-refusal path wrote its status directly instead, so a
put that applied some environments while the connection was dying reported VALID
and discarded the real error. The recovery flag had the same gap: it was set
whenever an event processed, so the stream dropped back to the short retry curve
even where the generation check had correctly refused the success.

Every status decision now goes through one generation-checked call, which reports
whether it applied, and the recovery flag is that result.

Also stop reporting the configuration as complete when a put applied nothing.
Relay would declare itself fully configured while serving no environments and
answer 401 for every credential, which says the credentials are wrong when the
truth is that there is no configuration to serve; not reporting itself configured
produces a 503 instead, which is honest and which a load balancer acts on. An
empty put is left alone: it refused nothing, so it is a complete configuration of
no environments.
The stream manager refuses a credential payload before the message receiver
records its version, so the relay never sees a refused patch at all. That is
tested where the decision is made, but nothing asserted the consequence an
operator cares about: the environment stays in the status document, its existing
credentials go on working, and the unusable key the patch carried does not
authenticate.

Driving it needs the auto-config stream after start-up, which the existing
harness keeps private because most tests only need the initial configuration, so
this adds a variant that hands the stream to the test.
A reviewer read "the ordering is the whole point" as ordering between
credentials, meaning which of them is validated last, rather than the ordering
between validation and Upsert. The sentence never said which ordering it meant,
and the paragraph asked the reader to hold four things at once before naming the
consequence.

Split it into what the function does, the rule, and why the rule exists, and
name Upsert in the first sentence of the rule.
The replay was enqueued right after the refusal, while the server could still hold the old
connection. Enqueue then wrote it to that connection and it was lost, which failed the test on
Go 1.27.1 in CI. Serving it as the second connection's initial event removes the race.
The cache already kept the last known good entry for an environment whose
payload was refused, so the next process start served it. This process did
not: the refused environment is skipped before it reaches the receiver, and
a partial refusal no longer reconnects, so the service never resends it.
The Relay Proxy held a serviceable configuration for an environment it
answered nothing for.

persistPut now returns the entries it carried forward, and the put applies
them in memory as well. Recovered environments count towards the readiness
gate, because they are being served.
@keelerm84
keelerm84 force-pushed the mk/SDK-3202/malformed-payloads branch from 52f5578 to c1a730e Compare October 5, 2026 15:09
// the life of the process, and a later unrelated reconnect waits minutes.
stream.ActivateProfile(normalProfile)
}
if outcome.restart {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

There being two ifs here and no else seems like a smell. Are you guaranteed either recovered or restart is set?

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.

Not guaranteed, and that's deliberate: all four combinations occur. A reconnect event sets
restart without being malformed, so it also goes through the success path and earns the short
curve -- it is both proof the connection works and a request to reconnect, and it needs both
branches. An unusable event asks for a restart alone. A put or patch that applied asks for the
curve alone. An unrecognized event arriving while the connection is already failing asks for
neither, because the generation check declines. An else would drop one of the two actions on the
reconnect path.

You're right that the shape made the reader derive all that, though. I replaced the booleans with a
named curve value plus restart, so the two fields are the two decisions the loop makes, and the
state space is written down on the type. 69709dc.

stream.ActivateProfile(extendedProfile)
}
stream.Restart()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No case where you can get backoff without restart?

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.

There wasn't one: backOff was only ever set in the malformed arm, and every path through that arm
also set restart, so it was a modifier on a reconnect that was already happening. 69709dc
replaced it with a single curve value, and 6730864 then removed the extended curve from the data
path entirely, so there is no backOff left to get without a restart.


// gotMalformedCredentials reports a payload that parsed but whose credentials cannot produce a
// usable set. The environment keeps the credentials it already had.
gotMalformedCredentials := func(envID config.EnvironmentID, err error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I think other SDK impls are treating malformed JSON of payloads as normal errors and not engaging extended RETRY regardless of how many malformed payloads are seen. This was mostly due to precedent in existing code not treating them as unrecoverable.

Perhaps malformed credentials warrants a different handling.

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.

You're right, and I've removed it. I checked: v8 relay sets shouldRestart and never touches the
profile, and go-server-sdk's streaming data source activates the extended profile only in its HTTP
error handler. This PR was the odd one out.

My reasoning for it was also wrong. I thought repeated restarts never back off, because eventsource
stamps its health timestamp on every delivered event including a bad one. What actually happens is
that the stamp only suppresses the reset: retryCount keeps climbing, so the normal curve grows to
its 30s ceiling on its own and the reconnects were already bounded. What the extended curve added
was a window of up to an hour where the stream is disconnected and therefore cannot learn that the
payload was corrected.

6730864 drops malformedBackoffThreshold and the consecutive counter; the extended delays now
belong to the stream error handler alone.

s.handlePut(putMessage.Data)
malformedEnvIDs, applied := s.handlePut(putMessage.Data)
switch {
case len(malformedEnvIDs) == 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.

If for some reason the put contains no envs, applied will be 0 and malformed will be 0? But isn't this possibly a situation worth restarting over? I am thinking of the category of bug where the server sends no content.

Perhaps this is hard to counter because a credential with no environments is possible? (even if unlikely). Thoughts?

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.

Yes, that's the behavior: an empty put takes the len(malformedEnvIDs) == 0 arm, so no restart,
Retain deletes everything relay held, and ReceivedAllEnvironments fires because nothing was
refused.

Your instinct about why it's hard to counter is the reason I left it. An auto-config key whose
policy matches no environments is a legal configuration, and its payload is identical to the one a
"server sent no content" bug would produce, so relay has nothing to discriminate on.

Restarting also costs more than it could buy here. The service would resend the same empty put, and
because eventsource stamps its health timestamp on every delivered event, the backoff never resets:
the reconnects grow to the 30s ceiling and stay there, permanently, against a configuration that is
valid. An operator with a deliberately empty policy would see a relay that never stops reconnecting.

One thing worth flagging that your comment points at: an entry whose envId disagrees with its map
key is skipped with a continue that counts as neither applied nor refused, so a put where every
entry is mismatched behaves exactly like the empty one. That shape really is only ever a defect, but
it isn't fixable by reconnecting either, for the same reason as above.

What the empty case was missing is a way to see it, so 96ef1ae logs a warning when a put removes
every environment relay was serving, naming the count and saying it now answers 401 for every
credential.

Comment thread internal/autoconfig/stream_manager.go Outdated
// A read failure on the prior cache means there is no safe snapshot to assemble, so the cache is left
// as it is. A nil result with no error is an empty cache rather than a failure, and there is simply
// nothing to carry forward.
func (s *StreamManager) persistPut(content PutContent, malformedEnvIDs map[config.EnvironmentID]bool) {

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 a different verb than persist here unless you know for sure the destination is a persistence (maybe it is indeed and my comment is silly). apply or commit are some I can think of. Also update log below to use the same verb, it is currently using write.

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.

Not silly, but it is a persistence: the cache comes from autoconfigcache.NewStore in
relay/relay.go, so it's the Redis or DynamoDB store, and the reason for writing it is that the
next process start has something to serve before it reaches LaunchDarkly. write in the log line is
accurate for the same reason.

One wrinkle you couldn't have seen: the function now also returns the entries it carried forward so
the put can apply them in memory, so the name covers only half of what it does. The doc comment
spells the return value out, so I've left the name, but say the word if you'd rather see it split.

The event outcome carried restart, backOff and recovered. Two of the eight
combinations could not occur: backOff only ever accompanied restart, and
backOff and recovered are mutually exclusive, so the type could express a
connection that was asked for both retry curves at once.

A retryCurve value replaces backOff and recovered. The event loop reads as
the two decisions it makes: which curve the connection uses from here, and
whether to reconnect now. No behavior changes.
Repeated unusable events moved the stream to the extended delays, which
diverges from v8 and from the SDK's streaming data source: both restart on
whatever curve is current and reserve the extended delays for connection
failures.

The divergence also cost more than it bought. eventsource grows its own
delay for each restart that follows an event, up to streamMaxRetryDelay, so
the reconnects were already bounded. The extended delays added a window of
up to an hour where the stream is disconnected and so cannot learn that the
payload it refused has been corrected.

The extended delays now belong to the stream error handler alone.
A configuration with no environments in it is legal, so relay applies it.
It then serves nothing and answers 401 for every credential, which an
operator had to infer from an environment count of zero and a run of delete
lines.
@keelerm84
keelerm84 requested a review from tanderson-ld October 5, 2026 17:02
// dedupes an older cached copy away. Recording the last-good version here is correct, because it
// is what Relay is now serving, and a corrected payload carries a higher one.
recovered := 0
if content.Persist {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Are we concerned of the possibility of there having been a previously malformed payload that was saved to a persistent store that would then be loaded into this relay?

Relay versions before this one wrote the auto-configuration cache without
checking credentials, so a store an older relay filled can hold a payload
this one refuses. Carrying such an entry forward created an environment
from a payload relay had just declared unusable, and wrote the same problem
back for the next process start.

An entry that does not validate is now carried to neither destination.
@keelerm84
keelerm84 merged commit 5256fb0 into feat/concurrent-keys-v9 Oct 6, 2026
36 of 38 checks passed
@keelerm84
keelerm84 deleted the mk/SDK-3202/malformed-payloads branch October 6, 2026 14:37
keelerm84 added a commit that referenced this pull request Oct 6, 2026
…898)

## Summary

Phase 5, the last of forward-porting concurrent multi-key support from
v8 ([#817](#817)) to v9.
**Stacked on
[#897](#897; GitHub
retargets it to `feat/concurrent-keys-v9` as the stack merges.

`EnvironmentStatusRep` gains `sdkKeys` and `mobileKeys` arrays of `{
key, value, expiry? }`. Both are always present rather than omitted, so
a consumer can iterate without a nil check: `sdkKeys` always holds at
least the anchor, and `mobileKeys` is empty for a server-side-only
environment.

### The scalar fields stay

`sdkKey` and `mobileKey` remain, designating which array entry owns the
connection to LaunchDarkly and which mobile key is used where only one
can be. Asked and decided during review: they are deliberately *not*
replaced by an `anchor: true` flag on an entry. `sdkKey` predates
concurrent keys and is what consumers read to identify an environment,
v8 reports the same pair of fields so one consumer can read both
versions, and encoding the designation in two places would let them
drift. The rationale is recorded on the field.

### `expiringSdkKey` is removed

One field cannot describe an environment that accepts several keys with
different expiries. The soonest-expiring-non-anchor rule phase 3 carried
was only there to keep that field, and the metrics reading it,
deterministic while the rest of the port landed. Per-key expiry now
lives in the arrays.

### Metrics re-based

The two instruments #876 derived from `ExpiringSDKKey != ""` move onto
the arrays:

- **per-environment** becomes
`launchdarkly.relay.environment.expiring_keys`, a count of that
environment's SDK keys carrying an expiry. A bit cannot say how many,
and it no longer means "a rotation is in progress" — a key set can hold
an expiring key as a steady state.
- **relay-wide** keeps
`launchdarkly.relay.environment.expiring_key.count`, still counting
environments, now those serving at least one key with an expiry.

Descriptions reworded in `status_measures.go` and `docs/metrics.md`. The
existing metrics tests caught a name collision when the per-environment
gauge was first renamed to `.expiring_key.count`, which is why the two
now have clearly distinct names.

### The `expect` API was already ready

No grammar change was needed. `internal/api/status_query_test.go`
carried a test named "array fields the schema does not have yet", whose
comment said the path grammar supports array selectors *specifically so
they would survive the concurrent-keys change*. That test now describes
the past, so it is split: addressing `sdkKeys` at the wrong level still
returns 422, and there is new positive coverage for `sdkKeys[key=...]`
and `sdkKeys[0]` resolving against real data, with a selector that
matches nothing returning 412 rather than 422.

### Documentation

`docs/endpoints.md` documented `expiringSdkKey=...` as a 412 example,
which would now be a 422, and its three status-document examples showed
only the scalar fields. All updated, plus a short paragraph on why an
environment reports several keys and what the scalar fields designate.

Part of SDK-3203, under epic SDK-3188.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> **Breaking change to `/status`:** each environment now exposes
always-present **`sdkKeys`** and **`mobileKeys`** arrays (`key`,
obscured `value`, optional `expiry`) while **`sdkKey`** /
**`mobileKey`** still designate the anchor and primary mobile key. The
singular **`expiringSdkKey`** field is **removed**; expiring credentials
are read from the arrays.
> 
> The top-level document adds **`refusedEnvironments`** (always an
array, sorted by `envId`) for auto-config payloads Relay rejected, with
**`serving`** distinguishing “never configured” (`401`) from “update
refused but still serving prior config.” Auto-config stream handling
records and clears refusals on put/patch/delete; **`expect`** probes can
target the new arrays and `refusedEnvironments` (including the inverted
alert recipe in the docs).
> 
> **Metrics:** per-environment
**`launchdarkly.relay.environment.expiring_keys`** replaces the boolean
**`expiring_key`** with a count of SDK keys that have an expiry; the
relay-wide **`expiring_key.count`** gauge now counts environments with
at least one expiring key. Docs, integration tests, and status
builders/tests are updated accordingly.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
f93060b. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com>
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.

4 participants