-
Notifications
You must be signed in to change notification settings - Fork 20
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
Changes from 15 commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
53af412
feat: Implement support for browser requests.
kinyoklion bf164dd
feat: Add support for conditional event source capabilities.
kinyoklion 8307b03
Add more comments.
kinyoklion c23bf45
Implement for edge SDKs.
kinyoklion a084b9a
Lint
kinyoklion 6acc5f4
Add getEventSourceCapabilities to mocks.
kinyoklion b89e14e
Test updates.
kinyoklion 5becb5b
More test updates.
kinyoklion 8f63e45
More tests.
kinyoklion 1c5450f
More test updates.
kinyoklion f0a51c6
feat: Implement browser platform.
kinyoklion 39355b8
Merge branch 'rlamb/sc-254415/support-event-source-capabilities' into…
kinyoklion d259791
Rename browser event source.
kinyoklion 679b813
Add backoff tests.
kinyoklion 0434103
Enable encoding.
kinyoklion 7c66b7b
Move to rollup.
kinyoklion a9ae9ac
Merge branch 'main' into rlamb/sc-254415/implement-browser-requests
kinyoklion fd8d7d6
Validate package.json imports.
kinyoklion 61a3631
Configurable reset time.
kinyoklion af341cb
Implements platform.
kinyoklion ff1dc23
Correct fetch.
kinyoklion File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
106
packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
import { | ||
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()); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.