Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement support for browser requests. #578

Merged
merged 21 commits into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions packages/sdk/browser/__tests__/platform/Backoff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import Backoff from '../../src/platform/Backoff';

const noJitter = (): number => 0;
const maxJitter = (): number => 1;

it.each([1, 1000, 5000])('has the correct starting delay', (initialDelay) => {
const backoff = new Backoff(initialDelay, noJitter);
expect(backoff.fail()).toEqual(initialDelay);
});

it.each([1, 1000, 5000])('doubles delay on consecutive failures', (initialDelay) => {
const backoff = new Backoff(initialDelay, noJitter);
expect(backoff.fail()).toEqual(initialDelay);
expect(backoff.fail()).toEqual(initialDelay * 2);
expect(backoff.fail()).toEqual(initialDelay * 4);
});

it('stops increasing delay when the max backoff is encountered', () => {
const backoff = new Backoff(5000, noJitter);
expect(backoff.fail()).toEqual(5000);
expect(backoff.fail()).toEqual(10000);
expect(backoff.fail()).toEqual(20000);
expect(backoff.fail()).toEqual(30000);

const backoff2 = new Backoff(1000, noJitter);
expect(backoff2.fail()).toEqual(1000);
expect(backoff2.fail()).toEqual(2000);
expect(backoff2.fail()).toEqual(4000);
expect(backoff2.fail()).toEqual(8000);
expect(backoff2.fail()).toEqual(16000);
expect(backoff2.fail()).toEqual(30000);
});

it('handles an initial retry delay longer than the maximum retry delay', () => {
const backoff = new Backoff(40000, noJitter);
expect(backoff.fail()).toEqual(30000);
});

it('jitters the backoff value', () => {
const backoff = new Backoff(1000, maxJitter);
expect(backoff.fail()).toEqual(500);
expect(backoff.fail()).toEqual(1000);
expect(backoff.fail()).toEqual(2000);
expect(backoff.fail()).toEqual(4000);
expect(backoff.fail()).toEqual(8000);
expect(backoff.fail()).toEqual(15000);
});

it('resets the delay when the last successful connection was connected greater than RESET_INTERVAL', () => {
const backoff = new Backoff(1000, noJitter);
expect(backoff.fail(1000)).toEqual(1000);
backoff.success(2000);
expect(backoff.fail(62001)).toEqual(1000);
expect(backoff.fail(62002)).toEqual(2000);
backoff.success(64002);
expect(backoff.fail(124003)).toEqual(1000);
});

it('does not reset the delay when the connection did not persist longer than the RESET_INTERVAL', () => {
const backoff = new Backoff(1000, noJitter);
expect(backoff.fail(1000)).toEqual(1000);
backoff.success(2000);
expect(backoff.fail(61000)).toEqual(2000);
expect(backoff.fail(120000)).toEqual(4000);
backoff.success(124000);
expect(backoff.fail(183000)).toEqual(8000);
});
73 changes: 73 additions & 0 deletions packages/sdk/browser/src/platform/Backoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
const MAX_RETRY_DELAY = 30 * 1000; // Maximum retry delay 30 seconds.
const JITTER_RATIO = 0.5; // Delay should be 50%-100% of calculated time.
const RESET_INTERVAL = 60 * 1000; // Reset interval in seconds.

/**
* Implements exponential backoff and jitter. This class tracks successful connections and failures
* and produces a retry delay.
*
* It does not start any timers or directly control a connection.
*
* The backoff follows an exponential backoff scheme with 50% jitter starting at
* initialRetryDelayMillis and capping at MAX_RETRY_DELAY. If RESET_INTERVAL has elapsed after a
* success, without an intervening faulure, then the backoff is reset to initialRetryDelayMillis.
*/
export default class Backoff {
private retryCount: number = 0;
private activeSince?: number;
private initialRetryDelayMillis: number;
/**
* The exponent at which the backoff delay will exceed the maximum.
* Beyond this limit the backoff can be set to the max.
*/
private readonly maxExponent: number;

constructor(
initialRetryDelayMillis: number,
private readonly random = Math.random,
) {
// Initial retry delay cannot be 0.
this.initialRetryDelayMillis = Math.max(1, initialRetryDelayMillis);
this.maxExponent = Math.ceil(Math.log2(MAX_RETRY_DELAY / this.initialRetryDelayMillis));
}

private backoff(): number {
const exponent = Math.min(this.retryCount, this.maxExponent);
const delay = this.initialRetryDelayMillis * 2 ** exponent;
return Math.min(delay, MAX_RETRY_DELAY);
}

private jitter(computedDelayMillis: number): number {
return computedDelayMillis - Math.trunc(this.random() * JITTER_RATIO * computedDelayMillis);
}

/**
* This function should be called when a connection attempt is successful.
*
* @param timeStampMs The time of the success. Used primarily for testing, when not provided
* the current time is used.
*/
success(timeStampMs: number = Date.now()): void {
this.activeSince = timeStampMs;
}

/**
* This function should be called when a connection fails. It returns the a delay, in
* milliseconds, after which a reconnection attempt should be made.
*
* @param timeStampMs The time of the success. Used primarily for testing, when not provided
* the current time is used.
* @returns The delay before the next connection attempt.
*/
fail(timeStampMs: number = Date.now()): number {
// If the last successful connection was active for more than the RESET_INTERVAL, then we
// return to the initial retry delay.
if (this.activeSince !== undefined && timeStampMs - this.activeSince > RESET_INTERVAL) {
this.retryCount = 0;
}
this.activeSince = undefined;
const delay = this.jitter(this.backoff());
this.retryCount += 1;
return delay;
}
}
8 changes: 6 additions & 2 deletions packages/sdk/browser/src/platform/BrowserPlatform.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import {
Crypto,
Encoding,
/* platform */
LDOptions,
Requests,
Storage,
} from '@launchdarkly/js-client-sdk-common';

import BrowserCrypto from './BrowserCrypto';
import BrowserEncoding from './BrowserEncoding';
import BrowserRequests from './BrowserRequests';
import LocalStorage, { isLocalStorageSupported } from './LocalStorage';

export default class BrowserPlatform /* implements platform.Platform */ {
// encoding?: Encoding;
encoding: Encoding = new BrowserEncoding();
// info: Info;
// fileSystem?: Filesystem;
crypto: Crypto = new BrowserCrypto();
// requests: Requests;
requests: Requests = new BrowserRequests();
storage?: Storage;

constructor(options: LDOptions) {
Expand Down
28 changes: 28 additions & 0 deletions packages/sdk/browser/src/platform/BrowserRequests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {
EventSourceCapabilities,
EventSourceInitDict,
EventSource as LDEventSource,
Options,
Requests,
Response,
} from '@launchdarkly/js-client-sdk-common';

import DefaultBrowserEventSource from './DefaultBrowserEventSource';

export default class BrowserRequests implements Requests {
fetch(url: string, options?: Options): Promise<Response> {
return this.fetch(url, options);
}

createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): LDEventSource {
return new DefaultBrowserEventSource(url, eventSourceInitDict);
}

getEventSourceCapabilities(): EventSourceCapabilities {
return {
customMethod: false,
readTimeout: false,
headers: false,
};
}
}
106 changes: 106 additions & 0 deletions packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {
Copy link
Member Author

Choose a reason for hiding this comment

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

For now I am not adding tests for this. Running the tests in node would require using an event source polyfill, effectively means it won't be testing much reality.

As a result it is likely some things will need to be tweaked when it is used for real.

Copy link
Member Author

Choose a reason for hiding this comment

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

I have done some manual testing in a browser.

EventListener,
EventName,
EventSourceInitDict,
HttpErrorResponse,
EventSource as LDEventSource,
} from '@launchdarkly/js-client-sdk-common';

import Backoff from './Backoff';

/**
* Implementation Notes:
*
* This event source does not support a read-timeout.
* This event source does not support customized verbs.
* This event source does not support headers.
*/

/**
* Browser event source implementation which extends the built-in event
* source with additional reconnection logic.
*/
export default class DefaultBrowserEventSource implements LDEventSource {
private es?: EventSource;
private backoff: Backoff;
private errorFilter: (err: HttpErrorResponse) => boolean;

// The type of the handle can be platform specific and we treat is opaquely.
private reconnectTimeoutHandle?: any;

private listeners: Record<string, EventListener[]> = {};

constructor(
private readonly url: string,
options: EventSourceInitDict,
) {
this.backoff = new Backoff(options.initialRetryDelayMillis);
this.errorFilter = options.errorFilter;
this.openConnection();
}

onclose: (() => void) | undefined;

onerror: ((err?: HttpErrorResponse) => void) | undefined;

onopen: (() => void) | undefined;

onretrying: ((e: { delayMillis: number }) => void) | undefined;

private openConnection() {
this.es = new EventSource(this.url);
this.es.onopen = () => {
this.backoff.success();
this.onopen?.();
};
// The error could be from a polyfill, or from the browser event source, so we are loose on the
// typing.
this.es.onerror = (err: any) => {
this.handleError(err);
this.onerror?.(err);
};
Object.entries(this.listeners).forEach(([eventName, listeners]) => {
listeners.forEach((listener) => {
this.es?.addEventListener(eventName, listener);
});
});
}

addEventListener(type: EventName, listener: EventListener): void {
this.listeners[type] ??= [];
this.listeners[type].push(listener);
this.es?.addEventListener(type, listener);
}

close(): void {
// Ensure any pending retry attempts are not done.
clearTimeout(this.reconnectTimeoutHandle);
this.reconnectTimeoutHandle = undefined;

// Close the event source and notify any listeners.
this.es?.close();
this.onclose?.();
}

private tryConnect(delayMs: number) {
this.onretrying?.({ delayMillis: delayMs });
this.reconnectTimeoutHandle = setTimeout(() => {
this.openConnection();
}, delayMs);
}

private handleError(err: any): void {
this.close();

// The event source may not produce a status. But the LaunchDarkly
// polyfill can. If we can get the status, then we should stop retrying
// on certain error codes.
if (err.status && typeof err.status === 'number' && !this.errorFilter(err)) {
// If we encounter an unrecoverable condition, then we do not want to
// retry anymore.
return;
}

this.tryConnect(this.backoff.fail());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ jest.mock('../src/platform', () => ({
requests: {
createEventSource: jest.fn(),
fetch: jest.fn(),
getEventSourceCapabilities: jest.fn(),
},
encoding: new PlatformEncoding(),
storage: new PlatformStorage(logger),
Expand Down Expand Up @@ -70,6 +71,7 @@ it('uses correct default diagnostic url', () => {
requests: {
createEventSource: jest.fn(),
fetch: mockedFetch,
getEventSourceCapabilities: jest.fn(),
},
encoding: new PlatformEncoding(),
storage: new PlatformStorage(logger),
Expand Down Expand Up @@ -97,6 +99,7 @@ it('uses correct default analytics event url', async () => {
requests: {
createEventSource: createMockEventSource,
fetch: mockedFetch,
getEventSourceCapabilities: jest.fn(),
},
encoding: new PlatformEncoding(),
storage: new PlatformStorage(logger),
Expand Down Expand Up @@ -128,6 +131,7 @@ it('uses correct default polling url', async () => {
requests: {
createEventSource: jest.fn(),
fetch: mockedFetch,
getEventSourceCapabilities: jest.fn(),
},
encoding: new PlatformEncoding(),
storage: new PlatformStorage(logger),
Expand Down Expand Up @@ -158,6 +162,7 @@ it('uses correct default streaming url', (done) => {
requests: {
createEventSource: mockedCreateEventSource,
fetch: jest.fn(),
getEventSourceCapabilities: jest.fn(),
},
encoding: new PlatformEncoding(),
storage: new PlatformStorage(logger),
Expand Down Expand Up @@ -197,6 +202,7 @@ it('includes authorization header for polling', async () => {
requests: {
createEventSource: jest.fn(),
fetch: mockedFetch,
getEventSourceCapabilities: jest.fn(),
},
encoding: new PlatformEncoding(),
storage: new PlatformStorage(logger),
Expand Down Expand Up @@ -231,6 +237,7 @@ it('includes authorization header for streaming', (done) => {
requests: {
createEventSource: mockedCreateEventSource,
fetch: jest.fn(),
getEventSourceCapabilities: jest.fn(),
},
encoding: new PlatformEncoding(),
storage: new PlatformStorage(logger),
Expand Down
9 changes: 9 additions & 0 deletions packages/sdk/react-native/src/platform/PlatformRequests.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
EventName,
EventSource,
EventSourceCapabilities,
EventSourceInitDict,
LDLogger,
Options,
Expand All @@ -21,6 +22,14 @@ export default class PlatformRequests implements Requests {
});
}

getEventSourceCapabilities(): EventSourceCapabilities {
return {
readTimeout: false,
headers: true,
customMethod: true,
};
}

fetch(url: string, options?: Options): Promise<Response> {
// @ts-ignore
return fetch(url, options);
Expand Down
Loading