Skip to content

feat(feature-flags): Evaluate feature flags locally with a polling runtime client - #765

Open
stanleyphu wants to merge 5 commits into
feat/feature-flags-runtime-evaluationfrom
feat/feature-flags-runtime-sync-client
Open

stanleyphu wants to merge 5 commits into
feat/feature-flags-runtime-evaluationfrom
feat/feature-flags-runtime-sync-client

Conversation

@stanleyphu

@stanleyphu stanleyphu commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Stack. This is #762, split into four PRs for review. Merge them in order; after each merge I'll rebase the next one onto main.

  1. chore(feature-flags): Parse and evaluate flag configurations locally #764 Configuration and evaluation
  2. feat(feature-flags): Evaluate feature flags locally with a polling runtime client #765 Sync runtime client ← this PR
  3. feat(feature-flags): Add an async feature flags runtime client #766 Async runtime client
  4. chore(feature-flags): Keep the runtime clients safe across fork() #767 Fork safety

Description

FeatureFlagsRuntimeClient polls GET /sdk/feature-flags?payload_version=2 on a daemon thread and keeps the configuration from #764 in memory. It evaluates flags locally, with no request per check.

flags = workos.create_feature_flags_runtime_client()
flags.start()
flags.wait_until_ready(timeout=5)
flags.is_enabled("new-checkout", {"user": "user_123", "organization": "org_123"})
  • Failures.
    • A failed poll keeps the last good configuration and backs off exponentially.
    • A 429's Retry-After is honored up to 60 seconds, by retries inside a refresh and before the next poll.
    • Transient failures are retried within one refresh, up to the client's max_retries and within request_timeout.
    • A 401 stops polling for good.
  • Time budget. Each refresh has a total budget. The transport streams the response and gives every network wait only the time left, so a refresh ends within request_timeout plus at most one read of a body that stalls.
  • Also included: bootstrap, on_change, on_error and on_status listeners, status, get_flag(), and a manual refresh() that works without start().
  • Listeners run after the refresh lock is released, one batch at a time and in refresh order, so a listener can call refresh() itself.
  • close() stops polling, and nothing a refresh still in flight does changes the configuration or status after it returns. Every caller waits for the poller, including concurrent ones.
  • Change events follow .NET: one on_change per refresh that installs a new configuration, including the first load.
  • v1 payloads fail the refresh and keep the previous configuration.

Where to look, in order:

  1. _core.py: state, evaluation and listeners.
  2. _sync.py: _poll_forever, _refresh_locked and _drain.
  3. _transport.py: the time budget.
  4. _policy.py: backoff and retry decisions.

Not in this PR: fork handling comes in #767. Until then, the README says to create the runtime client in each worker after forking. Merge #765 through #767 before the next release PR, so the first release includes fork handling.

How I checked it:

  • I ran it in our feature flags runtime lab, side by side with the Node SDK, against the same fixture responses. All 80 side-by-side checks pass: 40 for Python, 40 for Node. They cover:

    • rule order, contexts, disabled flags, unknown rules and non-boolean values;
    • bootstrap;
    • 401, 403 and 500 responses;
    • slow responses and v1 responses.

    The lab's asyncio checks need feat(feature-flags): Add an async feature flags runtime client #766.

  • test_transport.py sends the poll to a real local HTTP server. That includes one server that stalls before answering and one that drips the body a byte at a time. In both cases the refresh ends within its budget.

  • nox -s ci passes locally: 3,203 tests.

Documentation

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

[x] Yes

The feature flags SDK docs describe the runtime client as Node only. A docs PR adding Python, covering both clients, is still to do.

🤖 Generated with Claude Code

…ntime client

FeatureFlagsRuntimeClient polls GET /sdk/feature-flags?payload_version=2
on a daemon thread, keeps the configuration in memory and evaluates flags
locally from it. A failed poll keeps the last good configuration and backs
off, transient failures are retried within request_timeout, and a 401 stops
polling. It supports bootstrap, change, error and status listeners, status
and a manual refresh().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The reviewed changes appear safe to merge.

Summary

Adds a synchronous feature flag client that polls in the background and evaluates cached flags locally.

  • Python apps can keep feature flags current and check them locally.
  • Failed polls get bounded retries and slower follow-up polling.

Reviews (4) · Last reviewed commit: "fix(feature-flags): Make every close() c..." · Reviewed by Greptile

stanleyphu and others added 3 commits November 3, 2026 23:54
…e-flags-runtime-sync-client

# Conflicts:
#	tests/feature_flags_runtime/test_configuration.py
A retry inside a refresh waited for whatever Retry-After the server sent,
uncapped and possibly negative, while the wait before the next poll was
capped at 60 seconds. Both now use the same capped value, as Node does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
close() only joins the background thread, so a manual refresh() that
passed its closed check just before close() could still install a new
configuration after close() returned, without a change notification. The
check and the snapshot update now happen under the lock close() takes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@stanleyphu

Copy link
Copy Markdown
Contributor Author

@greptileai review

…stop

A second close() returned as soon as it saw the client was closed, while
the first was still waiting for an in-flight poll, so the second caller
could close the WorkOSClient under it. Every caller now waits for the
poller thread, as the async client does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@stanleyphu

Copy link
Copy Markdown
Contributor Author

@greptileai review

This branch has not been deployed

No deployments
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.

1 participant