Repository navigation
Description
Summary
createOpenFeatureAdapter caches the initialization promise but never clears it on rejection. If init() rejects once, every subsequent flag evaluation re-throws that same cached error for the remaining life of the process. Nothing retries and nothing reconnects — the only recovery is a restart.
The code
packages/adapter-openfeature/src/index.ts (current main, and shipped in @flags-sdk/openfeature@0.1.2):
let client: Client | null = typeof init === 'function' ? null : init;
let clientPromise: Promise<Client>;
async function initialize() {
if (client) return client;
if (clientPromise) return clientPromise; // <-- returns the rejected promise forever
clientPromise = typeof init === 'function' ? init() : Promise.resolve(init);
client = await clientPromise;
return clientPromise;
}When init() rejects, client stays null and clientPromise remains a rejected promise. On the next call, client is falsy so the clientPromise guard hits and returns the rejected promise again. There is no catch and no reset.
Reproduction
const init = vi.fn()
.mockRejectedValueOnce(new Error('transient connect failure'))
.mockResolvedValue(client);
const adapter = createOpenFeatureAdapter(init);
const boolean = adapter.booleanValue();
await boolean.decide({ key: 'k', entities: {}, defaultValue: false }); // throws (expected)
await boolean.decide({ key: 'k', entities: {}, defaultValue: false }); // throws again
// init was called once; it never retries even though the provider is now healthyImpact
This bit us in production and took a long time to diagnose. A single failed provider connect during startup left pods returning defaultValue for every flag until restarted — silently disabling gated features for users, with the flag backend perfectly healthy the whole time.
Two things made it especially hard to track down:
- It emits no network activity after the first failure. Per-netns
AttemptFailsstayed at exactly 0 while the process logged thousands of connect errors per minute — it was never dialing, just replaying one cached rejection. That sent us chasing the network, the service mesh, and the flag server, all of which were healthy. - Every flag reports the identical error, because it is literally the same
Errorobject replayed once per flag per render. With a few hundred flags evaluated per request that looks like a storm of independent failures rather than one cached one.
It is also cold-start sensitive: instances that boot straight into live traffic can lose the init race and are then poisoned permanently, while instances that come up quiet are fine. That produces a confusing partial outage where a fraction of instances serve wrong flag values indefinitely.
Suggested fix
Clear the cached promise when it rejects, so the next call retries:
let clientPromise: Promise<Client> | null = null;
async function initialize() {
if (client) return client;
if (clientPromise) return clientPromise;
const attempt = (typeof init === 'function' ? init() : Promise.resolve(init));
clientPromise = attempt;
attempt.catch(() => {
if (clientPromise === attempt) clientPromise = null; // allow a retry
});
client = await attempt;
return attempt;
}This keeps the existing single-flight behaviour for concurrent callers while making failures recoverable. Note that consumers cannot work around this from the outside: retrying inside their own init callback still hands the adapter a promise that it caches permanently, so the fix has to be here.
Versions
@flags-sdk/openfeature: 0.1.2 (currentlatest)flags: 4.2.0@openfeature/server-sdk: 1.22.0
Activity
dferber90 commentedon Aug 13, 2026
Thank you for the detailed report and digging in. I prepared a PR to fix here #474
dferber90 commentedon Aug 14, 2026
Released in
@flags-sdk/openfeature@0.1.3[openfeature] add close() to the adapter