Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,9 @@ polling populates the cache afterward.

A failed poll, including one that returns a payload the SDK can't read, emits
`error`, keeps the last good snapshot, and retries with backoff. A 401 stops
polling and also emits `failed`.
polling and also emits `failed`. Attach an `error` listener or a `logger` to see
failures; with neither, the client warns once and keeps polling, and warns again
if a 401 stops it.

## SDK Versioning

Expand Down
141 changes: 133 additions & 8 deletions src/feature-flags/runtime-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,20 +558,145 @@ describe('FeatureFlagsRuntimeClient', () => {
});

it('throws on an error event when no listener is attached', () => {
// poll()'s catch block emits 'error'. With no listener, the emitter must
// throw (Node semantics) rather than silently drop it — the behavior
// eventemitter3 lacked and the old emit() override restored. Every other
// error test attaches a listener first, so this is the only guard against
// re-introducing silent error-dropping. Asserted on the inherited emit
// directly: via the real poll path the throw would surface as an
// unhandled promise rejection (poll runs in a setTimeout), which can't be
// observed deterministically.
// With no listener, the emitter must throw (Node semantics) rather than
// silently drop an 'error' — the behavior eventemitter3 lacked. poll()
// checks for listeners before emitting; the tests below cover that path.
const client = workos.featureFlags.createRuntimeClient();

const boom = new Error('boom');
expect(() => client.emit('error', boom)).toThrow(boom);

client.close();
});

it('keeps polling when a poll fails with no error listener', async () => {
const emitWarning = jest
.spyOn(workos, 'emitWarning')
.mockImplementation(() => {});
fetch.mockRejectOnce(new Error('Network error'));

const client = workos.featureFlags.createRuntimeClient();
await jest.advanceTimersByTimeAsync(0);

fetchOnce(pollResponse);
await jest.advanceTimersByTimeAsync(35_000);

expect(client.getStats()).toMatchObject({
pollCount: 2,
pollErrorCount: 1,
flagCount: 2,
});
expect(client.isEnabled('flag-a')).toBe(true);

client.close();
emitWarning.mockRestore();
});

it('rejects waitUntilReady and emits failed on 401 with no error listener', async () => {
const emitWarning = jest
.spyOn(workos, 'emitWarning')
.mockImplementation(() => {});
fetchOnce(
{ message: 'Unauthorized' },
{ status: 401, headers: { 'X-Request-ID': 'req_123' } },
);

const client = workos.featureFlags.createRuntimeClient();
const failures: unknown[] = [];
client.on('failed', (err) => failures.push(err));
let readyError: unknown;
client.waitUntilReady().catch((err) => {
readyError = err;
});

await jest.advanceTimersByTimeAsync(0);

expect(readyError).toBeInstanceOf(UnauthorizedException);
expect(failures).toHaveLength(1);
expect(fetch.mock.calls.length).toBe(1);
expect(emitWarning).toHaveBeenCalledTimes(1);
expect(emitWarning).toHaveBeenCalledWith(
expect.stringContaining('stopped after a 401'),
);
expect(emitWarning).not.toHaveBeenCalledWith(
expect.stringContaining('retrying'),
);

client.close();
emitWarning.mockRestore();
});

it('warns once when polls fail with no error listener or logger', async () => {
const emitWarning = jest
.spyOn(workos, 'emitWarning')
.mockImplementation(() => {});
fetch.mockRejectOnce(new Error('Network error'));
fetch.mockRejectOnce(new Error('Network error'));

const client = workos.featureFlags.createRuntimeClient();
await jest.advanceTimersByTimeAsync(0);
await jest.advanceTimersByTimeAsync(35_000);

expect(client.getStats().pollErrorCount).toBe(2);
expect(emitWarning).toHaveBeenCalledTimes(1);
expect(emitWarning).toHaveBeenCalledWith(
expect.stringContaining("no 'error' listener or logger"),
);

client.close();
emitWarning.mockRestore();
});

it('warns about a 401 after an earlier unobserved failure', async () => {
const emitWarning = jest
.spyOn(workos, 'emitWarning')
.mockImplementation(() => {});
fetch.mockRejectOnce(new Error('Network error'));
fetchOnce(
{ message: 'Unauthorized' },
{ status: 401, headers: { 'X-Request-ID': 'req_123' } },
);

const client = workos.featureFlags.createRuntimeClient();
await jest.advanceTimersByTimeAsync(0);
await jest.advanceTimersByTimeAsync(35_000);

expect(emitWarning).toHaveBeenCalledTimes(2);
expect(emitWarning).toHaveBeenLastCalledWith(
expect.stringContaining('stopped after a 401'),
);

fetchOnce(pollResponse);
await jest.advanceTimersByTimeAsync(60_000);
expect(fetch.mock.calls.length).toBe(2);

client.close();
emitWarning.mockRestore();
});

it('logs instead of warning when a logger is attached', async () => {
const emitWarning = jest
.spyOn(workos, 'emitWarning')
.mockImplementation(() => {});
const logger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
fetch.mockRejectOnce(new Error('Network error'));

const client = workos.featureFlags.createRuntimeClient({ logger });
await jest.advanceTimersByTimeAsync(0);

expect(logger.error).toHaveBeenCalledWith(
'Poll failed',
expect.any(Error),
);
expect(emitWarning).not.toHaveBeenCalled();

client.close();
emitWarning.mockRestore();
});
});
});
19 changes: 18 additions & 1 deletion src/feature-flags/runtime-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export class FeatureFlagsRuntimeClient extends EventEmitter<RuntimeClientEvents>
private closed = false;
private initialized = false;
private consecutiveErrors = 0;
private warnedUnobservedError = false;
private pollTimer: ReturnType<typeof setTimeout> | null = null;
private pollAbortController: AbortController | null = null;

Expand Down Expand Up @@ -196,7 +197,23 @@ export class FeatureFlagsRuntimeClient extends EventEmitter<RuntimeClientEvents>

this.consecutiveErrors++;
this.stats.pollErrorCount++;
this.emit('error', err);
// A background poll has no caller to throw to. Emitting 'error' with no
// listener throws, which would skip the retry below and surface as an
// unhandled rejection, so an unobserved failure is reported instead.
if (this.listenerCount('error') > 0) {
this.emit('error', err);
} else if (!this.logger) {
if (err instanceof UnauthorizedException) {
this.workos.emitWarning(
`Feature flag sync stopped after a 401 and no 'error' listener or logger is attached; keeping the last good flags. Recreate the client with a valid API key to resume. ${err.message}`,
);
} else if (!this.warnedUnobservedError) {
this.warnedUnobservedError = true;
this.workos.emitWarning(
`Feature flag sync failed and no 'error' listener or logger is attached; keeping the last good flags and retrying. ${err.message}`,
);
}
}
this.logger?.error('Poll failed', err);

if (err instanceof UnauthorizedException) {
Expand Down
Loading