diff --git a/.changeset/tidy-hounds-shout.md b/.changeset/tidy-hounds-shout.md new file mode 100644 index 00000000..0ec4c6e4 --- /dev/null +++ b/.changeset/tidy-hounds-shout.md @@ -0,0 +1,9 @@ +--- +'@flags-sdk/openfeature': patch +--- + +Retry initialization after a failed attempt + +Previously a rejected `init()` was cached forever, so a single transient failure (e.g. a connect error during a cold start) made every later flag evaluation replay that same error for the rest of the process' lifetime, without ever dialing the provider again. + +The rejected attempt is now discarded so the next evaluation retries, while concurrent callers still share a single in-flight attempt. Failures reported by the provider as `PROVIDER_FATAL` — irrecoverable ones such as invalid credentials or configuration — remain cached, since retrying them cannot succeed. diff --git a/packages/adapter-openfeature/src/index.test.ts b/packages/adapter-openfeature/src/index.test.ts index 0f4015c6..9fba7a59 100644 --- a/packages/adapter-openfeature/src/index.test.ts +++ b/packages/adapter-openfeature/src/index.test.ts @@ -259,6 +259,65 @@ describe('OpenFeature Adapter', () => { await expect(adapter.client()).resolves.toBe(mockClient); }); + it('should retry initialization after a failed attempt', async () => { + const initFn = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('transient connect failure')) + .mockResolvedValue(mockClient); + const adapter = createOpenFeatureAdapter(initFn); + const boolean = adapter.booleanValue(); + const decideOptions = { + key: 'test-flag', + defaultValue: false, + entities: {}, + headers: mockHeaders as ReadonlyHeaders, + cookies: mockCookies as ReadonlyRequestCookies, + }; + + await expect(boolean.decide(decideOptions)).rejects.toThrow( + 'transient connect failure', + ); + + vi.mocked(mockClient.getBooleanValue).mockResolvedValue(true); + await expect(boolean.decide(decideOptions)).resolves.toBe(true); + expect(initFn).toHaveBeenCalledTimes(2); + }); + + it('should not retry initialization after a fatal error', async () => { + const fatalError = Object.assign(new Error('invalid credentials'), { + code: 'PROVIDER_FATAL', + }); + const initFn = vi + .fn<() => Promise>() + .mockRejectedValue(fatalError); + const adapter = createOpenFeatureAdapter(initFn); + + await expect(adapter.client()).rejects.toThrow('invalid credentials'); + await expect(adapter.client()).rejects.toThrow('invalid credentials'); + expect(initFn).toHaveBeenCalledTimes(1); + }); + + it('should share a single failed attempt across concurrent callers', async () => { + const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + const initFn = vi.fn<() => Promise>(async () => { + await delay(5); + throw new Error('transient connect failure'); + }); + const adapter = createOpenFeatureAdapter(initFn); + + const results = await Promise.allSettled([ + adapter.client(), + adapter.client(), + ]); + + expect(results.map((result) => result.status)).toEqual([ + 'rejected', + 'rejected', + ]); + expect(initFn).toHaveBeenCalledTimes(1); + }); + it('should only initialize the client once', async () => { const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/packages/adapter-openfeature/src/index.ts b/packages/adapter-openfeature/src/index.ts index 966e4333..3563c37b 100644 --- a/packages/adapter-openfeature/src/index.ts +++ b/packages/adapter-openfeature/src/index.ts @@ -22,6 +22,22 @@ type AdapterResponse = { client: ClientType; }; +/** + * Whether the provider signalled that it can never become ready, e.g. due to + * bad credentials or invalid configuration. Retrying such an initialization is + * pointless, so the failure is cached instead. + * + * @see https://openfeature.dev/specification/sections/providers#24-initialization + */ +function isFatalError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code: unknown }).code === 'PROVIDER_FATAL' + ); +} + /** * Creates a sync OpenFeature adapter. * @param init Client @@ -55,13 +71,30 @@ export function createOpenFeatureAdapter( ): AdapterResponse Promise)> { let client: Client | null = typeof init === 'function' ? null : init; - let clientPromise: Promise; - async function initialize() { + let clientPromise: Promise | null = null; + function initialize(): Client | Promise { if (client) return client; if (clientPromise) return clientPromise; - clientPromise = typeof init === 'function' ? init() : Promise.resolve(init); - client = await clientPromise; - return clientPromise; + + const attempt = ( + typeof init === 'function' ? init() : Promise.resolve(init) + ).then( + (resolvedClient) => { + client = resolvedClient; + return resolvedClient; + }, + (error: unknown) => { + // Initialization failed. Unless the provider declared the failure + // fatal, forget the rejected promise so the next evaluation retries + // instead of replaying this error for the rest of the process' life. + if (clientPromise === attempt && !isFatalError(error)) { + clientPromise = null; + } + throw error; + }, + ); + clientPromise = attempt; + return attempt; } function booleanValue( @@ -69,7 +102,7 @@ export function createOpenFeatureAdapter( ): Adapter { return { async decide({ key, entities, defaultValue }): Promise { - await initialize(); + const client = await initialize(); if (!client) return defaultValue as boolean; return client.getBooleanValue( key, @@ -86,7 +119,7 @@ export function createOpenFeatureAdapter( ): Adapter { return { async decide({ key, entities, defaultValue }): Promise { - await initialize(); + const client = await initialize(); if (!client) return defaultValue as string; return client.getStringValue( key, @@ -103,7 +136,7 @@ export function createOpenFeatureAdapter( ): Adapter { return { async decide({ key, entities, defaultValue }): Promise { - await initialize(); + const client = await initialize(); if (!client) return defaultValue as number; return client.getNumberValue( key, @@ -120,7 +153,7 @@ export function createOpenFeatureAdapter( ): Adapter { return { async decide({ key, entities, defaultValue }): Promise { - await initialize(); + const client = await initialize(); if (!client) return defaultValue as ValueType; return client.getObjectValue( key, @@ -140,7 +173,7 @@ export function createOpenFeatureAdapter( client: typeof init === 'function' ? async () => { - await initialize(); + const client = await initialize(); if (!client) throw new Error( '@flags-sdk/openfeature: OpenFeature client failed to initialize',