Skip to content
Draft
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
---

Add `close()` to the adapter

`close()` reverts the adapter to its uninitialized state, so the next flag evaluation initializes it again, as the OpenFeature provider specification describes for shutdown. An initialization that is still in flight is awaited first, and repeated calls without an intervening evaluation do nothing further.

Pass the new `onClose` option to dispose of whatever your `init` function set up, e.g. `{ onClose: () => OpenFeature.close() }`. The adapter cannot do this on your behalf, because an OpenFeature client cannot be closed on its own, and shutting down providers means closing them on the global `OpenFeature` API, which would also affect providers this adapter never registered.
20 changes: 20 additions & 0 deletions packages/adapter-openfeature/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,26 @@ const openFeatureAdapter = createOpenFeatureAdapter(async () => {
});
```

## Shutdown

Call `close()` to revert the adapter to its uninitialized state. The next flag evaluation initializes it again, which re-runs the `init` function you passed in. Any initialization still in flight is awaited first, and calling `close()` repeatedly without an intervening evaluation does nothing further.

Pass `onClose` to dispose of whatever your `init` function set up. The adapter can not do this for you: an OpenFeature client can not be closed on its own, and shutting down providers means closing them on the global `OpenFeature` API, which would also affect providers this adapter never registered.

```ts
const openFeatureAdapter = createOpenFeatureAdapter(
async () => {
await OpenFeature.setProviderAndWait(new YourProviderOfChoice());
return OpenFeature.getClient();
},
{ onClose: () => OpenFeature.close() },
);

await openFeatureAdapter.close();
```

Note that when you pass a client directly instead of an `init` function, the adapter has nothing to re-create, so evaluations after a `close()` keep using that same client.

## Documentation

Please check out the [OpenFeature provider documentation](https://flags-sdk.dev/docs/api-reference/adapters/openfeature) for more information.
102 changes: 102 additions & 0 deletions packages/adapter-openfeature/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,17 @@ describe('OpenFeature Adapter', () => {
expect(adapter.client).toBe(mockClient);
});
});

describe('close', () => {
it('should hand the provided client to onClose', async () => {
const onClose = vi.fn();
const adapter = createOpenFeatureAdapter(mockClient, { onClose });

await adapter.close();

expect(onClose).toHaveBeenCalledWith(mockClient);
});
});
});

describe('async client', () => {
Expand Down Expand Up @@ -318,6 +329,97 @@ describe('OpenFeature Adapter', () => {
expect(initFn).toHaveBeenCalledTimes(1);
});

describe('close', () => {
it('should re-initialize on the next evaluation', async () => {
const initFn = vi.fn(async () => mockClient);
const onClose = vi.fn();
const adapter = createOpenFeatureAdapter(initFn, { onClose });

await adapter.client();
await adapter.close();

expect(onClose).toHaveBeenCalledWith(mockClient);
await expect(adapter.client()).resolves.toBe(mockClient);
expect(initFn).toHaveBeenCalledTimes(2);
});

it('should do nothing when never initialized', async () => {
const initFn = vi.fn(async () => mockClient);
const onClose = vi.fn();
const adapter = createOpenFeatureAdapter(initFn, { onClose });

await adapter.close();

expect(initFn).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
});

it('should be idempotent', async () => {
const onClose = vi.fn();
const adapter = createOpenFeatureAdapter(async () => mockClient, {
onClose,
});

await adapter.client();
await adapter.close();
await adapter.close();
await Promise.all([adapter.close(), adapter.close()]);

expect(onClose).toHaveBeenCalledTimes(1);
});

it('should dispose a client that initialized during the shutdown', async () => {
const delay = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
const initFn = vi.fn(async () => {
await delay(5);
return mockClient;
});
const onClose = vi.fn();
const adapter = createOpenFeatureAdapter(initFn, { onClose });

const clientPromise = adapter.client();
await adapter.close();

await expect(clientPromise).resolves.toBe(mockClient);
expect(onClose).toHaveBeenCalledWith(mockClient);
});

it('should not dispose anything when initialization failed', async () => {
const initFn = vi
.fn<() => Promise<Client>>()
.mockRejectedValue(new Error('transient connect failure'));
const onClose = vi.fn();
const adapter = createOpenFeatureAdapter(initFn, { onClose });

await expect(adapter.client()).rejects.toThrow(
'transient connect failure',
);
await adapter.close();

expect(onClose).not.toHaveBeenCalled();
});

it('should make evaluations started during a shutdown wait for a fresh client', async () => {
const delay = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
const initFn = vi.fn(async () => mockClient);
const onClose = vi.fn(async () => {
await delay(5);
});
const adapter = createOpenFeatureAdapter(initFn, { onClose });

await adapter.client();
const closed = adapter.close();
const clientPromise = adapter.client();

await closed;
await expect(clientPromise).resolves.toBe(mockClient);
expect(initFn).toHaveBeenCalledTimes(2);
expect(onClose).toHaveBeenCalledTimes(1);
});
});

it('should only initialize the client once', async () => {
const delay = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
Expand Down
64 changes: 63 additions & 1 deletion packages/adapter-openfeature/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,34 @@ type AdapterResponse<ClientType> = {
options?: FlagEvaluationOptions,
) => Adapter<ValueType, EvaluationContext>;
client: ClientType;
/**
* Reverts the adapter to its uninitialized state, so the next flag
* evaluation initializes it again. Any in-flight initialization is awaited
* first, so the client it produces is handed to `onClose` rather than
* leaked. Calling this repeatedly without an intervening evaluation does
* nothing further.
*
* @see https://openfeature.dev/specification/sections/providers#25-shutdown
*/
close: () => Promise<void>;
};

export type OpenFeatureAdapterOptions = {
/**
* Called by `close()` with the initialized client, to dispose of whatever
* the adapter's `init` function set up.
*
* The adapter can not do this on your behalf: an OpenFeature client can not
* be closed on its own, and shutting down providers means closing them on
* the global `OpenFeature` API, which affects providers this adapter never
* registered.
*
* @example
* ```
* createOpenFeatureAdapter(init, { onClose: () => OpenFeature.close() });
* ```
*/
onClose?: (client: Client) => void | Promise<void>;
};

/**
Expand Down Expand Up @@ -51,7 +79,10 @@ function isFatalError(error: unknown): boolean {
* });
* ```
*/
export function createOpenFeatureAdapter(init: Client): AdapterResponse<Client>;
export function createOpenFeatureAdapter(
init: Client,
options?: OpenFeatureAdapterOptions,
): AdapterResponse<Client>;

/**
* Creates an async OpenFeature adapter.
Expand All @@ -65,14 +96,20 @@ export function createOpenFeatureAdapter(init: Client): AdapterResponse<Client>;
*/
export function createOpenFeatureAdapter(
init: () => Promise<Client>,
options?: OpenFeatureAdapterOptions,
): AdapterResponse<() => Promise<Client>>;
export function createOpenFeatureAdapter(
init: Client | (() => Promise<Client>),
adapterOptions?: OpenFeatureAdapterOptions,
): AdapterResponse<Client | (() => Promise<Client>)> {
let client: Client | null = typeof init === 'function' ? null : init;

let clientPromise: Promise<Client> | null = null;
let closePromise: Promise<void> | null = null;
function initialize(): Client | Promise<Client> {
// A shutdown in progress is about to discard the current client, so wait
// for it to finish and initialize from scratch afterwards.
if (closePromise) return closePromise.then(initialize);
if (client) return client;
if (clientPromise) return clientPromise;

Expand All @@ -97,6 +134,30 @@ export function createOpenFeatureAdapter(
return attempt;
}

function close(): Promise<void> {
if (closePromise) return closePromise;

const pendingClient = clientPromise ?? client;
if (!pendingClient) return Promise.resolve();

closePromise = (async () => {
// Awaiting the in-flight attempt lets its client be disposed instead of
// leaked. A failed attempt produced nothing to dispose.
const initializedClient = await Promise.resolve(pendingClient).catch(
() => null,
);

client = null;
clientPromise = null;

if (initializedClient) await adapterOptions?.onClose?.(initializedClient);
})().finally(() => {
closePromise = null;
});

return closePromise;
}

function booleanValue(
options?: FlagEvaluationOptions,
): Adapter<boolean, EvaluationContext> {
Expand Down Expand Up @@ -170,6 +231,7 @@ export function createOpenFeatureAdapter(
stringValue,
numberValue,
objectValue,
close,
client:
typeof init === 'function'
? async () => {
Expand Down
Loading