Skip to content

Commit

Permalink
feat: fire event when error is present and http call succeeds (#185)
Browse files Browse the repository at this point in the history
This PR adds a new event that fires when an error was recorded and the subsequent HTTP call is successful.

### Why
Our React Proxy SDK will set an error based on the error event fired from this SDK when the HTTP call fails. Without this event we have no way to effectively reset this error, because the only event that is fired consistently is the UPDATE event, which is only fired when the response is not 304. This does not cover the case when you intermittently lose connection and the next call succeeds with 304.

### How
We keep track of the sdkState internally in the SDK. When the HTTP calls error we change the state of the SDK to be in an error state. If the internal SDK state is set to error, and the HTTP call is successful we will emit a RECOVERED event that will allow subscribers to clear out stale errors and set the sdkState back to 'healthy'.
  • Loading branch information
FredrikOseberg authored Nov 29, 2023
1 parent bac049d commit 19e3476
Show file tree
Hide file tree
Showing 3 changed files with 74 additions and 0 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ unleash.on('update', () => {
- **initialized** - emitted after the SDK has read local cached data in the storageProvider.
- **ready** - emitted after the SDK has successfully started and performed the initial fetch towards the Unleash Proxy.
- **update** - emitted every time the Unleash Proxy return a new feature toggle configuration. The SDK will emit this event as part of the initial fetch from the SDK.
- **recovered** - emitted when the SDK has recovered from an error. This event will only be emitted if the SDK has previously emitted an error.
- **sent** - emitted when the SDK has successfully sent metrics to Unleash.

> PS! Please remember that you should always register your event listeners before your call `unleash.start()`. If you register them after you have started the SDK you risk loosing important events.
Expand Down
53 changes: 53 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1573,3 +1573,56 @@ test('Should report metrics', async () => {
});
client.stop();
});

test('Should emit RECOVERED event when sdkStatus is error and status is less than 400', (done) => {
const data = { status: 200 }; // replace with the actual data you want to test
fetchMock.mockResponseOnce(JSON.stringify(data), { status: 200 });

const config: IConfig = {
url: 'http://localhost/test',
clientKey: '12',
appName: 'web',
};

const client = new UnleashClient(config);

client.start();

client.on(EVENTS.INIT, () => {
// Set error after the SDK has moved through the sdk states internally
// eslint-disable-next-line
// @ts-ignore - Private method by design, but we want to access it in tests
client.sdkState = 'error';
});

client.on(EVENTS.RECOVERED, () => {
// eslint-disable-next-line
// @ts-ignore - Private method by design. but we want to access it in tests
expect(client.sdkState).toBe('healthy');
client.stop();
done();
});
});

test('Should set sdkState to healthy when client is started', (done) => {
const config: IConfig = {
url: 'http://localhost/test',
clientKey: '12',
appName: 'web',
};

const client = new UnleashClient(config);
// eslint-disable-next-line
// @ts-ignore - Private method by design, but we want to access it in tests
expect(client.sdkState).toBe('initializing');

client.start();

client.on(EVENTS.INIT, () => {
// eslint-disable-next-line
// @ts-ignore - Private method by design, but we want to access it in tests
expect(client.sdkState).toBe('healthy');
client.stop();
done();
});
});
19 changes: 19 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const EVENTS = {
UPDATE: 'update',
IMPRESSION: 'impression',
SENT: 'sent',
RECOVERED: 'recovered',
};

const IMPRESSION_EVENTS = {
Expand All @@ -81,6 +82,8 @@ const defaultVariant: IVariant = {
};
const storeKey = 'repo';

type SdkState = 'initializing' | 'healthy' | 'error';

export const resolveFetch = () => {
try {
if (typeof window !== 'undefined' && 'fetch' in window) {
Expand Down Expand Up @@ -130,6 +133,7 @@ export class UnleashClient extends TinyEmitter {
private readyEventEmitted = false;
private usePOSTrequests = false;
private started = false;
private sdkState: SdkState;

constructor({
storageProvider,
Expand Down Expand Up @@ -177,11 +181,13 @@ export class UnleashClient extends TinyEmitter {
this.refreshInterval = disableRefresh ? 0 : refreshInterval * 1000;
this.context = { appName, environment, ...context };
this.usePOSTrequests = usePOSTrequests;
this.sdkState = 'initializing';
this.ready = new Promise((resolve) => {
this.init()
.then(resolve)
.catch((error) => {
console.error(error);
this.sdkState = 'error';
this.emit(EVENTS.ERROR, error);
resolve();
});
Expand Down Expand Up @@ -324,6 +330,8 @@ export class UnleashClient extends TinyEmitter {
this.toggles = this.bootstrap;
this.emit(EVENTS.READY);
}

this.sdkState = 'healthy';
this.emit(EVENTS.INIT);
}

Expand Down Expand Up @@ -415,11 +423,20 @@ export class UnleashClient extends TinyEmitter {
body,
signal,
});
if (this.sdkState === 'error' && response.status < 400) {
this.sdkState = 'healthy';
this.emit(EVENTS.RECOVERED);
}

if (response.ok && response.status !== 304) {
this.etag = response.headers.get('ETag') || '';
const data = await response.json();
await this.storeToggles(data.toggles);

if (this.sdkState !== 'healthy') {
this.sdkState = 'healthy';
}

if (!this.bootstrap && !this.readyEventEmitted) {
this.emit(EVENTS.READY);
this.readyEventEmitted = true;
Expand All @@ -428,13 +445,15 @@ export class UnleashClient extends TinyEmitter {
console.error(
'Unleash: Fetching feature toggles did not have an ok response'
);
this.sdkState = 'error';
this.emit(EVENTS.ERROR, {
type: 'HttpError',
code: response.status,
});
}
} catch (e) {
console.error('Unleash: unable to fetch feature toggles', e);
this.sdkState = 'error';
this.emit(EVENTS.ERROR, e);
} finally {
this.abortController = null;
Expand Down

0 comments on commit 19e3476

Please sign in to comment.