Skip to content

createOpenFeatureAdapter caches a rejected init promise forever, permanently serving defaultValue #473

Description

@scottt732

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 healthy

Impact

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:

  1. It emits no network activity after the first failure. Per-netns AttemptFails stayed 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.
  2. Every flag reports the identical error, because it is literally the same Error object 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 (current latest)
  • flags: 4.2.0
  • @openfeature/server-sdk: 1.22.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions