Skip to content

Commit

Permalink
feat: Implement support for browser requests. (#578)
Browse files Browse the repository at this point in the history
  • Loading branch information
kinyoklion authored Sep 12, 2024
1 parent fe82500 commit 887548a
Show file tree
Hide file tree
Showing 10 changed files with 376 additions and 27 deletions.
87 changes: 87 additions & 0 deletions packages/sdk/browser/__tests__/platform/Backoff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import Backoff from '../../src/platform/Backoff';

const noJitter = (): number => 0;
const maxJitter = (): number => 1;
const defaultResetInterval = 60 * 1000;

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

it.each([1, 1000, 5000])('doubles delay on consecutive failures', (initialDelay) => {
const backoff = new Backoff(initialDelay, defaultResetInterval, 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, defaultResetInterval, 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, defaultResetInterval, 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, defaultResetInterval, noJitter);
expect(backoff.fail()).toEqual(30000);
});

it('jitters the backoff value', () => {
const backoff = new Backoff(1000, defaultResetInterval, 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.each([10 * 1000, 60 * 1000])(
'resets the delay when the last successful connection was connected greater than the retry reset interval',
(retryResetInterval) => {
let time = 1000;
const backoff = new Backoff(1000, retryResetInterval, noJitter);
expect(backoff.fail(time)).toEqual(1000);
time += 1;
backoff.success(time);
time = time + retryResetInterval + 1;
expect(backoff.fail(time)).toEqual(1000);
time += 1;
expect(backoff.fail(time)).toEqual(2000);
time += 1;
backoff.success(time);
time = time + retryResetInterval + 1;
expect(backoff.fail(time)).toEqual(1000);
},
);

it.each([10 * 1000, 60 * 1000])(
'does not reset the delay when the connection did not persist longer than the retry reset interval',
(retryResetInterval) => {
const backoff = new Backoff(1000, retryResetInterval, noJitter);

let time = 1000;
expect(backoff.fail(time)).toEqual(1000);
time += 1;
backoff.success(time);
time += retryResetInterval;
expect(backoff.fail(time)).toEqual(2000);
time += retryResetInterval;
expect(backoff.fail(time)).toEqual(4000);
time += 1;
backoff.success(time);
time += retryResetInterval;
expect(backoff.fail(time)).toEqual(8000);
},
);
13 changes: 8 additions & 5 deletions packages/sdk/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
],
"scripts": {
"clean": "rimraf dist",
"build": "tsc --noEmit && vite build",
"build": "rollup -c rollup.config.js",
"lint": "eslint . --ext .ts,.tsx",
"prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore",
"test": "jest",
Expand All @@ -39,6 +39,11 @@
},
"devDependencies": {
"@launchdarkly/private-js-mocks": "0.0.1",
"@rollup/plugin-commonjs": "^25.0.0",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.0.2",
"@rollup/plugin-terser": "^0.4.3",
"@rollup/plugin-typescript": "^11.1.1",
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
"@types/jest": "^29.5.11",
"@typescript-eslint/eslint-plugin": "^6.20.0",
Expand All @@ -54,11 +59,9 @@
"jest-environment-jsdom": "^29.7.0",
"prettier": "^3.0.0",
"rimraf": "^5.0.5",
"rollup": "^3.23.0",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"typedoc": "0.25.0",
"typescript": "^5.5.3",
"vite": "^5.4.1",
"vite-plugin-dts": "^4.0.3"
"typescript": "^5.5.3"
}
}
49 changes: 49 additions & 0 deletions packages/sdk/browser/rollup.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import common from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import terser from '@rollup/plugin-terser';
import typescript from '@rollup/plugin-typescript';
import json from '@rollup/plugin-json';

const getSharedConfig = (format, file) => ({
input: 'src/index.ts',
output: [
{
format: format,
sourcemap: true,
file: file,
},
],
onwarn: (warning) => {
if (warning.code !== 'CIRCULAR_DEPENDENCY') {
console.error(`(!) ${warning.message}`);
}
},
});

export default [
{
...getSharedConfig('es', 'dist/index.es.js'),
plugins: [
typescript({
module: 'esnext',
}),
common({
transformMixedEsModules: true,
esmExternals: true,
}),
resolve(),
terser(),
json(),
],
},
{
...getSharedConfig('cjs', 'dist/index.cjs.js'),
plugins: [
typescript(),
common(),
resolve(),
terser(),
json(),
],
},
];
7 changes: 7 additions & 0 deletions packages/sdk/browser/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import BrowserInfo from './platform/BrowserInfo';
import DefaultBrowserEventSource from './platform/DefaultBrowserEventSource';

// Temporary exports for testing in a browser.
export { DefaultBrowserEventSource, BrowserInfo };
export * from '@launchdarkly/js-client-sdk-common';

export function Hello() {
// eslint-disable-next-line no-console
console.log('HELLO');
Expand Down
76 changes: 76 additions & 0 deletions packages/sdk/browser/src/platform/Backoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
const MAX_RETRY_DELAY = 30 * 1000; // Maximum retry delay 30 seconds.
const JITTER_RATIO = 0.5; // Delay should be 50%-100% of calculated time.

/**
* 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 retryResetIntervalMillis: 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 > this.retryResetIntervalMillis
) {
this.retryCount = 0;
}
this.activeSince = undefined;
const delay = this.jitter(this.backoff());
this.retryCount += 1;
return delay;
}
}
17 changes: 13 additions & 4 deletions packages/sdk/browser/src/platform/BrowserPlatform.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import { Crypto, Encoding, Info, LDOptions, Storage } from '@launchdarkly/js-client-sdk-common';
import {
Crypto,
Encoding,
Info,
LDOptions,
Platform,
Requests,
Storage,
} from '@launchdarkly/js-client-sdk-common';

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

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

constructor(options: LDOptions) {
Expand Down
29 changes: 29 additions & 0 deletions packages/sdk/browser/src/platform/BrowserRequests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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> {
// @ts-ignore
return fetch(url, options);
}

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

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

0 comments on commit 887548a

Please sign in to comment.