Skip to content
Closed
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
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,48 @@ result = client.organizations.list_organizations(
> [!NOTE]
> The WorkOS API currently honors `Idempotency-Key` only on the [Create Audit Log Event](https://workos.com/docs/reference/audit-logs/event) endpoint (`audit_logs.create_event`). Other endpoints accept the header but do not deduplicate requests, so a retried mutation elsewhere can still create a duplicate.

## Feature Flags Runtime Client

The runtime client keeps an environment's feature flag configuration in memory and evaluates flags locally, with no network request per evaluation. It polls the configuration in the background (every 30 seconds by default) with the client's API key, base URL and retry settings. Create one long-lived instance per process and share it across requests. It is for trusted server-side code only.

```python
from workos import WorkOSClient

workos = WorkOSClient(api_key="sk_...")
flags = workos.create_feature_flags_runtime_client()
flags.start() # polls on a daemon thread
flags.wait_until_ready(timeout=5) # raises TimeoutError if no configuration arrives

flags.is_enabled("new-checkout", {"user": "user_123", "organization": "org_123"})
flags.is_enabled("new-checkout", {"workspace": "ws_1"}, default=True)
flags.get_all_flags({"organization": "org_123"}) # {"new-checkout": True, ...}

flags.close() # on shutdown; or use `with workos.create_feature_flags_runtime_client() as flags:`
```

With `AsyncWorkOSClient`, polling runs in an asyncio task: `await flags.start()`, `await flags.wait_until_ready(timeout=5)` and `await flags.close()` (or `async with`). Evaluation stays synchronous.

**Evaluation.** The context maps a target type to the exact, case-sensitive ID being evaluated. `user` and `organization` are target types like any [custom target](https://workos.com/docs/feature-flags/custom-targets) type, and an ID of `None` is skipped. A disabled flag serves its off value. For an enabled flag, the first rule whose conditions all match wins, so a rule serving `False` can exclude a user before a later rule includes their organization; otherwise the flag's default value applies. A missing flag, an unready client, or a flag without a boolean answer returns `default` (`False` unless you pass one), and `get_all_flags` omits flags without an answer. Evaluation never raises: invalid context entries are ignored with a logged warning and the rest is evaluated.

**Failures.** A failed poll keeps the last good configuration and retries with exponential backoff. Transient failures (429, 500, 502, 503, 504, timeouts, connection errors) are also retried within one refresh, up to the client's `max_retries` and within `request_timeout`, and a 429's `Retry-After` (up to 60 seconds) delays the next poll. The async client enforces `request_timeout` exactly. The sync client streams the response and gives every network wait only the time left, so a refresh takes at most `request_timeout` plus one read of a response body that stalls; a custom `HTTPBackend` that is not an httpx client receives the remaining time as its per-request timeout instead. A 401, from a poll or from `refresh()`, stops polling for good: create a new client with a valid API key. Failures are logged to the `workos.feature_flags.runtime` logger (pass `logger=` to use your own) and reported to error listeners. `flags.status` shows readiness, the last error, refresh counts and timestamps, `polling_stopped`, `flag_count` and `cache_age()`.

**Notifications.** `on_change` (or `add_change_listener`) fires after a refresh installs a different configuration, including the first load, with the previous and current snapshots and the changed slugs. `on_error` fires for failed background polls and `on_status` after every refresh. Listeners run one at a time; their exceptions are logged and never affect polling. Async clients also accept coroutine listeners.

**Bootstrap.** Persist the configuration and pass it back to make a new client ready before its first poll:

```python
from workos import FeatureFlagsConfiguration

flags = workos.create_feature_flags_runtime_client(
bootstrap=FeatureFlagsConfiguration.from_json(cached_json), # raises on unreadable JSON
on_change=lambda event: save_cache(event.current.to_json()),
)
```

**Inspection and manual refresh.** `flags.get_flag(slug)` returns a flag's configuration as received, with its rules in order. `flags.refresh()` fetches the configuration immediately, raising on failure, and works without `start()`, e.g. in scripts or serverless functions.

**Processes.** With the SDK's default HTTP client, the sync client polls through an HTTP client of its own and closes it in `close()`; with an HTTP client you supplied, close the runtime client before closing that client. A runtime client created before a fork (for example in an app gunicorn loads with `--preload`) can be started in each worker, and a sync client that was already polling resumes on new connections the first time it is used there. If the parent already sent requests through an HTTP client you supplied, or through the `AsyncWorkOSClient`, whether from the runtime client or any other SDK call, the runtime client does not poll in the worker: `start()` and `refresh()` raise, it keeps serving its last configuration, and if it was polling it logs a warning the first time it is used. The SDK can't see requests your own code sends through an HTTP client you supplied, so don't use it before forking either. On macOS, once a process has created an HTTP client (every `WorkOSClient` has one), a child it forks can crash when it opens a network connection, so a runtime client created before the fork never polls in a worker there; create the `WorkOSClient` and the runtime client after forking instead. Background threads must be enabled, so run uWSGI with `--enable-threads`.

## Type Safety

This SDK ships with full type annotations (`py.typed` / PEP 561) and works with mypy, pyright, and IDE autocompletion out of the box. All API resource models are `@dataclass(slots=True)` classes with `from_dict()` / `to_dict()` for serialization.
Expand Down
8 changes: 8 additions & 0 deletions src/workos/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
TransportTimeout,
)
from ._pagination import AsyncPage, ListMetadata, SyncPage
from .feature_flags.runtime import (
AsyncFeatureFlagsRuntimeClient,
FeatureFlagsConfiguration,
FeatureFlagsRuntimeClient,
)
from .public_client import create_public_client
from ._types import NOT_GIVEN, NotGiven, RequestOptions

Expand Down Expand Up @@ -51,4 +56,7 @@
"TransportError",
"TransportTimeout",
"TransportConnectError",
"FeatureFlagsRuntimeClient",
"AsyncFeatureFlagsRuntimeClient",
"FeatureFlagsConfiguration",
]
8 changes: 8 additions & 0 deletions src/workos/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,9 @@ def __init__(
self._backend, self._owns_http_client = resolve_sync_backend(
http_client, self._request_timeout
)
# Whether a request went out through the HTTP client, so a forked
# process can tell whether it would share the parent's connections.
self._sent_requests = False

def close(self) -> None:
"""Release the HTTP client if the SDK created it.
Expand Down Expand Up @@ -592,6 +595,7 @@ def request(
timeout = self._resolve_timeout(request_options)
max_retries = self._resolve_max_retries(request_options)
last_error: Optional[Exception] = None
self._sent_requests = True
for attempt in range(max_retries + 1):
try:
response = self._backend.request(
Expand Down Expand Up @@ -777,6 +781,9 @@ def __init__(
self._backend, self._owns_http_client = resolve_async_backend(
http_client, self._request_timeout
)
# Whether a request went out through the HTTP client, so a forked
# process can tell whether it would share the parent's connections.
self._sent_requests = False

async def close(self) -> None:
"""Release the HTTP client if the SDK created it.
Expand Down Expand Up @@ -836,6 +843,7 @@ async def request(
timeout = self._resolve_timeout(request_options)
max_retries = self._resolve_max_retries(request_options)
last_error: Optional[Exception] = None
self._sent_requests = True
for attempt in range(max_retries + 1):
try:
response = await self._backend.request(
Expand Down
12 changes: 12 additions & 0 deletions src/workos/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,12 @@ def pkce(self) -> PKCE:
"""PKCE (Proof Key for Code Exchange) utilities."""
return PKCE()

# A plain function bound as a method, so its signature and docs live next
# to the runtime client's constructor.
from .feature_flags.runtime._factory import (
create_feature_flags_runtime_client as create_feature_flags_runtime_client,
)

# @oagen-ignore-end


Expand Down Expand Up @@ -338,4 +344,10 @@ def pkce(self) -> PKCE:
"""PKCE (Proof Key for Code Exchange) utilities."""
return PKCE()

# A plain function bound as a method, so its signature and docs live next
# to the runtime client's constructor.
from .feature_flags.runtime._factory import (
create_async_feature_flags_runtime_client as create_feature_flags_runtime_client,
)

# @oagen-ignore-end
34 changes: 34 additions & 0 deletions src/workos/feature_flags/runtime/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# @oagen-ignore-file
# This file is hand-maintained. The feature flags runtime client evaluates
# flags locally from a polled configuration; it is not part of the OpenAPI spec.

"""Local evaluation of WorkOS feature flags from a polled configuration.

Use :class:`FeatureFlagsRuntimeClient` with :class:`workos.WorkOSClient` and
:class:`AsyncFeatureFlagsRuntimeClient` with :class:`workos.AsyncWorkOSClient`.
"""

from ._async import AsyncFeatureFlagsRuntimeClient
from ._configuration import (
ConditionDefinition,
FeatureFlagsConfiguration,
FeatureFlagsConfigurationError,
FlagDefinition,
RuleDefinition,
)
from ._context import EvaluationContext
from ._sync import FeatureFlagsRuntimeClient
from ._types import FlagsChangedEvent, RuntimeStatus

__all__ = [
"AsyncFeatureFlagsRuntimeClient",
"ConditionDefinition",
"EvaluationContext",
"FeatureFlagsConfiguration",
"FeatureFlagsConfigurationError",
"FeatureFlagsRuntimeClient",
"FlagDefinition",
"FlagsChangedEvent",
"RuleDefinition",
"RuntimeStatus",
]
Loading
Loading