Skip to content

feat: Adds feature flags runtime client with local evaluation#1511

Merged
stanleyphu merged 16 commits intomainfrom
feat/add-feature-flags-runtime-client
Apr 2, 2026
Merged

feat: Adds feature flags runtime client with local evaluation#1511
stanleyphu merged 16 commits intomainfrom
feat/add-feature-flags-runtime-client

Conversation

@stanleyphu
Copy link
Copy Markdown
Contributor

Description

  • Adds FeatureFlagsRuntimeClient, a polling-based client that fetches all flag configurations from /sdk/feature-flags and evaluates them locally in-memory. Targeting rules (org-level and user-level) are resolved client-side with zero per-request API overhead.
  • Exposed via workos.featureFlags.createRuntimeClient(options?).

Implementation Details

  • InMemoryStore — atomic-swap key/value store for flag data
  • Evaluator — stateless evaluation: flag enabled → org target → user target → default value
  • FeatureFlagsRuntimeClient — orchestrates polling, caching, and evaluation
    • Configurable polling interval (default 30s, min 5s) with jitter
    • Exponential backoff on consecutive errors (capped at 60s)
    • Stops polling on 401 and emits 'failed' event
    • waitUntilReady() with optional timeout
    • Bootstrap flags for instant availability before first poll
    • 'change' events when flags are added, modified, or removed
    • getStats() for observability

Usage

Basic usage

import { WorkOS } from '@workos-inc/node';

const workos = new WorkOS('sk_test_...');
const client = workos.featureFlags.createRuntimeClient();

await client.waitUntilReady();

if (client.isEnabled('new-dashboard')) {
  // serve new dashboard
}

With targeting context

// User-level targeting
client.isEnabled('beta-feature', { userId: 'user_123' });

// Org-level targeting
client.isEnabled('enterprise-feature', { organizationId: 'org_456' });

// Both
client.isEnabled('rollout-feature', {
  userId: 'user_123',
  organizationId: 'org_456',
});

// Custom default when flag doesn't exist
client.isEnabled('unknown-flag', {}, true); // returns true instead of false

Configuration options

const client = workos.featureFlags.createRuntimeClient({
  pollingIntervalMs: 10000,    // poll every 10s (min 5s)
  requestTimeoutMs: 5000,      // timeout per poll request
  bootstrapFlags: cachedFlags,  // pre-populate store for instant reads
  logger: console,              // any object with debug/info/warn/error
});

Reacting to changes

client.on('change', ({ key, previous, current }) => {
  console.log(`Flag ${key} changed`, { previous, current });
});

client.on('error', (err) => {
  console.error('Poll failed, will retry:', err.message);
});

client.on('failed', (err) => {
  // Unrecoverable (e.g. 401) — client has stopped polling
  console.error('Client stopped:', err.message);
});

Evaluating all flags at once

const flags = client.getAllFlags({ userId: 'user_123' });
// { 'new-dashboard': true, 'beta-feature': false, ... }

Observability

const stats = client.getStats();
// { pollCount: 42, pollErrorCount: 1, cacheAge: 12340, flagCount: 5, ... }

Cleanup

client.close(); // stops polling, removes listeners

Documentation

Does this require changes to the WorkOS Docs? E.g. the API Reference or code snippets need updates.

[ ] Yes

If yes, link a related docs PR and add a docs maintainer as a reviewer. Their approval is required.

@stanleyphu stanleyphu changed the title Adds feature flags runtime client with local evaluation feat: Adds feature flags runtime client with local evaluation Mar 3, 2026
stanleyphu and others added 12 commits March 26, 2026 16:11
Add interfaces for the runtime client: EvaluationContext, FlagPollEntry,
FlagPollResponse, RuntimeClientOptions, RuntimeClientStats, and
RuntimeClientLogger. These define the contract for the polling-based
feature flag client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Simple in-memory store that holds the polling response. Supports atomic
swap of the full flag map, O(1) lookup by slug, and size tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Synchronous flag evaluation against the in-memory store. Evaluation
order: flag not found → defaultValue, flag disabled → false, org
target match → target.enabled, user target match → target.enabled,
no match → default_value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EventEmitter-based client that polls GET /sdk/feature-flags, caches
flags in memory, and evaluates locally. Features: configurable polling
interval with jitter, per-request timeout via Promise.race, bootstrap
flags, waitUntilReady with timeout, change events on subsequent polls,
and 401 detection that stops polling and emits 'failed'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds createRuntimeClient() method to FeatureFlags module, allowing
users to create a polling-based runtime client via workos.featureFlags.createRuntimeClient().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds exponential backoff (1s base, 2x multiplier, 60s cap) when polls
fail consecutively. The backoff delay is used when it exceeds the
normal polling interval, and resets after a successful poll.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
User-level targets are more specific than organization-level targets and
should take precedence when both are provided in the evaluation context.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tion

Defer the first poll via setTimeout(0) so callers can attach error/failed
event listeners before the initial network request fires. Replace
JSON.stringify-based flag comparison with field-by-field checks to avoid
false change events from key ordering differences.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Introduces a pollAbortController that races alongside the fetch and
timeout promises. close() now aborts any in-flight request immediately
instead of letting it complete silently in the background.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add explicit return type to getFlag() and return a shallow copy from
InMemoryStore.getAll() to prevent external mutation of internal state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@stanleyphu stanleyphu force-pushed the feat/add-feature-flags-runtime-client branch from 859c04e to 6d1c765 Compare March 27, 2026 19:14
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@stanleyphu stanleyphu marked this pull request as ready for review March 27, 2026 21:28
@stanleyphu stanleyphu requested review from a team as code owners March 27, 2026 21:28
@stanleyphu stanleyphu requested a review from gjtorikian March 27, 2026 21:28
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 27, 2026

Greptile Summary

This PR introduces FeatureFlagsRuntimeClient, a polling-based, locally-evaluating feature-flag client exposed via workos.featureFlags.createRuntimeClient(). It includes an InMemoryStore for atomic flag swaps, an Evaluator for user/org targeting, configurable polling with jitter and exponential backoff, bootstrap support for instant availability, change-event diffing, and a waitUntilReady() promise with optional timeout — all well-covered by unit tests.

Key changes:

  • runtime-client.ts — core client with polling loop, abort handling, backoff, and change detection
  • evaluator.ts — stateless evaluation: user target → org target → default_value precedence
  • in-memory-store.ts — defensive-copy swap store
  • feature-flags.tscreateRuntimeClient() factory method
  • New interfaces: EvaluationContext, FlagPollResponse, RuntimeClientOptions, RuntimeClientStats

Issues found:

  • P1close() does not call readyReject, so any waitUntilReady() caller without a timeout will hang indefinitely if the client is closed before it becomes ready.
  • P2waitUntilReady({ timeoutMs: 0 }) is silently treated as "no timeout" because !0 === true bypasses the timeout branch.

Confidence Score: 4/5

Safe to merge after addressing the P1 close() issue that can cause process hang on shutdown.

One P1 defect remains: close() never calls readyReject, meaning a pending waitUntilReady() without a timeout will hang forever if close() is invoked before initialization completes. All previously raised concerns (abort guard, order-sensitive diff, 401 rejection) were addressed.

src/feature-flags/runtime-client.ts — specifically the close() method and waitUntilReady guard condition

Important Files Changed

Filename Overview
src/feature-flags/runtime-client.ts Core polling client; close() does not reject pending readyPromise, risking indefinite hangs on shutdown. Minor falsy-check issue in waitUntilReady.
src/feature-flags/evaluator.ts Stateless flag evaluator: user target → org target → default_value precedence. Logic is correct and well-tested.
src/feature-flags/in-memory-store.ts Simple atomic-swap key/value store; defensive copies on read and write look correct.
src/feature-flags/feature-flags.ts Adds createRuntimeClient factory method delegating to FeatureFlagsRuntimeClient; straightforward change.
src/feature-flags/runtime-client.spec.ts Comprehensive test suite covering polling, backoff, change events, error handling, and bootstrap flags.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant Client as FeatureFlagsRuntimeClient
    participant Store as InMemoryStore
    participant Evaluator
    participant API as WorkOS API

    Caller->>Client: createRuntimeClient(options)
    Note over Client: Bootstrap flags → store.swap(), resolveReady()
    Client-->>Client: setTimeout(() => poll(), 0)

    Client->>API: GET /sdk/feature-flags
    alt success
        API-->>Client: FlagPollResponse
        Client->>Store: store.swap(data)
        Client-->>Client: resolveReady()
        Client-->>Caller: emit('change', ...) [if 2nd+ poll]
        Client-->>Client: scheduleNextPoll(baseDelay + jitter)
    else error (non-401)
        API-->>Client: Error
        Client-->>Caller: emit('error', err)
        Client-->>Client: scheduleNextPoll(backoff + jitter)
    else 401 Unauthorized
        API-->>Client: UnauthorizedException
        Client-->>Caller: emit('error', err)
        Client-->>Caller: emit('failed', err)
        Client-->>Client: readyReject(err) [if not initialized]
        Note over Client: Polling stops permanently
    end

    Caller->>Client: waitUntilReady({ timeoutMs? })
    Client-->>Caller: readyPromise (resolves or rejects)

    Caller->>Client: close()
    Client-->>Client: closed=true, abort in-flight, clearTimer, removeAllListeners
Loading

Comments Outside Diff (1)

  1. src/feature-flags/runtime-client.ts, line 1022-1030 (link)

    P1 close() leaves pending waitUntilReady() promises hanging indefinitely

    close() aborts the in-flight poll and clears the timer, but never calls this.readyReject. Any caller currently awaiting waitUntilReady() without a timeoutMs (e.g. during graceful shutdown) will have their promise suspended forever, preventing the Node.js process from exiting and leaking the pending microtask.

    Concrete scenario:

    const client = workos.featureFlags.createRuntimeClient();
    const ready = client.waitUntilReady(); // no timeout
    // ... server is unreachable, first poll is in-flight
    client.close();
    await ready; // hangs forever — process cannot exit

    Fix: reject readyPromise in close():

    close(): void {
      this.closed = true;
      this.pollAbortController?.abort();
      if (this.pollTimer) {
        clearTimeout(this.pollTimer);
        this.pollTimer = null;
      }
      if (this.readyReject) {
        this.readyReject(new Error('Client closed before becoming ready'));
        this.readyReject = null;
      }
      this.removeAllListeners();
    }

Reviews (3): Last reviewed commit: "fix: Reject waitUntilReady on 401 before..." | Re-trigger Greptile

stanleyphu and others added 2 commits March 27, 2026 14:55
…t comparison

Prevent unhandled 'error' event crash when close() aborts an in-flight
poll by short-circuiting the catch block when closed. Switch target
comparison to a Map-based lookup so reordered targets from the server
don't trigger spurious change events.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@gjtorikian
Copy link
Copy Markdown
Contributor

@greptile review

If the first poll returns a 401, the ready promise was never settled,
causing waitUntilReady() without a timeout to hang indefinitely. Now
reject the ready promise with the UnauthorizedException so callers
get a clear error instead of hanging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@gjtorikian
Copy link
Copy Markdown
Contributor

@greptile review

@stanleyphu stanleyphu merged commit 581c618 into main Apr 2, 2026
8 checks passed
@stanleyphu stanleyphu deleted the feat/add-feature-flags-runtime-client branch April 2, 2026 17:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants