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
9 changes: 9 additions & 0 deletions .changeset/tidy-hounds-shout.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 59 additions & 0 deletions packages/adapter-openfeature/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Client>>()
.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<Client>>()
.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<Client>>(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));
Expand Down
53 changes: 43 additions & 10 deletions packages/adapter-openfeature/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ type AdapterResponse<ClientType> = {
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
Expand Down Expand Up @@ -55,21 +71,38 @@ export function createOpenFeatureAdapter(
): AdapterResponse<Client | (() => Promise<Client>)> {
let client: Client | null = typeof init === 'function' ? null : init;

let clientPromise: Promise<Client>;
async function initialize() {
let clientPromise: Promise<Client> | null = null;
function initialize(): Client | Promise<Client> {
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(
options?: FlagEvaluationOptions,
): Adapter<boolean, EvaluationContext> {
return {
async decide({ key, entities, defaultValue }): Promise<boolean> {
await initialize();
const client = await initialize();
if (!client) return defaultValue as boolean;
return client.getBooleanValue(
key,
Expand All @@ -86,7 +119,7 @@ export function createOpenFeatureAdapter(
): Adapter<string, EvaluationContext> {
return {
async decide({ key, entities, defaultValue }): Promise<string> {
await initialize();
const client = await initialize();
if (!client) return defaultValue as string;
return client.getStringValue(
key,
Expand All @@ -103,7 +136,7 @@ export function createOpenFeatureAdapter(
): Adapter<number, EvaluationContext> {
return {
async decide({ key, entities, defaultValue }): Promise<number> {
await initialize();
const client = await initialize();
if (!client) return defaultValue as number;
return client.getNumberValue(
key,
Expand All @@ -120,7 +153,7 @@ export function createOpenFeatureAdapter(
): Adapter<ValueType, EvaluationContext> {
return {
async decide({ key, entities, defaultValue }): Promise<ValueType> {
await initialize();
const client = await initialize();
if (!client) return defaultValue as ValueType;
return client.getObjectValue(
key,
Expand All @@ -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',
Expand Down
Loading