Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ 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.

## SDK Versioning

Expand Down
107 changes: 99 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,111 @@ 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);

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('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();
});
});
});
13 changes: 12 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,17 @@ 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 && !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}`,
Comment thread
stanleyphu marked this conversation as resolved.
Outdated
);
}
this.logger?.error('Poll failed', err);

if (err instanceof UnauthorizedException) {
Expand Down
Loading