Skip to content

Commit 279b00d

Browse files
committed
feat: Thread FDv1 fallback TTL through polling and streaming data sources
This commit will add in fallback TTL reading support as well as allow RN native event source read headers.
1 parent 4a213c3 commit 279b00d

7 files changed

Lines changed: 379 additions & 48 deletions

File tree

packages/sdk/react-native/__tests__/fromExternal/react-native-sse/EventSource.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,12 @@ describe('EventSource', () => {
3838
abort: jest.fn(),
3939
};
4040

41-
jest.spyOn(window, 'XMLHttpRequest').mockImplementation(() => mockXhr as XMLHttpRequest);
41+
const xhrSpy = jest.spyOn(window, 'XMLHttpRequest').mockImplementation(() => mockXhr as XMLHttpRequest);
42+
// Preserve static constants that EventSource reads from the constructor reference.
43+
// @ts-ignore
44+
xhrSpy.LOADING = 3;
45+
// @ts-ignore
46+
xhrSpy.DONE = 4;
4247

4348
eventSource = new EventSource<EventName>(uri, { logger });
4449
eventSource.onclose = jest.fn();
@@ -135,4 +140,31 @@ describe('EventSource', () => {
135140

136141
expect(mockXhr.open).toHaveBeenLastCalledWith('GET', `${uri}?basis=initial`, true);
137142
});
143+
144+
test('calls onopen with parsed response headers', () => {
145+
const onopen = jest.fn();
146+
eventSource.onopen = onopen;
147+
148+
mockXhr.getAllResponseHeaders = jest.fn(
149+
() => 'X-Ld-Fd-Fallback: true\r\nX-Ld-Fd-Fallback-Ttl: 60\r\nContent-Type: text/event-stream',
150+
);
151+
mockXhr.responseText = '';
152+
153+
jest.runAllTimers();
154+
155+
mockXhr.readyState = 4;
156+
mockXhr.status = 200;
157+
mockXhr.onreadystatechange();
158+
159+
expect(onopen).toHaveBeenCalledTimes(1);
160+
expect(onopen).toHaveBeenCalledWith(
161+
expect.objectContaining({
162+
type: 'open',
163+
headers: expect.objectContaining({
164+
'x-ld-fd-fallback': 'true',
165+
'x-ld-fd-fallback-ttl': '60',
166+
}),
167+
}),
168+
);
169+
});
138170
});

packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,10 @@ export default class EventSource<E extends string = never> {
194194
if (this._status === this.CONNECTING) {
195195
this._retryCount = 0;
196196
this._status = this.OPEN;
197-
this.dispatch('open', { type: 'open' });
197+
this.dispatch('open', {
198+
type: 'open',
199+
headers: this._parseResponseHeaders(this._xhr.getAllResponseHeaders()),
200+
});
198201
this._logger?.debug('[EventSource][onreadystatechange][OPEN] Connection opened.');
199202
}
200203

@@ -353,12 +356,28 @@ export default class EventSource<E extends string = never> {
353356
}
354357
}
355358

359+
private _parseResponseHeaders(raw: string | null): Record<string, string> {
360+
const result: Record<string, string> = {};
361+
if (!raw) {
362+
return result;
363+
}
364+
raw.split('\r\n').forEach((line) => {
365+
const separatorIndex = line.indexOf(': ');
366+
if (separatorIndex > 0) {
367+
const key = line.slice(0, separatorIndex).toLowerCase();
368+
const value = line.slice(separatorIndex + 2);
369+
result[key] = value;
370+
}
371+
});
372+
return result;
373+
}
374+
356375
dispatch<T extends EventType<E>>(type: T, data: EventSourceEvent<T>) {
357376
this._eventHandlers[type]?.forEach((handler: EventSourceListener<E, T>) => handler(data));
358377

359378
switch (type) {
360379
case 'open':
361-
this.onopen();
380+
this.onopen(data);
362381
break;
363382
case 'close':
364383
this.onclose();
@@ -389,7 +408,7 @@ export default class EventSource<E extends string = never> {
389408
return this._status;
390409
}
391410

392-
onopen() {}
411+
onopen(_e: any) {}
393412
onclose() {}
394413
onerror(_err: any) {}
395414
onretrying(_e: any) {}

packages/sdk/react-native/src/fromExternal/react-native-sse/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface MessageEvent {
1010

1111
export interface OpenEvent {
1212
type: 'open';
13+
headers?: Record<string, string>;
1314
}
1415

1516
export interface CloseEvent {

packages/shared/sdk-client/__tests__/datasource/fdv2/PollingBase.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,23 @@ describe('given a goodbye event in the response', () => {
303303
expect(result.reason).toBe('server-shutdown');
304304
}
305305
});
306+
307+
it('emits terminal_error when the response header signals fallback', async () => {
308+
const body = makeFDv2Body([{ event: 'goodbye', data: { reason: 'bye' } }]);
309+
const requestor = makeRequestor({
310+
status: 200,
311+
headers: makeHeaders({ 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '45' }),
312+
body,
313+
});
314+
315+
const result = await poll(requestor, undefined, logger);
316+
317+
expect(result.type).toBe('status');
318+
if (result.type !== 'status') return;
319+
expect(result.state).toBe('terminal_error');
320+
expect(result.fdv1Fallback).toBe(true);
321+
expect((result as any).fdv1FallbackTtlMs).toBe(45000);
322+
});
306323
});
307324

308325
describe('given a server error event in the response', () => {
@@ -468,3 +485,61 @@ describe('given a delete-object event', () => {
468485
}
469486
});
470487
});
488+
489+
describe('given x-ld-fd-fallback-ttl header', () => {
490+
it('reads TTL in seconds and converts to ms on a changeSet', async () => {
491+
const body = makeFullPayloadBody({ flagA: { value: true } });
492+
const requestor = makeRequestor({
493+
status: 200,
494+
headers: makeHeaders({ 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '60' }),
495+
body,
496+
});
497+
498+
const result = await poll(requestor, undefined, logger);
499+
500+
expect(result.fdv1Fallback).toBe(true);
501+
expect((result as any).fdv1FallbackTtlMs).toBe(60000);
502+
});
503+
504+
it('treats TTL "0" as indefinite (0 ms)', async () => {
505+
const body = makeFullPayloadBody({ flagA: { value: true } });
506+
const requestor = makeRequestor({
507+
status: 200,
508+
headers: makeHeaders({ 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '0' }),
509+
body,
510+
});
511+
512+
const result = await poll(requestor, undefined, logger);
513+
514+
expect(result.fdv1Fallback).toBe(true);
515+
expect((result as any).fdv1FallbackTtlMs).toBe(0);
516+
});
517+
518+
it('leaves TTL undefined when the ttl header is absent but fallback is true', async () => {
519+
const body = makeFullPayloadBody({ flagA: { value: true } });
520+
const requestor = makeRequestor({
521+
status: 200,
522+
headers: makeHeaders({ 'x-ld-fd-fallback': 'true' }),
523+
body,
524+
});
525+
526+
const result = await poll(requestor, undefined, logger);
527+
528+
expect(result.fdv1Fallback).toBe(true);
529+
expect((result as any).fdv1FallbackTtlMs).toBeUndefined();
530+
});
531+
532+
it('stamps TTL on a non-success error response', async () => {
533+
const requestor = makeRequestor({
534+
status: 503,
535+
headers: makeHeaders({ 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '30' }),
536+
body: null,
537+
});
538+
539+
const result = await poll(requestor, undefined, logger);
540+
541+
expect(result.fdv1Fallback).toBe(true);
542+
expect((result as any).fdv1FallbackTtlMs).toBe(30000);
543+
});
544+
});
545+

packages/shared/sdk-client/__tests__/datasource/fdv2/StreamingFDv2Base.test.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,3 +514,146 @@ it('calling start twice does not create a second EventSource', () => {
514514

515515
base.close();
516516
});
517+
518+
it('reads x-ld-fd-fallback-ttl on the error path', async () => {
519+
const mockEventSource = createMockEventSource();
520+
const mockRequests = createMockRequests(mockEventSource);
521+
const base = createBase(mockRequests, logger);
522+
base.start();
523+
524+
const willRetry = simulateErrorFilter(mockRequests, {
525+
status: 200,
526+
message: 'fallback',
527+
headers: { 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '60' },
528+
});
529+
expect(willRetry).toBe(false);
530+
531+
const result = await base.takeResult();
532+
expect(result.type).toBe('status');
533+
if (result.type !== 'status') return;
534+
expect(result.state).toBe('terminal_error');
535+
expect(result.fdv1Fallback).toBe(true);
536+
expect((result as any).fdv1FallbackTtlMs).toBe(60000);
537+
538+
base.close();
539+
});
540+
541+
it('defers the open fallback directive to the next changeSet', async () => {
542+
const mockEventSource = createMockEventSource();
543+
const mockRequests = createMockRequests(mockEventSource);
544+
const base = createBase(mockRequests, logger);
545+
base.start();
546+
547+
mockEventSource.onopen({
548+
type: 'open',
549+
headers: { 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '90' },
550+
});
551+
552+
sendFullTransfer(mockEventSource, [{ key: 'flag-a', version: 1, value: 'green' }]);
553+
554+
const result = await base.takeResult();
555+
expect(result.type).toBe('changeSet');
556+
if (result.type !== 'changeSet') return;
557+
expect(result.fdv1Fallback).toBe(true);
558+
expect((result as any).fdv1FallbackTtlMs).toBe(90000);
559+
560+
base.close();
561+
});
562+
563+
it('emits terminal_error for a goodbye with protocolFallbackTTL', async () => {
564+
const mockEventSource = createMockEventSource();
565+
const mockRequests = createMockRequests(mockEventSource);
566+
const base = createBase(mockRequests, logger);
567+
base.start();
568+
569+
simulateEvent(mockEventSource, 'goodbye', {
570+
reason: 'falling back',
571+
protocolFallbackTTL: 60,
572+
});
573+
574+
const result = await base.takeResult();
575+
expect(result.type).toBe('status');
576+
if (result.type !== 'status') return;
577+
expect(result.state).toBe('terminal_error');
578+
expect(result.fdv1Fallback).toBe(true);
579+
expect((result as any).fdv1FallbackTtlMs).toBe(60000);
580+
581+
base.close();
582+
});
583+
584+
it('emits terminal_error for a goodbye when a deferred open directive is pending', async () => {
585+
// When onopen sets pendingFallback but the goodbye event has no protocolFallbackTTL,
586+
// the pending directive triggers the fallback path with the deferred TTL.
587+
const mockEventSource = createMockEventSource();
588+
const mockRequests = createMockRequests(mockEventSource);
589+
const base = createBase(mockRequests, logger);
590+
base.start();
591+
592+
// Open with fallback headers (defers the directive)
593+
mockEventSource.onopen({
594+
type: 'open',
595+
headers: { 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '45' },
596+
});
597+
598+
// Goodbye fires before any payload — the pending directive triggers the fallback
599+
simulateEvent(mockEventSource, 'goodbye', { reason: 'bye' });
600+
601+
const result = await base.takeResult();
602+
expect(result.type).toBe('status');
603+
if (result.type !== 'status') return;
604+
expect(result.state).toBe('terminal_error');
605+
expect(result.fdv1Fallback).toBe(true);
606+
expect(result.fdv1FallbackTtlMs).toBe(45000);
607+
608+
base.close();
609+
});
610+
611+
it('clears a pending fallback directive when onopen fires without the fallback header', async () => {
612+
const mockEventSource = createMockEventSource();
613+
const mockRequests = createMockRequests(mockEventSource);
614+
const base = createBase(mockRequests, logger);
615+
base.start();
616+
617+
// First open carries fallback headers — arms the deferred directive
618+
mockEventSource.onopen({
619+
type: 'open',
620+
headers: { 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '60' },
621+
});
622+
623+
// Reconnect without fallback header — must clear the stale directive
624+
mockEventSource.onopen({ type: 'open', headers: {} });
625+
626+
// A payload arriving after the clean reconnect must NOT carry fdv1Fallback=true
627+
sendFullTransfer(mockEventSource, [{ key: 'flag-a', version: 1, value: 'blue' }]);
628+
629+
const result = await base.takeResult();
630+
expect(result.type).toBe('changeSet');
631+
if (result.type !== 'changeSet') return;
632+
expect(result.fdv1Fallback).toBe(false);
633+
expect(result.fdv1FallbackTtlMs).toBeUndefined();
634+
635+
base.close();
636+
});
637+
638+
it('close resets the pending fallback directive', async () => {
639+
// Calling close() must clear pendingFallback/pendingFallbackTtlMs so a
640+
// subsequently re-created source does not inherit stale state.
641+
const mockEventSource = createMockEventSource();
642+
const mockRequests = createMockRequests(mockEventSource);
643+
const base = createBase(mockRequests, logger);
644+
base.start();
645+
646+
// Arm the deferred directive
647+
mockEventSource.onopen({
648+
type: 'open',
649+
headers: { 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '30' },
650+
});
651+
652+
// Close before any payload arrives — the shutdown result must NOT carry TTL
653+
base.close();
654+
const result = await base.takeResult();
655+
expect(result.type).toBe('status');
656+
if (result.type !== 'status') return;
657+
expect(result.state).toBe('shutdown');
658+
expect(result.fdv1FallbackTtlMs).toBeUndefined();
659+
});

0 commit comments

Comments
 (0)