Skip to content

Commit 2d2f223

Browse files
authored
feat: FDv2 streaming base, initializer, and synchronizer (#267)
**Stacked on #266** (SSE capability surface). Mark this PR ready for review only after #266 merges and the base is rebased onto main. Jira: [SDK-2185](https://launchdarkly.atlassian.net/browse/SDK-2185) ## Scope Phase B1 from the parallel plan — the streaming half of FDv2 sources, mirroring the polling vertical that landed in SDK-2183 / SDK-2184. Three new files in `packages/common_client/lib/src/data_sources/fdv2/`: ### `streaming_base.dart` — `FDv2StreamingBase` Wraps an `SSEClient` with FDv2 protocol semantics. Single-subscription `StreamController<FDv2SourceResult>`: - `onListen` → builds a fresh `FDv2ProtocolHandler`, subscribes to the SSE client's stream. - `onCancel` → tears down without emitting shutdown (subscriber-initiated). - `close()` → emits shutdown then closes; idempotent. Per event: - Named SSE events get parsed as JSON, wrapped in an `FDv2Event`, fed to the handler. - `ActionPayload` → `ChangeSetResult` with `persist: true`. - `ActionGoodbye` → goodbye `StatusResult`; closes the connection (server told us to disconnect). - `ActionServerError` / `ActionError` → interrupted `StatusResult`; SSE client's built-in backoff handles reconnect. - `ActionNone` → no emission. - Legacy `ping` events → injected `PingHandler` (one-shot poll); result is forwarded. - `x-ld-fd-fallback: true` on the `OpenEvent` → terminalError with `fdv1Fallback: true`; closes. - SSE transport error → interrupted; reconnect via SSE client. Closed-ness tracked via a single `Completer<void> _stoppedSignal` (matches the polling synchronizer pattern from SDK-2184). ### `streaming_initializer.dart` — `FDv2StreamingInitializer` Implements `Initializer`. Subscribes to the base, completes `run()` with the first emission, then closes the connection. `close()` before the first emission yields a shutdown result. ### `streaming_synchronizer.dart` — `FDv2StreamingSynchronizer` Implements `Synchronizer`. Thin adapter forwarding the base's stream and `close()`, so the orchestrator can treat polling and streaming uniformly. ## Auth wiring (deferred to orchestrator) `FDv2StreamingBase` does not build URLs or set headers. The orchestrator (SDK-2186) constructs the `SSEClient`, deciding via `sseClient.hasCapability(SSECapability.requestHeaders)` whether to set the credential in the `Authorization` header or as the `auth` URL query parameter. The streaming source consumes whatever client it's handed. ## Verification - 25 new tests across the three files; full FDv2 directory now at 185 tests. - `dart analyze lib test` clean in `common_client/`. - `melos run analyze` and `melos run test` both pass workspace-wide. ## Follow-up (out of scope) - The orchestrator (C1 / SDK-2186) is what actually wires `FDv2StreamingBase` into the SDK. This PR delivers the building blocks. [SDK-2185]: https://launchdarkly.atlassian.net/browse/SDK-2185?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > New long-lived streaming path affects how flag payloads are received and persisted; mistakes could cause stale/corrupt updates or mishandled fallback/goodbye, though behavior is heavily tested and mirrors polling patterns. > > **Overview** > Adds the **FDv2 streaming** path alongside existing polling: `FDv2StreamingBase` wires `SSEClient` to `FDv2ProtocolHandler`, emitting `ChangeSetResult` / status results on a lazy single-subscription stream with `_stoppedSignal`-based shutdown (mirroring polling). > > **Protocol & resilience:** Resets the handler on each `OpenEvent` and after parse failures so partial transfers cannot cross reconnects. Handles `x-ld-fd-fallback` (terminal FDv1 fallback), goodbye (terminal close), malformed JSON as interrupted, and **sanitized** SSE error logging (no URL/context leakage). Legacy **`ping`** events trigger an injected one-shot poll with **coalescing** (one in flight, at most one follow-up). > > `FDv2StreamingSynchronizer` is a thin `Synchronizer` wrapper. **`FDv2ProtocolHandler`** server-error logs now include payload id (or `<unknown>`) and note automatic retry; tests cover streaming lifecycle, ping coalescing, reconnect safety, and log format. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit b9f185b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 2685dc3 commit 2d2f223

6 files changed

Lines changed: 1196 additions & 2 deletions

File tree

packages/common_client/lib/src/data_sources/fdv2/protocol_handler.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,9 @@ final class FDv2ProtocolHandler {
263263
}
264264

265265
ProtocolAction _processError(ServerErrorEvent data) {
266-
_logger.info('Server error encountered receiving updates: ${data.reason}');
266+
_logger.info('An issue was encountered receiving updates for payload '
267+
"'${data.payloadId ?? '<unknown>'}' with reason: '${data.reason}'. "
268+
'Automatic retry will occur.');
267269
_resetAfterError();
268270
return ActionServerError(data.reason, id: data.payloadId);
269271
}
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
import 'dart:async';
2+
import 'dart:convert';
3+
4+
import 'package:http/http.dart' as http;
5+
import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart';
6+
import 'package:launchdarkly_event_source_client/launchdarkly_event_source_client.dart';
7+
8+
import 'flag_eval_mapper.dart';
9+
import 'protocol_handler.dart';
10+
import 'protocol_types.dart';
11+
import 'source.dart';
12+
import 'source_result.dart';
13+
14+
/// Long-lived streaming data source over SSE.
15+
///
16+
/// Wraps an [SSEClient] with FDv2 protocol semantics. Each named SSE
17+
/// event is parsed as JSON, wrapped in an [FDv2Event], and fed to a
18+
/// fresh [FDv2ProtocolHandler]. The first emitted [ProtocolAction]
19+
/// per event is translated into an [FDv2SourceResult]:
20+
///
21+
/// - [ActionPayload] --> [ChangeSetResult] with `persist: true`.
22+
/// - [ActionGoodbye] --> goodbye [StatusResult]; the SSE connection is
23+
/// closed.
24+
/// - [ActionServerError] / [ActionError] --> interrupted
25+
/// [StatusResult]; the SSE client's built-in retry handles the
26+
/// reconnect.
27+
/// - [ActionNone] --> no emission (waiting for more events).
28+
///
29+
/// Legacy `ping` events are routed to the injected [PingHandler] (which
30+
/// performs a one-shot poll) and the result is forwarded to the stream.
31+
///
32+
/// The `x-ld-fd-fallback` header on the initial connection's response
33+
/// is detected and produces a terminal-error result with
34+
/// `fdv1Fallback: true`. The connection is closed.
35+
///
36+
/// Lifecycle: a single-subscription stream. [results] starts the SSE
37+
/// connection on subscribe. [close] stops the source, emits a shutdown
38+
/// [StatusResult], and closes the stream. Both paths funnel through a
39+
/// `Completer<void> _stoppedSignal` so async callbacks short-circuit
40+
/// safely.
41+
///
42+
/// `SSEClient.restart` is intentionally not surfaced here. The
43+
/// orchestrator drives connection lifecycle by tearing down a
44+
/// streaming source and constructing a fresh one, not by reconnecting
45+
/// an existing one.
46+
final class FDv2StreamingBase {
47+
final SSEClient _sseClient;
48+
final PingHandler _pingHandler;
49+
final DateTime Function() _now;
50+
final LDLogger _logger;
51+
52+
late final StreamController<FDv2SourceResult> _controller;
53+
final Completer<void> _stoppedSignal = Completer<void>();
54+
StreamSubscription<Event>? _sseSubscription;
55+
FDv2ProtocolHandler? _handler;
56+
String? _environmentId;
57+
bool _pingInFlight = false;
58+
bool _pingPending = false;
59+
60+
FDv2StreamingBase({
61+
required SSEClient sseClient,
62+
required PingHandler pingHandler,
63+
required LDLogger logger,
64+
DateTime Function()? now,
65+
}) : _sseClient = sseClient,
66+
_pingHandler = pingHandler,
67+
_logger = logger.subLogger('FDv2StreamingBase'),
68+
_now = now ?? DateTime.now {
69+
_controller = StreamController<FDv2SourceResult>(
70+
onListen: _onListen,
71+
onCancel: _onCancel,
72+
);
73+
}
74+
75+
/// Single-subscription stream of results. The SSE connection is
76+
/// established lazily on the first [Stream.listen] call.
77+
Stream<FDv2SourceResult> get results => _controller.stream;
78+
79+
/// Stops the source, emits a shutdown [StatusResult], and closes the
80+
/// stream. Idempotent.
81+
void close() {
82+
_terminate(
83+
finalResult:
84+
FDv2SourceResults.shutdown(message: 'Streaming source closed'));
85+
}
86+
87+
/// Terminal-path helper used by [close] and by the in-stream
88+
/// terminal paths (goodbye event, fdv1-fallback header). Completes
89+
/// [_stoppedSignal] *first* so any subsequent [close] call -- e.g.
90+
/// from inside an `onData` listener reacting to the [finalResult]
91+
/// we are about to emit -- short-circuits at its guard instead of
92+
/// racing into a closed controller. Idempotent.
93+
void _terminate({FDv2SourceResult? finalResult}) {
94+
if (_stoppedSignal.isCompleted) return;
95+
_stoppedSignal.complete();
96+
_tearDownConnection();
97+
if (!_controller.isClosed) {
98+
if (finalResult != null) {
99+
_controller.add(finalResult);
100+
}
101+
_controller.close();
102+
}
103+
}
104+
105+
void _onListen() {
106+
_resetHandler();
107+
_sseSubscription = _sseClient.stream.listen(
108+
_handleEvent,
109+
onError: _handleSseError,
110+
);
111+
}
112+
113+
/// Builds a fresh [FDv2ProtocolHandler]. Called on initial connect
114+
/// and on every subsequent [OpenEvent] (SSE auto-reconnect), so a
115+
/// partial transfer from the previous connection cannot bleed into
116+
/// the new one. Also called after a mid-event throw inside
117+
/// `processEvent` so any half-accumulated state is discarded.
118+
void _resetHandler() {
119+
_handler = FDv2ProtocolHandler(
120+
objProcessors: {flagEvalKind: processFlagEval},
121+
logger: _logger,
122+
);
123+
}
124+
125+
Future<void> _onCancel() async {
126+
if (_stoppedSignal.isCompleted) return;
127+
_stoppedSignal.complete();
128+
_tearDownConnection();
129+
// No shutdown emission -- the subscriber asked us to stop. Close
130+
// the controller so its internal state is released; we keep no
131+
// subscribers and will never emit again.
132+
if (!_controller.isClosed) {
133+
_controller.close();
134+
}
135+
}
136+
137+
void _tearDownConnection() {
138+
_sseSubscription?.cancel();
139+
_sseSubscription = null;
140+
// Best-effort close. The SSE client may already be closed if it
141+
// emitted an error; that's fine -- the operation is documented as
142+
// safe in any state.
143+
_sseClient.close();
144+
}
145+
146+
void _handleEvent(Event event) {
147+
if (_stoppedSignal.isCompleted) return;
148+
switch (event) {
149+
case OpenEvent open:
150+
_handleOpen(open);
151+
case MessageEvent message:
152+
unawaited(_handleMessage(message));
153+
}
154+
}
155+
156+
void _handleOpen(OpenEvent event) {
157+
// Every OpenEvent represents a (re)established connection. Rebuild
158+
// the protocol handler so a partial transfer from the prior
159+
// connection cannot bleed into this one -- the SDK must defend
160+
// against this regardless of whether the server respects the
161+
// protocol's "re-send server-intent on resume" semantic.
162+
_resetHandler();
163+
164+
final headers = event.headers;
165+
if (headers == null) return;
166+
167+
final envId = headers['x-ld-envid'];
168+
if (envId != null) {
169+
_environmentId = envId;
170+
}
171+
172+
final fallback = headers['x-ld-fd-fallback']?.toLowerCase() == 'true';
173+
if (fallback) {
174+
// Server told us to fall back; route through the terminal helper
175+
// so a close() from the listener's onData -- a natural reaction
176+
// to a fallback signal -- doesn't race with our own close.
177+
_terminate(
178+
finalResult: FDv2SourceResults.terminalError(
179+
message: 'Server requested FDv1 fallback',
180+
fdv1Fallback: true,
181+
));
182+
}
183+
}
184+
185+
Future<void> _handleMessage(MessageEvent event) async {
186+
if (event.type == 'ping') {
187+
// Legacy bridge: older servers may still send `ping` instead of
188+
// FDv2 events. Defer to the injected handler for a one-shot poll.
189+
await _handlePing();
190+
return;
191+
}
192+
193+
// Capture freshness as close to message arrival as possible, before
194+
// any parse/dispatch work, so the timestamp reflects when the SDK
195+
// saw the update -- not when it finished processing it.
196+
final freshness = _now();
197+
198+
final ProtocolAction action;
199+
try {
200+
final decoded = jsonDecode(event.data);
201+
if (decoded is! Map<String, dynamic>) {
202+
_logger.warn('Ignoring SSE event with non-object data: '
203+
'event=${event.type}');
204+
_emit(FDv2SourceResults.interrupted(
205+
message: 'Streaming event payload was not a JSON object'));
206+
return;
207+
}
208+
// Wrap the protocol-handler dispatch in the same try/catch as the
209+
// jsonDecode: the structural casts inside the per-event fromJson
210+
// factories (e.g. PayloadIntent, PutObjectEvent) throw TypeError
211+
// on shape mismatch and would otherwise become unhandled async
212+
// exceptions.
213+
action =
214+
_handler!.processEvent(FDv2Event(event: event.type, data: decoded));
215+
} catch (err) {
216+
_logger.warn('Failed to parse or process SSE event (${err.runtimeType})');
217+
// Reset the handler -- a mid-event throw can leave it with stale
218+
// _tempUpdates from the partially-processed payload.
219+
_resetHandler();
220+
_emit(FDv2SourceResults.interrupted(
221+
message: 'Streaming event payload was malformed'));
222+
return;
223+
}
224+
225+
if (_stoppedSignal.isCompleted) return;
226+
227+
switch (action) {
228+
case ActionPayload(:final payload):
229+
_emit(ChangeSetResult(
230+
payload: payload,
231+
environmentId: _environmentId,
232+
freshness: freshness,
233+
persist: true,
234+
));
235+
case ActionGoodbye(:final reason):
236+
// Server told us to disconnect; route through the terminal
237+
// helper so a close() from the listener's onData -- a natural
238+
// reaction to a goodbye -- doesn't race with our own close.
239+
_terminate(
240+
finalResult: FDv2SourceResults.goodbyeResult(message: reason));
241+
case ActionServerError(:final reason):
242+
_emit(FDv2SourceResults.interrupted(message: reason));
243+
case ActionError(:final message):
244+
_emit(FDv2SourceResults.interrupted(message: message));
245+
case ActionNone():
246+
// No emission; continue accumulating events until the handler
247+
// reaches a terminal action.
248+
break;
249+
}
250+
}
251+
252+
Future<void> _handlePing() async {
253+
// The FDv2 ping semantic is "go re-poll". Two competing concerns:
254+
//
255+
// 1. Concurrent polls race on emit-order and amplify load on the
256+
// polling endpoint, so only one poll may be in flight at a time.
257+
// 2. Simply dropping pings that arrive during an in-flight poll
258+
// can leave the SDK on a stale snapshot: if server state changed
259+
// between when the in-flight poll captured it and when the
260+
// dropped ping arrived, no further poll fires and the change is
261+
// never seen.
262+
//
263+
// Coalesce: pings that arrive while a poll is running set a
264+
// `_pingPending` flag. When the in-flight poll returns we drain the
265+
// flag with one more poll, capturing whatever the latest state is.
266+
// Multiple pings during the same in-flight window collapse to a
267+
// single follow-up.
268+
if (_pingInFlight) {
269+
_pingPending = true;
270+
return;
271+
}
272+
_pingInFlight = true;
273+
try {
274+
do {
275+
_pingPending = false;
276+
final FDv2SourceResult result;
277+
try {
278+
result = await _pingHandler();
279+
} catch (err) {
280+
_logger.warn('Ping handler threw unexpectedly: ${err.runtimeType}');
281+
if (_stoppedSignal.isCompleted) return;
282+
_emit(FDv2SourceResults.interrupted(
283+
message: 'Ping handler raised error unexpectedly'));
284+
return;
285+
}
286+
if (_stoppedSignal.isCompleted) return;
287+
_emit(result);
288+
} while (_pingPending && !_stoppedSignal.isCompleted);
289+
} finally {
290+
_pingInFlight = false;
291+
}
292+
}
293+
294+
void _handleSseError(Object err, StackTrace stack) {
295+
if (_stoppedSignal.isCompleted) return;
296+
// The SSE client's built-in backoff handles reconnection. Surface
297+
// the disruption as interrupted; the orchestrator decides whether
298+
// to fall through to a different source after enough time.
299+
//
300+
// Don't log the raw exception. http.ClientException's toString
301+
// formats as 'ClientException: <msg>, uri=<full-url>', and in GET
302+
// mode the URL embeds the base64-encoded context. Only the
303+
// category and a synthetic stack header go to the log.
304+
_logger.warn('SSE error (${err.runtimeType}); will retry');
305+
_logger.debug('SSE error stack:\n$stack');
306+
_emit(FDv2SourceResults.interrupted(message: _describeError(err)));
307+
}
308+
309+
/// Categorizes an exception surfaced on the SSE stream into a fixed
310+
/// sanitized message. Mirrors the polling base's helper so neither
311+
/// surface (the public StatusResult.message nor the warn log) ever
312+
/// echoes a raw http.ClientException -- whose toString carries the
313+
/// full request URL.
314+
String _describeError(Object err) {
315+
if (err is TimeoutException) {
316+
return 'Streaming request timed out';
317+
}
318+
if (err is http.ClientException) {
319+
return 'Network error during streaming request';
320+
}
321+
final type = err.runtimeType.toString();
322+
if (type.contains('Tls') || type.contains('Handshake')) {
323+
return 'TLS error during streaming request';
324+
}
325+
return 'Streaming connection error';
326+
}
327+
328+
void _emit(FDv2SourceResult result) {
329+
if (_stoppedSignal.isCompleted) return;
330+
if (_controller.isClosed) return;
331+
_controller.add(result);
332+
}
333+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import 'source.dart';
2+
import 'source_result.dart';
3+
import 'streaming_base.dart';
4+
5+
/// Long-lived streaming synchronizer.
6+
///
7+
/// A thin adapter that exposes [FDv2StreamingBase.results] as a
8+
/// [Synchronizer]. The base class already implements all of the
9+
/// connection lifecycle, protocol parsing, and error handling; this
10+
/// wrapper exists only to satisfy the [Synchronizer] interface so the
11+
/// orchestrator can treat polling and streaming uniformly.
12+
final class FDv2StreamingSynchronizer implements Synchronizer {
13+
final FDv2StreamingBase _base;
14+
15+
FDv2StreamingSynchronizer({required FDv2StreamingBase base}) : _base = base;
16+
17+
@override
18+
Stream<FDv2SourceResult> get results => _base.results;
19+
20+
@override
21+
void close() => _base.close();
22+
}

0 commit comments

Comments
 (0)