Skip to content

feat!: report every SSE error response with status, headers, and recoverability#311

Merged
kinyoklion merged 2 commits into
mainfrom
rlamb/sdk-2189/sse-fallback-directive
Jun 25, 2026
Merged

feat!: report every SSE error response with status, headers, and recoverability#311
kinyoklion merged 2 commits into
mainfrom
rlamb/sdk-2189/sse-fallback-directive

Conversation

@kinyoklion

@kinyoklion kinyoklion commented Jun 24, 2026

Copy link
Copy Markdown
Member

⚠ Breaking change

The SSE client now reports recoverable error responses (e.g. 5xx) on the stream. Previously only unrecoverable responses surfaced and recoverable ones were retried silently. A consumer that treats any error from the stream as terminal will now tear down on a transient error — it must check SseHttpError.recoverable and ignore recoverable errors (the client retries those itself). UnrecoverableStatusError is removed; use SseHttpError instead.

This bumps event_source to a major (2.2.0 → 3.0.0).

What

Replaces UnrecoverableStatusError with SseHttpError, reported on the event stream for any non-200 response — recoverable or not. It carries:

  • statusCode
  • headers (always — may hold a service directive)
  • recoverable — whether the client will retry on its own (backoff) or has stopped
final class SseHttpError implements Exception {
  final int statusCode;
  final Map<String, String> headers;
  final bool recoverable;
  const SseHttpError(this.statusCode, this.headers, {required this.recoverable});
}

Why

A LaunchDarkly streaming endpoint can deliver the FDv2-to-FDv1 fallback directive (x-ld-fd-fallback) in the headers of an otherwise-retriable error response (e.g. a 500). Previously recoverable responses surfaced nothing, so that directive was invisible and the client just kept reconnecting. Now every error response is reported with its headers and a recoverable flag, and the consumer decides what to do. This keeps the client a pure transport — it reports what it saw rather than taking an injected retry policy. The browser EventSource cannot observe responses and reports nothing, as before.

Tests

state_connecting covers a recoverable error (backs off, reports recoverable: true with headers) and an unrecoverable one (goes idle, reports recoverable: false with headers).


First of a two-PR stack; the fallback behavior that consumes this is in the stacked follow-up.


Note

High Risk
Breaking public API and stream semantics: recoverable HTTP failures now emit errors, so existing consumers may tear down connections incorrectly unless they check SseHttpError.recoverable.

Overview
Breaking: Replaces UnrecoverableStatusError with SseHttpError (statusCode, headers, recoverable). The public export and all call sites move to the new type.

Non-200 HTTP responses are now always surfaced on the event stream when the transport can read them. Recoverable statuses (unchanged retry set via ErrorUtils.isHttpStatusCodeRecoverable) still backoff and reconnect, but the client now calls eventSink.addError with SseHttpError(recoverable: true) so consumers can read headers (e.g. x-ld-fd-fallback) on transient failures. Unrecoverable responses still transition to idle and report recoverable: false.

Stream subscribers that treat any error as fatal must ignore errors where recoverable is true. Tests assert both paths include directive headers.

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

@kinyoklion kinyoklion force-pushed the rlamb/sdk-2189/sse-fallback-directive branch from 306fef8 to 2343975 Compare June 24, 2026 16:12
@kinyoklion kinyoklion changed the title feat: allow vetoing SSE retry of a directive-bearing error response feat: report every SSE error response with status, headers, and recoverability Jun 24, 2026
@kinyoklion kinyoklion changed the title feat: report every SSE error response with status, headers, and recoverability feat!: report every SSE error response with status, headers, and recoverability Jun 24, 2026
@kinyoklion kinyoklion marked this pull request as ready for review June 24, 2026 16:17
@kinyoklion kinyoklion requested a review from a team as a code owner June 24, 2026 16:17
@kinyoklion kinyoklion force-pushed the rlamb/sdk-2189/sse-fallback-directive branch from 2343975 to 6bcf11f Compare June 24, 2026 16:18
@kinyoklion kinyoklion force-pushed the rlamb/sdk-2189/sse-fallback-directive branch from 6bcf11f to 6e64d3e Compare June 25, 2026 16:57
Base automatically changed from rlamb/sdk-2186/fdv2-data-system to main June 25, 2026 21:13
…verability

Replace UnrecoverableStatusError with SseHttpError, reported on the event
stream for any non-200 response -- recoverable or not. It carries the
status code, the response headers (which may hold a service directive),
and a recoverable flag indicating whether the client will retry on its own
(backoff) or has stopped.

BREAKING CHANGE: The SSE client now reports recoverable error responses
(e.g. 5xx) on the stream; previously only unrecoverable responses surfaced
and recoverable ones were retried silently. A consumer that treats any
error from the stream as terminal will now tear down on a transient error.
Such consumers must check SseHttpError.recoverable and ignore recoverable
errors -- the client retries those on its own. UnrecoverableStatusError is
removed; use SseHttpError (statusCode, headers, recoverable) instead.
@kinyoklion kinyoklion force-pushed the rlamb/sdk-2189/sse-fallback-directive branch from 6e64d3e to 21aa063 Compare June 25, 2026 21:23
@kinyoklion kinyoklion merged commit 0707b60 into main Jun 25, 2026
4 of 6 checks passed
@kinyoklion kinyoklion deleted the rlamb/sdk-2189/sse-fallback-directive branch June 25, 2026 21:25
kinyoklion added a commit that referenced this pull request Jun 25, 2026
#312)

Stacked on #311 (the `SseHttpError` surfacing this depends on).

## What

Honors the FDv1 fallback directive across every way the server can
deliver it:

- **Successful connection:** the directive is emitted with the basis
change set, so the streamed payload is applied *before* the SDK falls
back — previously the basis was dropped the moment the header was seen.
- **Any error response carrying the header** (`SseHttpError`,
recoverable or not): the streaming source closes the connection — which
stops the client's own retry — and routes to the fallback tier.
- **`goodbye` event with `protocolFallbackTTL`:** treated as an in-band
fallback directive, for transports that cannot read response headers.

A single helper parses the directive (presence + TTL) from response
headers, used for both the successful and error paths; the goodbye path
reads its TTL in-band.

The streaming source maps `SseHttpError` by its `recoverable` flag:
recoverable → interrupted (the client retries), unrecoverable →
terminal. The FDv1 streaming source ignores recoverable errors so a
transient 5xx no longer shuts it down.

When a fallback tier is configured the orchestrator engages it. When
none is configured, the SDK stays interrupted and retries FDv2 after the
directive's TTL (default 1 hour; a TTL of `0` means remain paused with
no retry) rather than halting or reconnecting immediately. Source
results carry the fallback TTL.

## Tests

- Orchestrator: apply-then-engage from a directive-bearing change set,
and the three no-fallback cases (finite TTL retries, absent TTL defers,
zero TTL pauses).
- `streaming_base`: defer-on-success, the TTL header, the goodbye/TTL
directive, and the `SseHttpError` paths (recoverable → interrupted/stays
open, unrecoverable → terminal/closes, directive → terminal+TTL
regardless of recoverability). `protocol_handler` covers
`protocolFallbackTTL` parsing.
- v3 contract harness `fdv1-fallback` suite passes end-to-end (816
total, 792 ran, exit 0), with no regression.

The contract-test-service capability + `fdv1Fallback` config wiring that
exercises this in the harness lives on the e2e branch and will land with
the v3 contract-tests PR.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes core FDv2 connection lifecycle, tier selection, and flag
delivery ordering when servers send fallback signals; behavior shifts
are broad but covered by new unit/contract tests.
> 
> **Overview**
> Implements end-to-end handling when the server asks the SDK to fall
back from FDv2 to FDv1, including **TTL-aware retry** when no FDv1 tier
is configured.
> 
> **Directive parsing and propagation:** Adds shared
`readFallbackDirective` for `x-ld-fd-fallback` / `x-ld-fd-fallback-ttl`,
threads `fdv1FallbackTtl` through `FDv2SourceResult`, and parses in-band
`protocolFallbackTTL` on goodbye events.
> 
> **Streaming / polling behavior:** On a **successful** stream open, the
directive is **deferred** and emitted with the next change set so the
basis payload is applied before fallback (replacing immediate terminal
error on connect). **HTTP errors** with the header, and **goodbye** with
TTL or a pending header, surface as terminal fallback results so the
orchestrator does not recycle past the directive. Polling mirrors
goodbye/header TTL stamping. FDv2 streaming maps `SseHttpError` by
recoverability; legacy FDv1 streaming **ignores recoverable** SSE HTTP
errors so transient 5xx no longer shuts the source down.
> 
> **Orchestrator:** Classifies directives into engage FDv1 tier, defer
retry (no tier: wait TTL—default 1h, zero = pause indefinitely), or
none; schedules recycle via `_pendingRetryDelay` and interruptible
`_delay` on stop.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
74e7c4b. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
kinyoklion pushed a commit that referenced this pull request Jun 25, 2026
🤖 I have created a release *beep* *boop*
---


##
[3.0.0](launchdarkly_event_source_client-v2.2.0...launchdarkly_event_source_client-v3.0.0)
(2026-06-25)


### ⚠ BREAKING CHANGES

* report every SSE error response with status, headers, and
recoverability
([#311](#311))

### Features

* report every SSE error response with status, headers, and
recoverability
([#311](#311))
([0707b60](0707b60))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> This PR only updates version, changelog, and Release Please manifest;
runtime risk depends on the already-merged #311 change, not these files.
> 
> **Overview**
> Release Please bumps **`launchdarkly_event_source_client`** from
**2.2.0** to **3.0.0** and records the semver-major release in the
monorepo manifest and package changelog.
> 
> The **3.0.0** entry documents the breaking behavior already landed in
[#311](#311):
**every SSE HTTP error response** is now surfaced on the event stream
with **status**, **headers**, and whether the failure is **recoverable**
(retry vs stop). Consumers that only handled success events or assumed
errors were silent must update their stream error handling; the major
version signals that contract change even though this PR’s diff is
mostly versioning and release notes.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
f467397. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
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