diff --git a/README.md b/README.md index ab4923ba..ed37d8a9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/workos/__init__.py b/src/workos/__init__.py index 1a1182f4..fc234f95 100644 --- a/src/workos/__init__.py +++ b/src/workos/__init__.py @@ -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 @@ -51,4 +56,7 @@ "TransportError", "TransportTimeout", "TransportConnectError", + "FeatureFlagsRuntimeClient", + "AsyncFeatureFlagsRuntimeClient", + "FeatureFlagsConfiguration", ] diff --git a/src/workos/_base_client.py b/src/workos/_base_client.py index ec98fc43..1f77c2e4 100644 --- a/src/workos/_base_client.py +++ b/src/workos/_base_client.py @@ -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. @@ -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( @@ -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. @@ -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( diff --git a/src/workos/_client.py b/src/workos/_client.py index 9776dd23..8425e8f8 100644 --- a/src/workos/_client.py +++ b/src/workos/_client.py @@ -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 @@ -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 diff --git a/src/workos/feature_flags/runtime/__init__.py b/src/workos/feature_flags/runtime/__init__.py new file mode 100644 index 00000000..e13e2b12 --- /dev/null +++ b/src/workos/feature_flags/runtime/__init__.py @@ -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", +] diff --git a/src/workos/feature_flags/runtime/_async.py b/src/workos/feature_flags/runtime/_async.py new file mode 100644 index 00000000..86d5f769 --- /dev/null +++ b/src/workos/feature_flags/runtime/_async.py @@ -0,0 +1,444 @@ +# @oagen-ignore-file +# This file is hand-maintained. The asynchronous feature flags runtime client +# polls in an asyncio task and evaluates flags locally. +# pyright: strict, reportPrivateUsage=false + +from __future__ import annotations + +import asyncio +import inspect +import os +import time +import weakref +from types import TracebackType +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Type, Union + +from ..._errors import WorkOSTimeoutError +from . import _core, _policy +from ._configuration import FeatureFlagsConfiguration +from ._context import LoggerLike +from ._core import Notification, RuntimeClientCore, fork_blocked_message, utc_now +from ._types import FlagsChangedEvent, RuntimeStatus + +if TYPE_CHECKING: + from ..._base_client import AsyncWorkOSClient + +ChangeListener = Callable[[FlagsChangedEvent], Union[None, Awaitable[None]]] +ErrorListener = Callable[[Exception], Union[None, Awaitable[None]]] +StatusListener = Callable[[RuntimeStatus], Union[None, Awaitable[None]]] + + +class _Closed(Exception): + """The client was closed while a refresh was in progress.""" + + +class AsyncFeatureFlagsRuntimeClient(RuntimeClientCore): + """Polls an environment's feature flag configuration in an asyncio task and evaluates flags locally. + + Create one long-lived instance per event loop and share it. Evaluation + (``is_enabled``, ``get_all_flags``, ``get_flag``) is synchronous, never + makes a network request and never raises:: + + flags = workos.create_feature_flags_runtime_client() + await flags.start() + await flags.wait_until_ready(timeout=5) + flags.is_enabled("new-checkout", {"organization": "org_123"}) + + The client polls through its ``AsyncWorkOSClient``, so use it on that + client's event loop, and close it before closing the ``AsyncWorkOSClient``. + A client created before a fork can start in the child unless the + ``AsyncWorkOSClient`` already sent requests in the parent, or the platform + is macOS. + """ + + def __init__( + self, + client: AsyncWorkOSClient, + *, + polling_interval: float = _policy.DEFAULT_POLLING_INTERVAL, + request_timeout: float = _policy.DEFAULT_REQUEST_TIMEOUT, + max_retry_delay: float = _policy.DEFAULT_MAX_RETRY_DELAY, + bootstrap: Optional[FeatureFlagsConfiguration] = None, + logger: Optional[LoggerLike] = None, + on_change: Optional[ChangeListener] = None, + on_error: Optional[ErrorListener] = None, + on_status: Optional[StatusListener] = None, + ) -> None: + """Create a runtime client. No request is made until :meth:`start` or :meth:`refresh`. + + Takes the same options as :class:`FeatureFlagsRuntimeClient`. Listeners + may be plain functions or coroutine functions. + + Raises: + ConfigurationError: If ``client`` has no API key. + """ + super().__init__( + client, + polling_interval=polling_interval, + request_timeout=request_timeout, + max_retry_delay=max_retry_delay, + bootstrap=bootstrap, + logger=logger, + ) + self._client = client + self._task: Optional[asyncio.Task[None]] = None + self._draining = False + # Why polling cannot run in this forked child, if it cannot. + self._fork_blocker: Optional[str] = None + self._fork_warned = False + self._init_synchronization() + if on_change is not None: + self.add_change_listener(on_change) + if on_error is not None: + self.add_error_listener(on_error) + if on_status is not None: + self.add_status_listener(on_status) + _clients.add(self) + + def _init_synchronization(self) -> None: + # None of these binds to an event loop until first awaited. + self._refresh_lock = asyncio.Lock() + self._ready = asyncio.Event() + # Set when polling stops after a 401, to end the polling task's sleep. + self._wake = asyncio.Event() + if self._closed or self._status.ready or self._readiness_error is not None: + self._ready.set() + + # --- Lifecycle ------------------------------------------------------------ + + async def start(self) -> None: + """Start background polling in a task on the running event loop. Idempotent. + + The first poll is sent right away, then every ``polling_interval`` + seconds. Failed polls retry with exponential backoff. A 401 stops + polling permanently; the last good configuration stays usable. + + Raises: + RuntimeError: If the client is closed, no event loop is running, or + the client cannot poll in this forked process. + """ + if self._closed: + raise RuntimeError( + "This feature flags runtime client is closed; create a new one" + ) + self._raise_if_fork_blocked() + if self._started: + return + loop = asyncio.get_running_loop() + self._started = True + if self._status.polling_stopped: + self._logger.warning( + "Not starting feature flag polling: it stopped after a 401. " + "Create a new runtime client with a valid API key." + ) + return + self._task = loop.create_task(self._poll_forever(), name="workos-feature-flags") + + async def wait_until_ready(self, timeout: Optional[float] = None) -> None: + """Wait until a bootstrap or a successful refresh makes flags available. + + Does not start polling: call :meth:`start` (or :meth:`refresh`). + + Args: + timeout: Seconds to wait. ``None`` waits indefinitely. + + Raises: + TimeoutError: If flags are not available within ``timeout``. + AuthenticationError: If polling stopped after a 401 before any + configuration was available. + RuntimeError: If the client was closed before it became ready. + """ + if self._status.ready: + return + try: + await asyncio.wait_for(self._ready.wait(), timeout) + except asyncio.TimeoutError: + # The readiness timeout names the last refresh error as its cause. + raise self._readiness_timeout(timeout) + if not self._status.ready: + raise self._readiness_failure() + + async def refresh(self) -> None: + """Fetch the configuration now. + + Serialized with background polling. Errors are raised to the caller and + the last good configuration is kept; ``on_error`` is not called. A 401 + also stops background polling, as it would from a background poll. + + Raises: + WorkOSError: If the request fails or the payload cannot be read. + RuntimeError: If the client is closed or cannot poll in this forked + process. + """ + if self._closed: + raise RuntimeError( + "This feature flags runtime client is closed; create a new one" + ) + self._raise_if_fork_blocked() + try: + await self._refresh(background=False) + except _Closed: + raise RuntimeError( + "The feature flags runtime client was closed during the refresh" + ) from None + except Exception as error: + if _policy.is_unauthorized(error): + await self._stop_polling(error) + raise + + async def close(self) -> None: + """Stop polling and wait for the polling task to finish. Idempotent. + + Evaluation keeps serving the last configuration after closing. + """ + if self._closed: + return + self._closed = True + self._ready.set() + task = self._task + if task is None or task.done() or task is asyncio.current_task(): + # Called from a listener inside the polling task: the task exits on + # its own once the listener returns. + return + task.cancel() + # `wait` never raises the task's own cancellation, but still + # propagates a cancellation of the caller. + await asyncio.wait({task}) + + async def __aenter__(self) -> AsyncFeatureFlagsRuntimeClient: + await self.start() + return self + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + await self.close() + + # --- Listeners ------------------------------------------------------------ + + def add_change_listener(self, listener: ChangeListener) -> None: + """Call ``listener`` after a refresh installs a different configuration. + + Fires on the first load and on the first refresh that differs from a + bootstrap; never for identical refreshes or failures. Coroutine + listeners are awaited. Listeners run one at a time in refresh order; + their exceptions are logged and never affect polling. + """ + self._add_listener("change", listener) + + def remove_change_listener(self, listener: ChangeListener) -> None: + """Stop calling a change listener.""" + self._remove_listener("change", listener) + + def add_error_listener(self, listener: ErrorListener) -> None: + """Call ``listener`` with the error of every failed background poll.""" + self._add_listener("error", listener) + + def remove_error_listener(self, listener: ErrorListener) -> None: + """Stop calling an error listener.""" + self._remove_listener("error", listener) + + def add_status_listener(self, listener: StatusListener) -> None: + """Call ``listener`` with the new status after every refresh and when polling stops.""" + self._add_listener("status", listener) + + def remove_status_listener(self, listener: StatusListener) -> None: + """Stop calling a status listener.""" + self._remove_listener("status", listener) + + # --- Polling -------------------------------------------------------------- + + async def _poll_forever(self) -> None: + failures = 0 + while not self._closed and not self._status.polling_stopped: + retry_after: Optional[float] = None + try: + await self._refresh(background=True) + failures = 0 + except _Closed: + return + except Exception as error: + if self._closed: + return + if _policy.is_unauthorized(error): + await self._stop_polling(error) + return + failures = min(failures + 1, _policy.MAX_COUNTED_FAILURES) + retry_after = _policy.retry_after(error) + if self._closed: + return + delay = _policy.next_delay( + self._polling_interval, failures, self._max_retry_delay + ) + try: + await asyncio.wait_for( + self._wake.wait(), timeout=max(delay, retry_after or 0.0) + ) + except asyncio.TimeoutError: + pass + + async def _refresh(self, *, background: bool) -> None: + try: + async with self._refresh_lock: + await self._refresh_locked(background=background) + finally: + await self._drain() + + async def _refresh_locked(self, *, background: bool) -> None: + if ( + self._closed + or self._fork_blocker is not None + or (background and self._status.polling_stopped) + ): + raise _Closed() + attempted = utc_now() + try: + payload = await asyncio.wait_for(self._fetch(), self._request_timeout) + configuration = self._parse(payload) + except asyncio.TimeoutError as error: + if self._closed: + raise _Closed() from error + timeout = WorkOSTimeoutError( + f"Feature flag refresh exceeded its request timeout of {self._request_timeout} seconds" + ) + self._pending.append( + self._record_failure(timeout, attempted, background=background) + ) + raise timeout from error + except Exception as error: + if self._closed: + raise _Closed() from error + self._pending.append( + self._record_failure(error, attempted, background=background) + ) + raise + if self._closed: + raise _Closed() + notifications = self._record_success( + configuration, attempted, background=background + ) + self._ready.set() + self._pending.append(notifications) + + async def _fetch(self) -> Any: + deadline = time.monotonic() + self._request_timeout + attempt = 0 + while True: + remaining = max(deadline - time.monotonic(), 0.001) + try: + return await self._client.request( + "get", + _policy.POLL_PATH, + params=dict(_policy.POLL_PARAMS), + request_options={"timeout": remaining, "max_retries": 0}, + ) + except Exception as error: + delay = _policy.attempt_retry_delay( + error, attempt, deadline - time.monotonic(), self._max_retries + ) + if delay is None: + raise + await asyncio.sleep(delay) + attempt += 1 + + async def _stop_polling(self, error: Exception) -> None: + async with self._refresh_lock: + if self._status.polling_stopped: + return + notifications = self._record_polling_stopped(error) + if not self._status.ready: + self._readiness_error = error + self._ready.set() + self._wake.set() + self._pending.append(notifications) + await self._drain() + + async def _drain(self) -> None: + """Deliver pending notifications in refresh order, one batch at a time. + + A refresh that finishes while another task is delivering, including a + listener calling refresh(), leaves its batch to that task, so batches + are never interleaved. + """ + if self._draining: + return + self._draining = True + try: + while self._pending: + batch = self._pending.popleft() + if not self._closed: + await self._deliver(batch) + finally: + self._draining = False + + async def _deliver(self, notifications: list[Notification]) -> None: + for listener, argument in notifications: + try: + result = listener(argument) + if inspect.isawaitable(result): + await result + except Exception: + self._log_listener_error() + + # --- Fork safety ---------------------------------------------------------- + + def _reinitialize_after_fork(self) -> None: + # Once the AsyncWorkOSClient has sent requests, its connections belong + # to the parent's event loop, so a copy of a client that polled, or of + # one whose AsyncWorkOSClient made other calls, stops polling here (even + # if the child keeps running a copy of the loop) and keeps serving its + # last configuration. Otherwise it starts over on the child's own loop. + self._reinitialize_core_after_fork() + polling = ( + self._started and not self._closed and not self._status.polling_stopped + ) + # The polling task and anything waiting on these primitives belong to + # the parent's event loop, which may not run here: close() must not + # wait for that task. + self._task = None + self._init_synchronization() + if self._closed: + self._fork_blocker = None + elif not _core.FORK_SAFE_PLATFORM: + self._fork_blocker = ( + f"{_core.MACOS_FORK_HAZARD}. Create the AsyncWorkOSClient and the " + "runtime client after forking instead" + ) + elif self._used_before_fork() or self._client._sent_requests: + self._fork_blocker = ( + "the AsyncWorkOSClient was used in the parent, on the parent's " + "event loop and connections. Create the AsyncWorkOSClient and the " + "runtime client after forking instead" + ) + else: + self._fork_blocker = None + # Only a client that was polling warns, on first use, that it no longer + # polls; any other raises from start() and refresh() instead. + self._fork_warned = not polling + + def _before_use(self) -> None: + if self._fork_blocker is not None and not self._fork_warned: + self._fork_warned = True + self._logger.warning( + "%s. Evaluation keeps serving the last configuration.", + fork_blocked_message(self._fork_blocker), + ) + + def _raise_if_fork_blocked(self) -> None: + if self._fork_blocker is not None: + raise RuntimeError(fork_blocked_message(self._fork_blocker)) + + +_clients: weakref.WeakSet[AsyncFeatureFlagsRuntimeClient] = weakref.WeakSet() + + +def _reinitialize_clients_after_fork() -> None: + for client in list(_clients): + client._reinitialize_after_fork() + + +if hasattr(os, "register_at_fork"): + os.register_at_fork(after_in_child=_reinitialize_clients_after_fork) diff --git a/src/workos/feature_flags/runtime/_configuration.py b/src/workos/feature_flags/runtime/_configuration.py new file mode 100644 index 00000000..b034ddbc --- /dev/null +++ b/src/workos/feature_flags/runtime/_configuration.py @@ -0,0 +1,473 @@ +# @oagen-ignore-file +# This file is hand-maintained. It parses the feature flags runtime payload +# (`GET /sdk/feature-flags?payload_version=2`), which is not part of the +# OpenAPI spec. +# pyright: strict, reportPrivateUsage=false + +from __future__ import annotations + +import json +from dataclasses import dataclass +from types import MappingProxyType +from typing import Any, Iterator, Mapping, Sequence, TypeGuard, cast + +from ..._errors import WorkOSError + +PAYLOAD_VERSION = 2 + +_CONDITIONS_KIND = "conditions" +_ONE_OF_OPERATOR = "one_of" + + +class FeatureFlagsConfigurationError(WorkOSError, ValueError): + """A feature flags payload that this SDK cannot read. + + Raised by :meth:`FeatureFlagsConfiguration.from_dict` and + :meth:`FeatureFlagsConfiguration.from_json`. When a background poll returns + such a payload, the runtime client keeps its last good configuration and + reports this error instead. + """ + + +@dataclass(frozen=True, slots=True) +class ConditionDefinition: + """One condition of a rule, as received. + + The SDK evaluates only the ``one_of`` operator. A rule containing any other + operator never matches. ``target_type`` and ``operator`` are ``None`` only + when a condition the SDK does not evaluate omits them. + """ + + target_type: str | None + operator: str | None + values: tuple[str, ...] + """Exact, case-sensitive IDs in the order received.""" + + +@dataclass(frozen=True, slots=True) +class RuleDefinition: + """One rule of a flag, as received. + + The SDK evaluates only ``conditions`` rules. A rule of any other kind, such + as one added by a newer server, never matches. + """ + + id: str + kind: str + value: bool | None + """Served when the rule matches. ``None`` when the served value is not a boolean.""" + conditions: tuple[ConditionDefinition, ...] + """Conditions that must all match. Best effort for kinds the SDK does not evaluate.""" + + +@dataclass(frozen=True, slots=True) +class FlagDefinition: + """One flag's configuration as the runtime client received it. + + For inspection and debugging only. Use the runtime client's ``is_enabled`` + for decisions. + """ + + slug: str + enabled: bool + """Whether the flag is on in this environment.""" + default_value: bool | None + """Served when the flag is on and no rule matches. ``None`` when not a boolean.""" + off_value: bool | None + """Served when the flag is off. ``None`` when not a boolean.""" + rules: tuple[RuleDefinition, ...] + """Rules in evaluation order. The first matching rule wins.""" + + +@dataclass(frozen=True, slots=True) +class _CompiledCondition: + target_type: str + ids: frozenset[str] + + +@dataclass(frozen=True, slots=True) +class _CompiledRule: + # Empty when the rule can never match: an unknown kind, an unknown + # operator, a condition without a target type, or no conditions at all. + conditions: tuple[_CompiledCondition, ...] + value: bool | None + + +@dataclass(frozen=True, slots=True) +class _CompiledFlag: + definition: FlagDefinition + rules: tuple[_CompiledRule, ...] + canonical: str + + def evaluate(self, targets: Mapping[str, str]) -> bool | None: + """Return the served boolean, or ``None`` when the flag has no answer.""" + definition = self.definition + if not definition.enabled: + return definition.off_value + for rule in self.rules: + if rule.conditions and all( + targets.get(condition.target_type) in condition.ids + for condition in rule.conditions + ): + return rule.value + return definition.default_value + + +_NEVER_MATCHES = _CompiledRule(conditions=(), value=None) + + +class FeatureFlagsConfiguration: + """An immutable, validated snapshot of an environment's feature flags. + + Persist :meth:`to_json` in your own storage and pass + ``FeatureFlagsConfiguration.from_json(cached)`` as ``bootstrap`` to make a + new runtime client usable before its first successful poll. + """ + + __slots__ = ("_json", "_fingerprint", "_flags") + + _json: str + # The payload serialized with sorted keys: equal for equal content, so an + # unchanged poll can keep this snapshot without parsing it again. + _fingerprint: str + _flags: Mapping[str, _CompiledFlag] + + def __init__(self, payload: Mapping[str, Any]) -> None: + """Validate a complete version 2 payload. + + Raises: + FeatureFlagsConfigurationError: If the payload is malformed or uses + another payload version. Unknown rule kinds and operators are + accepted and never match, and a served value that is not a + boolean leaves the flag without an answer. + """ + self._initialize(payload, _fingerprint(payload)) + + def _initialize(self, payload: object, fingerprint: str) -> None: + self._json = _dumps(payload, sort_keys=False) + self._fingerprint = fingerprint + self._flags = MappingProxyType(_parse_payload(json.loads(self._json))) + + def __reduce__(self) -> tuple[Any, ...]: + # MappingProxyType cannot be pickled; the JSON rebuilds an equal snapshot. + return (_restore, (self._json,)) + + def __copy__(self) -> FeatureFlagsConfiguration: + return self + + def __deepcopy__(self, memo: dict[int, Any]) -> FeatureFlagsConfiguration: + return self + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> FeatureFlagsConfiguration: + """Validate a decoded version 2 payload, e.g. one you cached with :meth:`to_dict`.""" + return cls(data) + + @classmethod + def from_json(cls, text: str | bytes) -> FeatureFlagsConfiguration: + """Validate a version 2 payload serialized as JSON, e.g. one you cached with :meth:`to_json`.""" + try: + data: object = json.loads(text) + except ValueError as error: + raise FeatureFlagsConfigurationError( + f"Feature flags configuration is not valid JSON: {error}" + ) from error + if not _is_mapping(data): + raise FeatureFlagsConfigurationError( + "Expected an object for the feature flags configuration." + ) + return cls(data) + + def to_dict(self) -> dict[str, Any]: + """Return a copy of the complete payload, including fields this SDK ignores.""" + return cast(dict[str, Any], json.loads(self._json)) + + def to_json(self) -> str: + """Return the complete payload as JSON, including fields this SDK ignores.""" + return self._json + + def get_flag(self, slug: str) -> FlagDefinition | None: + """Return one flag's configuration, or ``None`` if the snapshot does not contain it.""" + flag = self._compiled(slug) + return flag.definition if flag is not None else None + + @property + def slugs(self) -> tuple[str, ...]: + """Every flag slug in this snapshot, in payload order.""" + return tuple(self._flags) + + def __len__(self) -> int: + return len(self._flags) + + def __contains__(self, slug: object) -> bool: + return isinstance(slug, str) and slug in self._flags + + def __iter__(self) -> Iterator[str]: + return iter(self._flags) + + def __repr__(self) -> str: + return f"FeatureFlagsConfiguration(flags={len(self._flags)})" + + def _compiled(self, slug: object) -> _CompiledFlag | None: + return self._flags.get(slug) if _is_string(slug) else None + + def _compiled_items(self) -> Iterator[tuple[str, _CompiledFlag]]: + return iter(self._flags.items()) + + +def configuration_for( + payload: object, current: FeatureFlagsConfiguration | None +) -> FeatureFlagsConfiguration: + """``current`` when ``payload`` has the same content, otherwise a new snapshot. + + Most polls return an unchanged configuration; reusing the snapshot skips + validating it again. Key order does not count as a change. + """ + fingerprint = _fingerprint(payload) + if current is not None and current._fingerprint == fingerprint: + return current + configuration = FeatureFlagsConfiguration.__new__(FeatureFlagsConfiguration) + configuration._initialize(payload, fingerprint) + return configuration + + +def changed_flags( + previous: FeatureFlagsConfiguration | None, current: FeatureFlagsConfiguration +) -> frozenset[str]: + """Slugs added, removed or modified between two snapshots. + + Every slug counts as changed on a first load. A flag's complete content is + compared, including fields this SDK does not interpret, so a cache kept + fresh from change notifications never misses a newer server's fields. + """ + if previous is None: + return frozenset(current.slugs) + if previous is current: + return frozenset() + changed = { + slug + for slug, flag in current._compiled_items() + if (before := previous._compiled(slug)) is None + or before.canonical != flag.canonical + } + changed.update(slug for slug in previous.slugs if slug not in current) + return frozenset(changed) + + +def _restore(text: str) -> FeatureFlagsConfiguration: + return FeatureFlagsConfiguration.from_json(text) + + +# --- Parsing ------------------------------------------------------------------ + + +def _dumps(payload: object, *, sort_keys: bool) -> str: + try: + return json.dumps( + payload, sort_keys=sort_keys, ensure_ascii=False, separators=(",", ":") + ) + except (TypeError, ValueError) as error: + raise FeatureFlagsConfigurationError( + f"Feature flags configuration is not JSON-serializable: {error}" + ) from error + + +def _fingerprint(payload: object) -> str: + return _dumps(payload, sort_keys=True) + + +def _invalid(message: str) -> FeatureFlagsConfigurationError: + return FeatureFlagsConfigurationError( + f"Invalid feature flags configuration: {message}" + ) + + +def _is_mapping(value: object) -> TypeGuard[Mapping[str, Any]]: + return isinstance(value, Mapping) + + +def _is_list(value: object) -> TypeGuard[list[Any]]: + return isinstance(value, list) + + +def _is_string(value: object) -> TypeGuard[str]: + return isinstance(value, str) + + +def _is_nonempty_string(value: object) -> TypeGuard[str]: + return isinstance(value, str) and value != "" + + +def _served_value(value: object) -> bool | None: + # Mirrors the server: a served value that is not a boolean, such as a + # future multivariate variant, means "no answer" rather than false, so the + # caller's own default applies. + return value if isinstance(value, bool) else None + + +def _parse_payload(payload: object) -> dict[str, _CompiledFlag]: + if not _is_mapping(payload): + raise _invalid("expected an object.") + version = payload.get("version") + # `bool` is a subclass of `int`, and `2.0 == 2`, so compare the exact type. + if type(version) is not int or version != PAYLOAD_VERSION: + raise _invalid(f"expected SDK payload version {PAYLOAD_VERSION}.") + flags = payload.get("flags") + if not _is_mapping(flags): + raise _invalid("expected an object for flags.") + return {slug: _parse_flag(slug, entry) for slug, entry in flags.items()} + + +def _parse_flag(slug: str, entry: object) -> _CompiledFlag: + if not _is_mapping(entry): + raise _invalid(f"expected an object for flag {slug!r}.") + # The map key is the flag's identity. A `slug` that differs from it is + # tolerated, as the Node SDK does, rather than failing the whole refresh. + if not isinstance(entry.get("slug"), str): + raise _invalid(f"expected a string slug for flag {slug!r}.") + enabled = entry.get("enabled") + if not isinstance(enabled, bool): + raise _invalid(f"expected a boolean for {slug!r}.enabled.") + raw_rules = entry.get("rules") + if not _is_list(raw_rules): + raise _invalid(f"expected an array for {slug!r}.rules.") + parsed = [_parse_rule(slug, rule) for rule in raw_rules] + definition = FlagDefinition( + slug=slug, + enabled=enabled, + default_value=_served_value(entry.get("default_value")), + off_value=_served_value(entry.get("off_value")), + rules=tuple(definition for definition, _ in parsed), + ) + return _CompiledFlag( + definition=definition, + rules=tuple(compiled for _, compiled in parsed), + canonical=_canonical_flag(entry), + ) + + +def _parse_rule(slug: str, rule: object) -> tuple[RuleDefinition, _CompiledRule]: + if not _is_mapping(rule): + raise _invalid(f"expected an object for a rule of {slug!r}.") + rule_id = rule.get("id") + kind = rule.get("kind") + if not _is_nonempty_string(rule_id) or not _is_nonempty_string(kind): + raise _invalid(f"expected a nonempty id and kind for every rule of {slug!r}.") + value = _served_value(rule.get("value")) + if kind != _CONDITIONS_KIND: + # A future rule kind may have entirely different fields. Record what is + # recognizable for inspection but never validate or evaluate it, so a + # newer server can never fail this SDK's refresh. + return ( + RuleDefinition( + rule_id, kind, value, _inspect_conditions(rule.get("conditions")) + ), + _NEVER_MATCHES, + ) + raw_conditions = rule.get("conditions") + if raw_conditions is None: + return RuleDefinition(rule_id, kind, value, ()), _NEVER_MATCHES + if not _is_list(raw_conditions): + raise _invalid(f"expected an array of conditions for rule {rule_id!r}.") + parsed = [_parse_condition(rule_id, condition) for condition in raw_conditions] + definitions = tuple(definition for definition, _ in parsed) + compiled = tuple(condition for _, condition in parsed if condition is not None) + evaluable = bool(parsed) and len(compiled) == len(parsed) + return ( + RuleDefinition(rule_id, kind, value, definitions), + _CompiledRule(compiled, value) if evaluable else _NEVER_MATCHES, + ) + + +def _parse_condition( + rule_id: str, condition: object +) -> tuple[ConditionDefinition, _CompiledCondition | None]: + if not _is_mapping(condition): + raise _invalid(f"expected an object for a condition of rule {rule_id!r}.") + operator = condition.get("operator") + if not isinstance(operator, str): + raise _invalid(f"expected a string operator in rule {rule_id!r}.") + if operator != _ONE_OF_OPERATOR: + # Future operators may carry different operands. They are recorded for + # inspection but never validated, and the rule containing them never + # matches. + return _inspect_condition(condition), None + target_type = condition.get("target_type") + if target_type is not None and not isinstance(target_type, str): + raise _invalid(f"expected a string target_type in rule {rule_id!r}.") + raw_values = condition.get("values") + if not _is_list(raw_values): + raise _invalid( + f"expected an array of values for a one_of condition in rule {rule_id!r}." + ) + values = _string_values(raw_values) + definition = ConditionDefinition(target_type, operator, values) + if target_type is None: + return definition, None + # IDs that are not strings can never equal a context ID, so they are + # dropped rather than failing the whole refresh. + return definition, _CompiledCondition(target_type, frozenset(values)) + + +def _inspect_conditions(raw: object) -> tuple[ConditionDefinition, ...]: + if not _is_list(raw): + return () + return tuple( + _inspect_condition(condition) for condition in raw if _is_mapping(condition) + ) + + +def _inspect_condition(condition: Mapping[str, Any]) -> ConditionDefinition: + target_type = condition.get("target_type") + operator = condition.get("operator") + return ConditionDefinition( + target_type if isinstance(target_type, str) else None, + operator if isinstance(operator, str) else None, + _string_values(condition.get("values")), + ) + + +def _string_values(raw: object) -> tuple[str, ...]: + if not _is_list(raw): + return () + return tuple(value for value in raw if isinstance(value, str)) + + +# --- Change detection --------------------------------------------------------- + + +def _canonical_flag(entry: Mapping[str, Any]) -> str: + # Property order never matters. Rule order does (first match wins), but + # the conditions of a `conditions` rule must all match, so their order does + # not, and the IDs of a `one_of` condition form a set. Every other array + # stays order-sensitive: a spurious change only costs a redundant + # notification, but a missed one leaves a cache stale. + canonical = dict(entry) + rules = canonical.get("rules") + if _is_list(rules): + canonical["rules"] = [_canonical_rule(rule) for rule in rules] + return json.dumps( + canonical, sort_keys=True, ensure_ascii=False, separators=(",", ":") + ) + + +def _canonical_rule(rule: object) -> object: + if not _is_mapping(rule) or rule.get("kind") != _CONDITIONS_KIND: + return rule + conditions = rule.get("conditions") + if not _is_list(conditions): + return rule + ordered = sorted( + (_canonical_condition(condition) for condition in conditions), + key=lambda condition: json.dumps(condition, sort_keys=True, ensure_ascii=False), + ) + return {**rule, "conditions": ordered} + + +def _canonical_condition(condition: object) -> object: + if not _is_mapping(condition) or condition.get("operator") != _ONE_OF_OPERATOR: + return condition + values = condition.get("values") + if not _is_list(values) or not all(isinstance(value, str) for value in values): + return condition + return {**condition, "values": sorted(set(cast(Sequence[str], values)))} diff --git a/src/workos/feature_flags/runtime/_context.py b/src/workos/feature_flags/runtime/_context.py new file mode 100644 index 00000000..a3bb333e --- /dev/null +++ b/src/workos/feature_flags/runtime/_context.py @@ -0,0 +1,137 @@ +# @oagen-ignore-file +# This file is hand-maintained. It validates the evaluation context passed to +# the feature flags runtime client. +# pyright: strict + +from __future__ import annotations + +import logging +import re +import threading +from typing import Mapping, Optional, Union, cast + +EvaluationContext = Mapping[str, Optional[str]] +"""Maps a target type to the exact ID being evaluated. + +For example ``{"user": "user_123", "organization": "org_123", "workspace": "ws_1"}``. +``user`` and ``organization`` are target types like any custom type. Entries +whose ID is ``None`` are skipped, so optional IDs can be passed directly. +""" + +LoggerLike = Union[logging.Logger, "logging.LoggerAdapter[logging.Logger]"] + +# Mirrors the API's validation rules for target types and IDs. `fullmatch` is +# required: `$` in `re.match` also matches before a trailing newline. +_TARGET_TYPE = re.compile(r"[a-z][a-z0-9_-]{0,63}") +_TARGET_ID = re.compile(r"[A-Za-z0-9._:-]{1,255}") +_RESERVED_IDS = frozenset({".", ".."}) + +# Spellings from other SDKs or Python habit that name a built-in target type. +_BUILT_IN_HINTS = { + "userId": "user", + "user_id": "user", + "organizationId": "organization", + "organization_id": "organization", +} + +_MAX_DISTINCT_WARNINGS = 100 + + +class ContextWarnings: + """Logs each distinct context problem once per runtime client. + + Evaluation runs on hot paths, so a recurring mistake must not log on every + call. The number of remembered problems is bounded. + """ + + def __init__(self, logger: LoggerLike) -> None: + self._logger = logger + self._seen: set[tuple[str, str]] = set() + self._lock = threading.Lock() + self._saturated = False + + def warn(self, reason: str, key: str, message: str) -> None: + with self._lock: + if (reason, key) in self._seen: + return + if len(self._seen) >= _MAX_DISTINCT_WARNINGS: + if self._saturated: + return + self._saturated = True + message = ( + "Too many distinct evaluation context problems; " + "further ones are not logged" + ) + else: + self._seen.add((reason, key)) + self._logger.warning(message) + + def reinitialize_after_fork(self) -> None: + self._lock = threading.Lock() + + +def normalize_context(context: object, warnings: ContextWarnings) -> dict[str, str]: + """Reduce an evaluation context to valid target type to ID pairs. + + Evaluation must never raise in application code, so every invalid piece of + context is ignored with a logged warning and the rest is still evaluated. + """ + if context is None: + return {} + if not isinstance(context, Mapping): + warnings.warn( + "context", + type(context).__name__, + "Ignoring invalid evaluation context: expected a mapping of target type to ID, " + f"got {type(context).__name__}", + ) + return {} + targets: dict[str, str] = {} + for key, value in cast(Mapping[object, object], context).items(): + if value is None: + continue + target_type = _valid_target_type(key, warnings) + if target_type is not None and _is_valid_id(target_type, value, warnings): + targets[target_type] = cast(str, value) + return targets + + +def _valid_target_type(key: object, warnings: ContextWarnings) -> str | None: + if not isinstance(key, str) or _TARGET_TYPE.fullmatch(key) is None: + label = key if isinstance(key, str) else type(key).__name__ + hint = _BUILT_IN_HINTS.get(label) + suffix = f"; use {hint!r}" if hint else "" + warnings.warn( + "type", + label, + f"Ignoring invalid target type in evaluation context: {label!r}{suffix}", + ) + return None + hint = _BUILT_IN_HINTS.get(key) + if hint is not None: + warnings.warn( + "hint", + key, + f"Evaluation context key {key!r} is treated as a custom target type; " + f"use {hint!r} to match {hint} targets", + ) + return key + + +def _is_valid_id(target_type: str, value: object, warnings: ContextWarnings) -> bool: + if isinstance(value, str): + if _TARGET_ID.fullmatch(value) is not None and value not in _RESERVED_IDS: + return True + warnings.warn( + "id", + target_type, + f"Ignoring invalid target id in evaluation context for type: {target_type!r}", + ) + return False + warnings.warn( + "id-type", + target_type, + f"Ignoring target type {target_type!r} in evaluation context: expected a string ID, " + f"got {type(value).__name__}", + ) + return False diff --git a/src/workos/feature_flags/runtime/_core.py b/src/workos/feature_flags/runtime/_core.py new file mode 100644 index 00000000..19497118 --- /dev/null +++ b/src/workos/feature_flags/runtime/_core.py @@ -0,0 +1,373 @@ +# @oagen-ignore-file +# This file is hand-maintained. State and local evaluation shared by the sync +# and async feature flags runtime clients. +# pyright: strict, reportPrivateUsage=false + +from __future__ import annotations + +import copy +import logging +import os +import sys +import threading +from collections import deque +from dataclasses import replace +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any, Callable, Deque, Optional, Tuple + +from ..._errors import APIError +from . import _policy +from ._configuration import ( + FeatureFlagsConfiguration, + FeatureFlagsConfigurationError, + FlagDefinition, + changed_flags, + configuration_for, +) +from ._context import ContextWarnings, EvaluationContext, LoggerLike, normalize_context +from ._types import FlagsChangedEvent, RuntimeStatus + +if TYPE_CHECKING: + from ..._base_client import _BaseWorkOSClient + +LOGGER_NAME = "workos.feature_flags.runtime" + +Listener = Callable[[Any], object] +Notification = Tuple[Listener, Any] + +# On macOS, once a process has created an HTTP client, a child it forks without +# exec'ing can crash inside system libraries (DNS resolution, proxy settings, +# the system trust store) when it opens a network connection. Every +# WorkOSClient has an HTTP client, so a runtime client created before the fork +# never polls in the child there. +FORK_SAFE_PLATFORM = sys.platform != "darwin" +MACOS_FORK_HAZARD = ( + "on macOS, a forked process cannot safely open network connections once its " + "parent has created an HTTP client" +) + + +def fork_blocked_message(reason: str) -> str: + return ( + f"Feature flag polling does not run in forked process {os.getpid()}: {reason}" + ) + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +class RuntimeClientCore: + """Readiness, status, listeners and local evaluation for a runtime client. + + The configuration snapshot is immutable and replaced by a single reference + assignment, so evaluation never locks and always sees one complete + snapshot, even while a refresh replaces it on another thread. + """ + + def __init__( + self, + client: _BaseWorkOSClient, + *, + polling_interval: float, + request_timeout: float, + max_retry_delay: float, + bootstrap: Optional[FeatureFlagsConfiguration], + logger: Optional[LoggerLike], + ) -> None: + # A client built for public (PKCE) flows has no API key, and every + # poll would fail with a 401. Fail before starting instead. + client._require_api_key() + _require_configuration(bootstrap) + self._polling_interval = _policy.polling_interval(polling_interval) + self._request_timeout = _policy.validate_duration( + request_timeout, "request_timeout" + ) + self._max_retry_delay = _policy.validate_duration( + max_retry_delay, "max_retry_delay" + ) + # Retries of transient failures inside one refresh follow the client's + # `max_retries`, as the Node SDK's transport retries do. + self._max_retries = client._resolve_max_retries(None) + self._logger: LoggerLike = ( + logger if logger is not None else logging.getLogger(LOGGER_NAME) + ) + self._context_warnings = ContextWarnings(self._logger) + self._status = RuntimeStatus(configuration=bootstrap) + self._status_lock = threading.Lock() + self._listeners_lock = threading.Lock() + self._listeners: dict[str, tuple[Listener, ...]] = { + "change": (), + "error": (), + "status": (), + } + self._started = False + self._closed = False + # Notification batches, appended in refresh order while the refresh + # lock is held and delivered after it is released, so a listener may + # call refresh() itself. + self._pending: Deque[list[Notification]] = deque() + # The 401 that stopped polling before any configuration was available. + self._readiness_error: Optional[Exception] = None + + # --- Evaluation ----------------------------------------------------------- + + def is_enabled( + self, + slug: str, + context: Optional[EvaluationContext] = None, + *, + default: bool = False, + ) -> bool: + """Evaluate one flag locally, without a network request. + + A disabled flag serves its off value. For an enabled flag, the first rule + whose conditions all match the context wins; otherwise the flag's + default value applies. ``default`` is returned for a missing flag, an + unready client, and a flag without a boolean answer. Never raises: + invalid context entries are ignored with a logged warning. + + Args: + slug: The flag's slug. + context: Target type to exact ID, e.g. ``{"user": "user_123", + "organization": "org_123", "workspace": "ws_1"}``. + default: Returned when the flag has no answer. + """ + self._before_use() + configuration = self._status.configuration + flag = configuration._compiled(slug) if configuration is not None else None + if flag is None: + return default + answer = flag.evaluate(normalize_context(context, self._context_warnings)) + return default if answer is None else answer + + def get_all_flags( + self, context: Optional[EvaluationContext] = None + ) -> dict[str, bool]: + """Evaluate every flag in one snapshot for the given context. + + Flags without a boolean answer, such as a future multivariate flag, are + omitted so that ``flags.get(slug, my_default)`` applies your default. + An unready client returns an empty dict. + """ + self._before_use() + configuration = self._status.configuration + if configuration is None: + return {} + targets = normalize_context(context, self._context_warnings) + evaluated: dict[str, bool] = {} + for slug, flag in configuration._compiled_items(): + answer = flag.evaluate(targets) + if answer is not None: + evaluated[slug] = answer + return evaluated + + def get_flag(self, slug: str) -> Optional[FlagDefinition]: + """Return one flag's configuration from the current snapshot, for inspection.""" + self._before_use() + configuration = self._status.configuration + return configuration.get_flag(slug) if configuration is not None else None + + @property + def status(self) -> RuntimeStatus: + """Readiness, the current snapshot, refresh timestamps, counts and the last error.""" + self._before_use() + return self._status + + @property + def configuration(self) -> Optional[FeatureFlagsConfiguration]: + """The current snapshot, suitable for persisting with ``to_json()``.""" + self._before_use() + return self._status.configuration + + @property + def is_ready(self) -> bool: + """Whether a validated configuration is available for local evaluation.""" + self._before_use() + return self._status.configuration is not None + + @property + def is_closed(self) -> bool: + """Whether ``close()`` has been called.""" + self._before_use() + return self._closed + + # --- Listeners ------------------------------------------------------------ + + def _add_listener(self, kind: str, listener: Listener) -> None: + if not callable(listener): + raise TypeError(f"{kind} listener must be callable") + with self._listeners_lock: + self._listeners[kind] = (*self._listeners[kind], listener) + + def _remove_listener(self, kind: str, listener: Listener) -> None: + with self._listeners_lock: + self._listeners[kind] = tuple( + registered + for registered in self._listeners[kind] + if registered != listener + ) + + def _notifications(self, kind: str, argument: object) -> list[Notification]: + return [(listener, argument) for listener in self._listeners[kind]] + + # --- Refresh outcomes ----------------------------------------------------- + + def _record_success( + self, + configuration: FeatureFlagsConfiguration, + attempted: datetime, + *, + background: bool, + ) -> list[Notification]: + with self._status_lock: + previous = self._status + current = replace( + previous, + configuration=configuration, + last_successful_refresh_at=utc_now(), + last_refresh_attempt_at=attempted, + last_error=None, + refresh_count=previous.refresh_count + 1, + ) + self._status = current + if background and previous.last_error is not None: + self._logger.info("Feature flag refresh recovered") + self._logger.debug( + "Feature flag refresh succeeded (%d flags)", len(configuration) + ) + notifications = self._notifications("status", current) + # An unchanged payload keeps the same snapshot. A new one can differ + # only outside its flags, and a cache persisted from change events + # still needs it. + if configuration is not previous.configuration: + changed = changed_flags(previous.configuration, configuration) + event = FlagsChangedEvent(previous.configuration, configuration, changed) + notifications += self._notifications("change", event) + return notifications + + def _record_failure( + self, error: Exception, attempted: datetime, *, background: bool + ) -> list[Notification]: + with self._status_lock: + previous = self._status + current = replace( + previous, + last_refresh_attempt_at=attempted, + last_error=error, + refresh_count=previous.refresh_count + 1, + failed_refresh_count=previous.failed_refresh_count + 1, + ) + self._status = current + notifications = self._notifications("status", current) + if not background: + self._logger.debug("Feature flag refresh failed. %s", describe_error(error)) + return notifications + if not _policy.is_unauthorized(error): + # A 401 is logged once polling stops; see _record_polling_stopped. + level = logging.ERROR if _status_code(error) == 403 else logging.WARNING + self._logger.log( + level, + "Feature flag refresh failed; keeping the last good flags and retrying. %s", + describe_error(error), + ) + return notifications + self._notifications("error", error) + + def _record_polling_stopped(self, error: Exception) -> list[Notification]: + with self._status_lock: + current = replace(self._status, polling_stopped=True) + self._status = current + self._logger.error( + "Feature flag polling stopped after a 401; keeping the last good flags. " + "Create a new runtime client with a valid API key to resume. %s", + describe_error(error), + ) + return self._notifications("status", current) + + def _parse(self, payload: object) -> FeatureFlagsConfiguration: + # The transport returns None for a body that is not JSON; the + # configuration rejects that like any other payload it can't read. + try: + return configuration_for(payload, self._status.configuration) + except FeatureFlagsConfigurationError as error: + raise FeatureFlagsConfigurationError( + "Feature flag poll returned a payload this SDK can't read; " + f"keeping the last good flags. {error}" + ) from error + + def _readiness_timeout(self, timeout: Optional[float]) -> TimeoutError: + last_error = self._status.last_error + message = f"Feature flags were not ready within {timeout} seconds" + if last_error is not None: + message += f"; the last refresh failed: {describe_error(last_error)}" + error = TimeoutError(message) + error.__cause__ = last_error + error.__suppress_context__ = True + return error + + def _readiness_failure(self) -> Exception: + """A new exception for each waiter, so no shared traceback keeps growing.""" + stored = self._readiness_error + if stored is None: + return RuntimeError( + "The feature flags runtime client was closed before it became ready" + ) + try: + # Same type and attributes, with an empty traceback. + error = copy.copy(stored) + except Exception: + error = RuntimeError( + f"Feature flag polling stopped before it was ready: {describe_error(stored)}" + ) + error.__cause__ = stored + error.__suppress_context__ = True + return error + + def _before_use(self) -> None: + """Hook run before public calls; the sync client restarts polling after a fork.""" + + def _used_before_fork(self) -> bool: + """Whether this client sent requests, or was polling, in the parent.""" + return self._started or self._status.refresh_count > 0 + + def _reinitialize_core_after_fork(self) -> None: + # The child has only the forking thread, so these locks may have been + # held by threads that no longer exist. + self._status_lock = threading.Lock() + self._listeners_lock = threading.Lock() + self._context_warnings.reinitialize_after_fork() + self._pending.clear() + + def _log_listener_error(self) -> None: + self._logger.exception("A feature flags runtime client listener raised") + + +def describe_error(error: BaseException) -> str: + """A one-line description for logs, with the HTTP status of API errors.""" + if isinstance(error, APIError): + parts = [type(error).__name__] + if error.status_code is not None: + parts.append(f"HTTP {error.status_code}") + if error.message and error.message != "No message": + parts.append(error.message) + description = ": ".join(parts) + return ( + f"{description} (request {error.request_id})" + if error.request_id + else description + ) + message = str(error) + return f"{type(error).__name__}: {message}" if message else type(error).__name__ + + +def _status_code(error: Exception) -> Optional[int]: + status_code = getattr(error, "status_code", None) + return status_code if isinstance(status_code, int) else None + + +def _require_configuration(bootstrap: object) -> None: + if bootstrap is not None and not isinstance(bootstrap, FeatureFlagsConfiguration): + raise TypeError( + "bootstrap must be a FeatureFlagsConfiguration; parse cached JSON with " + "FeatureFlagsConfiguration.from_json()" + ) diff --git a/src/workos/feature_flags/runtime/_factory.py b/src/workos/feature_flags/runtime/_factory.py new file mode 100644 index 00000000..393b3e02 --- /dev/null +++ b/src/workos/feature_flags/runtime/_factory.py @@ -0,0 +1,87 @@ +# @oagen-ignore-file +# This file is hand-maintained. Factory methods that WorkOSClient and +# AsyncWorkOSClient expose as `create_feature_flags_runtime_client`. +# pyright: strict + +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable, Optional + +from . import _policy +from ._async import ( + AsyncFeatureFlagsRuntimeClient, + ChangeListener, + ErrorListener, + StatusListener, +) +from ._configuration import FeatureFlagsConfiguration +from ._context import LoggerLike +from ._sync import FeatureFlagsRuntimeClient +from ._types import FlagsChangedEvent, RuntimeStatus + +if TYPE_CHECKING: + from ..._base_client import AsyncWorkOSClient, WorkOSClient + + +def create_feature_flags_runtime_client( + self: WorkOSClient, + *, + polling_interval: float = _policy.DEFAULT_POLLING_INTERVAL, + request_timeout: float = _policy.DEFAULT_REQUEST_TIMEOUT, + max_retry_delay: float = _policy.DEFAULT_MAX_RETRY_DELAY, + bootstrap: Optional[FeatureFlagsConfiguration] = None, + logger: Optional[LoggerLike] = None, + on_change: Optional[Callable[[FlagsChangedEvent], None]] = None, + on_error: Optional[Callable[[Exception], None]] = None, + on_status: Optional[Callable[[RuntimeStatus], None]] = None, +) -> FeatureFlagsRuntimeClient: + """Create a feature flags runtime client that polls through this client. + + The runtime client evaluates flags locally from a configuration it polls in + the background. Call ``start()`` to begin polling. See + :class:`~workos.feature_flags.runtime.FeatureFlagsRuntimeClient` for the + options. + """ + return FeatureFlagsRuntimeClient( + self, + polling_interval=polling_interval, + request_timeout=request_timeout, + max_retry_delay=max_retry_delay, + bootstrap=bootstrap, + logger=logger, + on_change=on_change, + on_error=on_error, + on_status=on_status, + ) + + +def create_async_feature_flags_runtime_client( + self: AsyncWorkOSClient, + *, + polling_interval: float = _policy.DEFAULT_POLLING_INTERVAL, + request_timeout: float = _policy.DEFAULT_REQUEST_TIMEOUT, + max_retry_delay: float = _policy.DEFAULT_MAX_RETRY_DELAY, + bootstrap: Optional[FeatureFlagsConfiguration] = None, + logger: Optional[LoggerLike] = None, + on_change: Optional[ChangeListener] = None, + on_error: Optional[ErrorListener] = None, + on_status: Optional[StatusListener] = None, +) -> AsyncFeatureFlagsRuntimeClient: + """Create an async feature flags runtime client that polls through this client. + + The runtime client evaluates flags locally from a configuration it polls in + an asyncio task. Call ``await start()`` to begin polling. See + :class:`~workos.feature_flags.runtime.AsyncFeatureFlagsRuntimeClient` for + the options. + """ + return AsyncFeatureFlagsRuntimeClient( + self, + polling_interval=polling_interval, + request_timeout=request_timeout, + max_retry_delay=max_retry_delay, + bootstrap=bootstrap, + logger=logger, + on_change=on_change, + on_error=on_error, + on_status=on_status, + ) diff --git a/src/workos/feature_flags/runtime/_policy.py b/src/workos/feature_flags/runtime/_policy.py new file mode 100644 index 00000000..09ce5415 --- /dev/null +++ b/src/workos/feature_flags/runtime/_policy.py @@ -0,0 +1,123 @@ +# @oagen-ignore-file +# This file is hand-maintained. It holds the polling and retry policy shared +# by the sync and async feature flags runtime clients. +# pyright: strict + +from __future__ import annotations + +import math +import random + +from ..._base_client import RETRY_STATUS_CODES +from ..._errors import ( + APIError, + RateLimitExceededError, + WorkOSConnectionError, + WorkOSError, + WorkOSTimeoutError, +) +from ..._http import TransportError + +# Matches the Node and .NET runtime clients. +DEFAULT_POLLING_INTERVAL = 30.0 +MINIMUM_POLLING_INTERVAL = 5.0 +DEFAULT_REQUEST_TIMEOUT = 10.0 +DEFAULT_MAX_RETRY_DELAY = 60.0 +MINIMUM_DELAY = 1.0 +INITIAL_RETRY_DELAY = 1.0 +JITTER_FACTOR = 0.1 +MAX_COUNTED_FAILURES = 20 + +# Retries inside one refresh: at most the client's `max_retries`, like the +# Node SDK's transport retries, and always within the refresh's request timeout. +_ATTEMPT_RETRY_BASE = 0.5 +# A retry is only worth sending with at least this much of the budget left +# for the request itself. +MINIMUM_ATTEMPT_TIME = 0.5 +# Matches the Node SDK's MAXIMUM_RETRY_AFTER_TIME_IN_MILLISECONDS. +MAXIMUM_RETRY_AFTER = 60.0 +_ATTEMPT_RETRY_MULTIPLIER = 1.5 + +POLL_PATH = ("sdk", "feature-flags") +POLL_PARAMS = {"payload_version": "2"} + + +def validate_duration(value: object, name: str) -> float: + """Reject durations a timer cannot honor.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"{name} must be a number of seconds") + if not math.isfinite(value) or value <= 0: + raise ValueError(f"{name} must be a positive, finite number of seconds") + return float(value) + + +def polling_interval(value: float) -> float: + return max(MINIMUM_POLLING_INTERVAL, validate_duration(value, "polling_interval")) + + +def next_delay(interval: float, failures: int, max_retry_delay: float) -> float: + """Seconds until the next poll. + + Failures back off exponentially from one second up to ``max_retry_delay``, + but never poll sooner than the polling interval. A ten percent jitter in + either direction spreads polls from many processes, with a one second floor. + """ + base = interval + if failures > 0: + exponent = min(failures, MAX_COUNTED_FAILURES) - 1 + base = max(base, min(INITIAL_RETRY_DELAY * 2**exponent, max_retry_delay)) + jitter = 1 + (random.random() * 2 - 1) * JITTER_FACTOR + return max(MINIMUM_DELAY, base * jitter) + + +def is_unauthorized(error: BaseException) -> bool: + """A 401 stops polling: the API key is invalid and retrying cannot help.""" + return isinstance(error, APIError) and error.status_code == 401 + + +def attempt_retry_delay( + error: BaseException, attempt: int, remaining: float, max_retries: int +) -> float | None: + """Seconds to wait before retrying a failed request inside one refresh. + + ``None`` means give up and fail the refresh with ``error``: it is not + transient, ``max_retries`` retries were already made, or the wait would not + leave ``MINIMUM_ATTEMPT_TIME`` of the budget for the retry itself. A retry + sent with almost no time left would only time out and hide the server's + real error. + """ + if attempt >= max_retries or not _is_transient(error): + return None + retry_after = ( + error.retry_after if isinstance(error, RateLimitExceededError) else None + ) + if retry_after is not None: + delay = retry_after + else: + delay = ( + _ATTEMPT_RETRY_BASE + * _ATTEMPT_RETRY_MULTIPLIER**attempt + * (0.5 + random.random()) + ) + return delay if delay + MINIMUM_ATTEMPT_TIME <= remaining else None + + +def retry_after(error: BaseException) -> float | None: + """The wait a 429 asked for, capped at ``MAXIMUM_RETRY_AFTER`` seconds. + + A refresh that could not honor it within its budget fails; the next poll + then waits at least this long. + """ + if isinstance(error, RateLimitExceededError) and error.retry_after is not None: + return min(max(error.retry_after, 0.0), MAXIMUM_RETRY_AFTER) + return None + + +def _is_transient(error: BaseException) -> bool: + if isinstance(error, APIError): + return error.status_code in RETRY_STATUS_CODES + if isinstance(error, (WorkOSTimeoutError, WorkOSConnectionError)): + return True + return isinstance(error, WorkOSError) and isinstance( + error.__cause__, TransportError + ) diff --git a/src/workos/feature_flags/runtime/_sync.py b/src/workos/feature_flags/runtime/_sync.py new file mode 100644 index 00000000..dd0c16ff --- /dev/null +++ b/src/workos/feature_flags/runtime/_sync.py @@ -0,0 +1,487 @@ +# @oagen-ignore-file +# This file is hand-maintained. The synchronous feature flags runtime client +# polls on a background thread and evaluates flags locally. +# pyright: strict, reportPrivateUsage=false + +from __future__ import annotations + +import inspect +import os +import threading +import time +import weakref +from types import TracebackType +from typing import TYPE_CHECKING, Any, Callable, Optional, Type + +from . import _policy +from ._configuration import FeatureFlagsConfiguration +from ._context import LoggerLike +from ._core import Notification, RuntimeClientCore, fork_blocked_message, utc_now +from ._transport import PollTransport +from ._types import FlagsChangedEvent, RuntimeStatus + +if TYPE_CHECKING: + from ..._base_client import WorkOSClient + + +class _Closed(Exception): + """The client was closed while a refresh was in progress.""" + + +class FeatureFlagsRuntimeClient(RuntimeClientCore): + """Polls an environment's feature flag configuration and evaluates flags locally. + + Create one long-lived instance per process and share it across requests. + Evaluation never makes a network request and never raises. Polling runs on + a daemon thread started by :meth:`start` and stopped by :meth:`close`:: + + flags = workos.create_feature_flags_runtime_client() + flags.start() + flags.wait_until_ready(timeout=5) + flags.is_enabled("new-checkout", {"organization": "org_123"}) + + Polling uses the ``WorkOSClient``'s API key, base URL, headers and retry + settings. With the SDK's default HTTP client it polls through an HTTP + client of its own, which a forked child replaces; with an HTTP client you + supplied, close this client before closing yours. A client created before + a fork can start in the child, and one that was polling resumes there, + except through an HTTP client you supplied that the parent already sent + requests through, and on macOS. + """ + + def __init__( + self, + client: WorkOSClient, + *, + polling_interval: float = _policy.DEFAULT_POLLING_INTERVAL, + request_timeout: float = _policy.DEFAULT_REQUEST_TIMEOUT, + max_retry_delay: float = _policy.DEFAULT_MAX_RETRY_DELAY, + bootstrap: Optional[FeatureFlagsConfiguration] = None, + logger: Optional[LoggerLike] = None, + on_change: Optional[Callable[[FlagsChangedEvent], None]] = None, + on_error: Optional[Callable[[Exception], None]] = None, + on_status: Optional[Callable[[RuntimeStatus], None]] = None, + ) -> None: + """Create a runtime client. No request is made until :meth:`start` or :meth:`refresh`. + + Args: + client: The ``WorkOSClient`` whose API key, base URL and HTTP client to poll with. + polling_interval: Seconds between polls, before a 10% jitter. At least 5. + request_timeout: Seconds one refresh may take, including retries of + transient failures. The response is streamed and every network + wait gets only the time left, so a refresh ends within this, plus + at most one read of a response body that stalls. A custom + ``HTTPBackend`` that is not an httpx client gets the remaining + time as its per-request timeout instead. + max_retry_delay: Upper bound in seconds for the exponential backoff + after failed polls. Polls never come sooner than the interval. + bootstrap: A cached configuration to evaluate from before the first + successful poll. Makes the client ready immediately. + logger: Where to log failures and ignored context. Defaults to the + ``workos.feature_flags.runtime`` logger. + on_change: Called after a refresh installs a different configuration, + including the first load. + on_error: Called when a background poll fails. + on_status: Called after every refresh, successful or not, and when + polling stops. + + Raises: + ConfigurationError: If ``client`` has no API key. + """ + super().__init__( + client, + polling_interval=polling_interval, + request_timeout=request_timeout, + max_retry_delay=max_retry_delay, + bootstrap=bootstrap, + logger=logger, + ) + self._client = client + self._transport = PollTransport(client) + self._thread: Optional[threading.Thread] = None + self._delivering: Optional[int] = None + self._fork_restart_pending = False + # Why polling cannot run in this forked child, if it cannot. + self._fork_blocker: Optional[str] = None + self._fork_warned = False + self._init_synchronization() + if on_change is not None: + self.add_change_listener(on_change) + if on_error is not None: + self.add_error_listener(on_error) + if on_status is not None: + self.add_status_listener(on_status) + _clients.add(self) + + def _init_synchronization(self) -> None: + self._lifecycle_lock = threading.Lock() + self._refresh_lock = threading.RLock() + self._drain_lock = threading.Lock() + # `_stop` is set by close(); `_wake` also interrupts the poller's sleep + # when polling stops after a 401. + self._stop = threading.Event() + self._wake = threading.Event() + self._ready = threading.Event() + if self._closed or self._status.ready or self._readiness_error is not None: + self._ready.set() + if self._closed: + self._stop.set() + self._wake.set() + + # --- Lifecycle ------------------------------------------------------------ + + def start(self) -> None: + """Start background polling. Returns immediately and is idempotent. + + The first poll is sent right away, then every ``polling_interval`` + seconds. Failed polls retry with exponential backoff. A 401 stops + polling permanently; the last good configuration stays usable. + + Raises: + RuntimeError: If the client is closed or cannot poll in this forked + process. + """ + self._before_use() + with self._lifecycle_lock: + if self._closed: + raise RuntimeError( + "This feature flags runtime client is closed; create a new one" + ) + self._raise_if_fork_blocked() + if self._started: + return + self._started = True + if self._status.polling_stopped: + self._logger.warning( + "Not starting feature flag polling: it stopped after a 401. " + "Create a new runtime client with a valid API key." + ) + return + self._spawn_poller() + + def wait_until_ready(self, timeout: Optional[float] = None) -> None: + """Block until a bootstrap or a successful refresh makes flags available. + + Does not start polling: call :meth:`start` (or :meth:`refresh`), possibly + from another thread. + + Args: + timeout: Seconds to wait. ``None`` waits indefinitely. + + Raises: + TimeoutError: If flags are not available within ``timeout``. + AuthenticationError: If polling stopped after a 401 before any + configuration was available. + RuntimeError: If the client was closed before it became ready. + """ + self._before_use() + if self._status.ready: + return + if not self._ready.wait(timeout): + raise self._readiness_timeout(timeout) + if not self._status.ready: + raise self._readiness_failure() + + def refresh(self) -> None: + """Fetch the configuration now, on the calling thread. + + Serialized with background polling. Errors are raised to the caller and + the last good configuration is kept; ``on_error`` is not called. A 401 + also stops background polling, as it would from a background poll. + + Raises: + WorkOSError: If the request fails or the payload cannot be read. + RuntimeError: If the client is closed or cannot poll in this forked + process. + """ + self._before_use() + if self._closed: + raise RuntimeError( + "This feature flags runtime client is closed; create a new one" + ) + self._raise_if_fork_blocked() + try: + self._refresh(background=False) + except _Closed: + raise RuntimeError( + "The feature flags runtime client was closed during the refresh" + ) from None + except Exception as error: + if _policy.is_unauthorized(error): + self._stop_polling(error) + raise + + def close(self) -> None: + """Stop polling and release the background thread. Idempotent. + + Waits up to ``request_timeout`` for an in-flight poll to finish, then + closes the HTTP client polling used, if the runtime created it. A poll + still running after that, such as one reading a stalled response, is + abandoned: it ends on its own and changes nothing. Evaluation keeps + serving the last configuration after closing. + """ + with self._lifecycle_lock: + if self._closed: + return + self._closed = True + self._stop.set() + self._wake.set() + self._ready.set() + thread = self._thread + if ( + thread is not None + and thread is not threading.current_thread() + and self._delivering != threading.get_ident() + and thread.is_alive() + ): + thread.join(self._request_timeout + 1.0) + self._transport.close() + + def __enter__(self) -> FeatureFlagsRuntimeClient: + self.start() + return self + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + self.close() + + # --- Listeners ------------------------------------------------------------ + + def add_change_listener( + self, listener: Callable[[FlagsChangedEvent], None] + ) -> None: + """Call ``listener`` after a refresh installs a different configuration. + + Fires on the first load and on the first refresh that differs from a + bootstrap; never for identical refreshes or failures. Persist + ``event.current.to_json()`` here to keep a bootstrap cache fresh. + Listeners run one at a time in refresh order, on the thread that + refreshed or on one already delivering notifications; their exceptions + are logged and never affect polling. + """ + self._add_listener("change", listener) + + def remove_change_listener( + self, listener: Callable[[FlagsChangedEvent], None] + ) -> None: + """Stop calling a change listener.""" + self._remove_listener("change", listener) + + def add_error_listener(self, listener: Callable[[Exception], None]) -> None: + """Call ``listener`` with the error of every failed background poll.""" + self._add_listener("error", listener) + + def remove_error_listener(self, listener: Callable[[Exception], None]) -> None: + """Stop calling an error listener.""" + self._remove_listener("error", listener) + + def add_status_listener(self, listener: Callable[[RuntimeStatus], None]) -> None: + """Call ``listener`` with the new status after every refresh and when polling stops.""" + self._add_listener("status", listener) + + def remove_status_listener(self, listener: Callable[[RuntimeStatus], None]) -> None: + """Stop calling a status listener.""" + self._remove_listener("status", listener) + + # --- Polling -------------------------------------------------------------- + + def _spawn_poller(self) -> None: + thread = threading.Thread( + target=self._poll_forever, name="workos-feature-flags", daemon=True + ) + self._thread = thread + thread.start() + + def _poll_forever(self) -> None: + failures = 0 + while not self._stop.is_set() and not self._status.polling_stopped: + retry_after: Optional[float] = None + try: + self._refresh(background=True) + failures = 0 + except _Closed: + return + except Exception as error: + if self._stop.is_set(): + return + if _policy.is_unauthorized(error): + self._stop_polling(error) + return + failures = min(failures + 1, _policy.MAX_COUNTED_FAILURES) + retry_after = _policy.retry_after(error) + delay = _policy.next_delay( + self._polling_interval, failures, self._max_retry_delay + ) + if self._wake.wait(max(delay, retry_after or 0.0)): + return + + def _refresh(self, *, background: bool) -> None: + try: + with self._refresh_lock: + self._refresh_locked(background=background) + finally: + self._drain() + + def _refresh_locked(self, *, background: bool) -> None: + if self._closed or (background and self._status.polling_stopped): + raise _Closed() + attempted = utc_now() + try: + configuration = self._parse(self._fetch()) + except _Closed: + raise + except Exception as error: + if self._closed: + raise _Closed() from error + self._pending.append( + self._record_failure(error, attempted, background=background) + ) + raise + if self._closed: + raise _Closed() + notifications = self._record_success( + configuration, attempted, background=background + ) + self._ready.set() + self._pending.append(notifications) + + def _fetch(self) -> Any: + deadline = time.monotonic() + self._request_timeout + attempt = 0 + while True: + try: + return self._transport.get(deadline) + except Exception as error: + delay = _policy.attempt_retry_delay( + error, attempt, deadline - time.monotonic(), self._max_retries + ) + if delay is None: + raise + if self._stop.wait(delay): + raise _Closed() from error + attempt += 1 + + def _stop_polling(self, error: Exception) -> None: + with self._refresh_lock: + if self._status.polling_stopped: + return + notifications = self._record_polling_stopped(error) + if not self._status.ready: + self._readiness_error = error + self._wake.set() + self._ready.set() + self._pending.append(notifications) + self._drain() + + def _drain(self) -> None: + """Deliver pending notifications in refresh order, one batch at a time. + + Only one thread delivers at once. A refresh that finishes while + another thread is delivering leaves its batch to that thread, which + also covers a listener calling refresh(): its batch is delivered after + the current one, never interleaved with it. + """ + with self._drain_lock: + if self._delivering is not None: + return + self._delivering = threading.get_ident() + try: + while True: + with self._drain_lock: + if not self._pending: + self._delivering = None + return + batch = self._pending.popleft() + if not self._closed: + self._deliver(batch) + except BaseException: + with self._drain_lock: + self._delivering = None + raise + + def _deliver(self, notifications: list[Notification]) -> None: + for listener, argument in notifications: + try: + result = listener(argument) + except Exception: + self._log_listener_error() + continue + if inspect.isawaitable(result): + _discard_awaitable(result) + self._logger.warning( + "An async listener was registered on the sync FeatureFlagsRuntimeClient " + "and was not run; use AsyncFeatureFlagsRuntimeClient for async listeners" + ) + + # --- Fork safety ---------------------------------------------------------- + + def _before_use(self) -> None: + if self._fork_restart_pending: + self._restart_after_fork() + elif self._fork_blocker is not None and not self._fork_warned: + self._fork_warned = True + self._logger.warning( + "%s. Evaluation keeps serving the last configuration.", + fork_blocked_message(self._fork_blocker), + ) + + def _raise_if_fork_blocked(self) -> None: + if self._fork_blocker is not None: + raise RuntimeError(fork_blocked_message(self._fork_blocker)) + + def _restart_after_fork(self) -> None: + with self._lifecycle_lock: + if not self._fork_restart_pending: + return + self._fork_restart_pending = False + if self._closed: + return + self._logger.info( + "Restarting feature flag polling in forked process %d", os.getpid() + ) + self._spawn_poller() + + def _reinitialize_after_fork(self) -> None: + # The child has only the forking thread. Locks may have been held by + # threads that no longer exist, the poller thread is gone, and the + # parent's HTTP connections must not be shared, so rebuild all of it + # and restart polling, with a new HTTP client, on the next use. + self._reinitialize_core_after_fork() + self._transport.reinitialize_after_fork() + self._delivering = None + self._thread = None + self._init_synchronization() + polling = ( + self._started and not self._closed and not self._status.polling_stopped + ) + self._fork_blocker = ( + None + if self._closed + else self._transport.fork_blocker(self._used_before_fork()) + ) + self._fork_restart_pending = polling and self._fork_blocker is None + # Only a client that was polling warns, on first use, that it no longer + # polls; any other raises from start() and refresh() instead. + self._fork_warned = not polling + + +def _discard_awaitable(awaitable: object) -> None: + close = getattr(awaitable, "close", None) + if callable(close): + close() + + +_clients: weakref.WeakSet[FeatureFlagsRuntimeClient] = weakref.WeakSet() + + +def _reinitialize_clients_after_fork() -> None: + for client in list(_clients): + client._reinitialize_after_fork() + + +if hasattr(os, "register_at_fork"): + os.register_at_fork(after_in_child=_reinitialize_clients_after_fork) diff --git a/src/workos/feature_flags/runtime/_transport.py b/src/workos/feature_flags/runtime/_transport.py new file mode 100644 index 00000000..1004ba89 --- /dev/null +++ b/src/workos/feature_flags/runtime/_transport.py @@ -0,0 +1,207 @@ +# @oagen-ignore-file +# This file is hand-maintained. The sync runtime client's poll request, sent +# with a total time budget and without sharing connections across a fork. +# pyright: strict, reportPrivateUsage=false + +from __future__ import annotations + +import threading +import time +from typing import TYPE_CHECKING, Any, Dict, Optional +from urllib.parse import urlsplit + +import httpx2 + +from ..._errors import WorkOSConnectionError, WorkOSError, WorkOSTimeoutError +from ..._http import ( + HTTPResponse, + HttpxBackend, + TransportConnectError, + TransportError, + TransportTimeout, + _HttpxFamily, +) +from . import _core, _policy + +if TYPE_CHECKING: + from ..._base_client import WorkOSClient + +_PHASES = ("connect", "read", "write", "pool") +_MINIMUM_WAIT = 0.001 + + +class _DeadlineTimeouts(Dict[str, Optional[float]]): + """httpx timeouts that shrink to the time left before a deadline. + + httpcore reads each phase's timeout when the phase starts, so connecting, + sending and waiting for the response each get only what is left of the + budget instead of the whole budget every time. + """ + + def __init__(self, deadline: float) -> None: + super().__init__({phase: None for phase in _PHASES}) + self._deadline = deadline + + def remaining(self) -> float: + return max(self._deadline - time.monotonic(), _MINIMUM_WAIT) + + def get(self, key: str, default: Optional[float] = None) -> Optional[float]: # type: ignore[override] + return self.remaining() if key in _PHASES else super().get(key, default) + + def __getitem__(self, key: str) -> Optional[float]: + return self.remaining() if key in _PHASES else super().__getitem__(key) + + +class PollTransport: + """Sends ``GET /sdk/feature-flags`` for the sync runtime client. + + The request carries the same URL, headers and User-Agent as any other + request from the ``WorkOSClient`` and maps responses and failures to the + same errors. Two things differ: + + - A refresh has a total budget. The body is streamed, every network wait is + limited to the time left, and the response is abandoned once the deadline + passes, so a refresh ends within ``request_timeout`` plus at most one read + of a stalling response body. A caller-supplied ``HTTPBackend`` that is + not an httpx client cannot stream; it gets the remaining budget as its + per-request timeout. + - With the SDK's default HTTP client, polling uses its own httpx2 client + with the same settings, so a forked child can replace it instead of + sharing the parent's connections. A caller-supplied client cannot be + rebuilt that way, so a child does not poll through one the parent sent + requests through. On macOS, a child does not poll at all. + """ + + def __init__(self, workos: WorkOSClient) -> None: + self._workos = workos + backend = workos._backend + self._shared_httpx: Any = None + self._family = _HttpxFamily("httpx2") + if isinstance(backend, HttpxBackend) and not workos._owns_http_client: + self._shared_httpx = backend._client + self._family = backend._family + self._uses_default_client = workos._owns_http_client + self._owned: Optional[httpx2.Client] = None + self._closed = False + self._lock = threading.Lock() + + def fork_blocker(self, used_before_fork: bool) -> Optional[str]: + """Why a forked child cannot poll safely, or ``None`` if it can.""" + if not _core.FORK_SAFE_PLATFORM: + return ( + f"{_core.MACOS_FORK_HAZARD}. Create the WorkOSClient and the " + "runtime client after forking instead" + ) + # The WorkOSClient's other requests count too: they open the same + # HTTP client's connections. + if ( + used_before_fork or self._workos._sent_requests + ) and not self._uses_default_client: + return ( + "the parent used its caller-supplied HTTP client, whose " + "connections this process would share. Create that HTTP client, " + "the WorkOSClient and the runtime client after forking instead" + ) + return None + + def reinitialize_after_fork(self) -> None: + # Abandon, never close, the inherited client: its sockets are the + # parent's connections, and closing it could wait on a pool lock that + # a thread missing from this process held at fork time. + self._owned = None + self._lock = threading.Lock() + + def close(self) -> None: + with self._lock: + self._closed = True + owned, self._owned = self._owned, None + if owned is not None: + owned.close() + + def get(self, deadline: float) -> Any: + """Send one poll request and return the decoded body.""" + workos = self._workos + url = workos._build_request_url(_policy.POLL_PATH, _policy.POLL_PARAMS, None) + headers = workos._build_headers("get", None, None) + try: + response = self._send(url, headers, deadline) + except TransportTimeout as error: + raise WorkOSTimeoutError(f"Request timed out: {error}") from error + except TransportConnectError as error: + raise WorkOSConnectionError(f"Connection failed: {error}") from error + except TransportError as error: + raise WorkOSError(f"Network error: {error}") from error + if response.status_code >= 400: + workos._raise_error(response) + return workos._deserialize_response(response, None) + + def _send(self, url: str, headers: Dict[str, str], deadline: float) -> HTTPResponse: + if deadline - time.monotonic() <= 0: + raise TransportTimeout("no time was left in the request timeout") + if not self._uses_default_client: + self._workos._sent_requests = True + client = self._httpx_client() + if client is None: + return self._workos._backend.request( + "GET", + url, + headers=headers, + content=None, + timeout=max(deadline - time.monotonic(), _MINIMUM_WAIT), + ) + try: + return self._stream(client, url, headers, deadline) + except self._family.timeout_error as error: + raise TransportTimeout(str(error)) from error + except self._family.connect_error as error: + raise TransportConnectError(str(error)) from error + except self._family.http_error as error: + raise TransportError(str(error)) from error + + def _httpx_client(self) -> Any: + if self._shared_httpx is not None: + return self._shared_httpx + if not self._uses_default_client: + return None + with self._lock: + # A poll that close() stopped waiting for must not open a client + # that nothing would close. + if self._closed: + raise RuntimeError("The feature flags runtime client is closed") + if self._owned is None: + # The settings resolve_sync_backend gives the SDK's default client. + self._owned = httpx2.Client( + timeout=self._workos._request_timeout, follow_redirects=True + ) + return self._owned + + def _stream( + self, client: Any, url: str, headers: Dict[str, str], deadline: float + ) -> HTTPResponse: + request = client.build_request( + "GET", + url, + # Explicit params merge client defaults, as HttpxBackend does. + params=urlsplit(url).query or None, + headers=headers, + extensions={"timeout": _DeadlineTimeouts(deadline)}, + ) + response = client.send(request, stream=True) + try: + chunks: list[bytes] = [] + for chunk in response.iter_bytes(): + chunks.append(chunk) + if time.monotonic() > deadline: + raise TransportTimeout( + "the response body did not finish within the request timeout" + ) + content = b"".join(chunks) + finally: + response.close() + return HTTPResponse( + status_code=response.status_code, + headers=response.headers, + content=content, + request_method="GET", + request_url=str(response.request.url), + ) diff --git a/src/workos/feature_flags/runtime/_types.py b/src/workos/feature_flags/runtime/_types.py new file mode 100644 index 00000000..4b068a9d --- /dev/null +++ b/src/workos/feature_flags/runtime/_types.py @@ -0,0 +1,73 @@ +# @oagen-ignore-file +# This file is hand-maintained. Public value types of the feature flags +# runtime client. +# pyright: strict + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone + +from ._configuration import FeatureFlagsConfiguration + + +@dataclass(frozen=True, slots=True) +class FlagsChangedEvent: + """A refresh installed a configuration that differs from the previous one.""" + + previous: FeatureFlagsConfiguration | None + """The snapshot this refresh replaced. + + ``None`` on a first load without bootstrap, and the bootstrap configuration + on the first refresh after one. + """ + current: FeatureFlagsConfiguration + """The newly installed snapshot. Persist ``current.to_json()`` to bootstrap later.""" + changed_flags: frozenset[str] + """Slugs that were added, removed or modified. Every slug on a first load. + + Empty when only fields outside the flags changed. + """ + + +@dataclass(frozen=True, slots=True) +class RuntimeStatus: + """An immutable view of readiness and the most recent refresh result.""" + + configuration: FeatureFlagsConfiguration | None = None + """The last valid snapshot. Failed refreshes never replace it.""" + last_successful_refresh_at: datetime | None = None + """When the last successful network refresh finished. ``None`` for bootstrap-only readiness.""" + last_refresh_attempt_at: datetime | None = None + """When the most recent finished refresh started.""" + last_error: Exception | None = None + """The most recent refresh failure, cleared by the next successful refresh.""" + refresh_count: int = 0 + """Refreshes that finished, successfully or not, whether polled or manual.""" + failed_refresh_count: int = 0 + """Finished refreshes that failed, including timeouts and unreadable payloads.""" + polling_stopped: bool = False + """Background polling stopped after a 401. + + The last good snapshot stays usable but is never refreshed again. Fix the + API key and create a new runtime client. Closing the client does not set + this. + """ + + @property + def ready(self) -> bool: + """Whether a validated configuration is available for local evaluation.""" + return self.configuration is not None + + @property + def flag_count(self) -> int: + """Number of flags in the current snapshot.""" + return len(self.configuration) if self.configuration is not None else 0 + + def cache_age(self) -> float | None: + """Seconds since the last successful network refresh, or ``None`` if there was none.""" + if self.last_successful_refresh_at is None: + return None + return ( + datetime.now(timezone.utc) - self.last_successful_refresh_at + ).total_seconds() diff --git a/tests/feature_flags_runtime/__init__.py b/tests/feature_flags_runtime/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/feature_flags_runtime/conftest.py b/tests/feature_flags_runtime/conftest.py new file mode 100644 index 00000000..8106ef8b --- /dev/null +++ b/tests/feature_flags_runtime/conftest.py @@ -0,0 +1,97 @@ +# @oagen-ignore-file +"""Fixtures for the feature flags runtime client tests.""" + +from __future__ import annotations + +from typing import Any, AsyncIterator, Callable, Iterator, List + +import pytest +import pytest_asyncio + +from workos import AsyncWorkOSClient, WorkOSClient +from workos.feature_flags.runtime import ( + AsyncFeatureFlagsRuntimeClient, + FeatureFlagsRuntimeClient, + _policy, +) + +from tests.feature_flags_runtime.helpers import ( + API_KEY, + BASE_URL, + AsyncBackend, + FlagServer, + SyncBackend, +) + + +@pytest.fixture +def server() -> FlagServer: + return FlagServer() + + +@pytest.fixture +def client(server: FlagServer) -> Iterator[WorkOSClient]: + workos = WorkOSClient( + api_key=API_KEY, base_url=BASE_URL, http_client=SyncBackend(server) + ) + yield workos + workos.close() + + +@pytest.fixture +def async_client(server: FlagServer) -> AsyncWorkOSClient: + return AsyncWorkOSClient( + api_key=API_KEY, base_url=BASE_URL, http_client=AsyncBackend(server) + ) + + +@pytest.fixture +def fast_polling(monkeypatch: pytest.MonkeyPatch) -> List[int]: + """Poll every 10 ms and retry inside a refresh after 5 ms. + + Returns the failure counts the poller computed delays for, so tests can + assert on backoff without waiting for it. + """ + failures: List[int] = [] + + def next_delay(interval: float, count: int, max_retry_delay: float) -> float: + failures.append(count) + return 0.01 + + monkeypatch.setattr(_policy, "next_delay", next_delay) + monkeypatch.setattr(_policy, "_ATTEMPT_RETRY_BASE", 0.005) + return failures + + +@pytest.fixture +def make_runtime( + client: WorkOSClient, +) -> Iterator[Callable[..., FeatureFlagsRuntimeClient]]: + """Create runtime clients that are always closed at teardown.""" + created: List[FeatureFlagsRuntimeClient] = [] + + def make(**options: Any) -> FeatureFlagsRuntimeClient: + runtime = client.create_feature_flags_runtime_client(**options) + created.append(runtime) + return runtime + + yield make + for runtime in created: + runtime.close() + + +@pytest_asyncio.fixture +async def make_async_runtime( + async_client: AsyncWorkOSClient, +) -> AsyncIterator[Callable[..., AsyncFeatureFlagsRuntimeClient]]: + """Create async runtime clients that are always closed at teardown.""" + created: List[AsyncFeatureFlagsRuntimeClient] = [] + + def make(**options: Any) -> AsyncFeatureFlagsRuntimeClient: + runtime = async_client.create_feature_flags_runtime_client(**options) + created.append(runtime) + return runtime + + yield make + for runtime in created: + await runtime.close() diff --git a/tests/feature_flags_runtime/fixtures/change_cases.json b/tests/feature_flags_runtime/fixtures/change_cases.json new file mode 100644 index 00000000..25c90f81 --- /dev/null +++ b/tests/feature_flags_runtime/fixtures/change_cases.json @@ -0,0 +1,958 @@ +{ + "description": "Change detection between two valid payloads. changed lists the slugs a change notification reports. A case without before starts from the top-level baseline payload.", + "format": 1, + "baseline": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "cases": [ + { + "name": "rule order", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + }, + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "rule value", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "membership", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_2" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "off value", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": true, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "default value", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "enabled", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": false, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "rule kind", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "rollout", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "condition operator", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "not_one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "target type", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "workspace", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "removed rule", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "membership and condition order do not count", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "before": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1", + "user_2" + ] + }, + { + "target_type": "workspace", + "operator": "one_of", + "values": [ + "ws_1" + ] + } + ] + } + ] + } + } + }, + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "workspace", + "operator": "one_of", + "values": [ + "ws_1" + ] + }, + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_2", + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [] + }, + { + "name": "additions and removals", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "after": { + "version": 2, + "flags": { + "added": { + "slug": "added", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "added", + "flag" + ] + }, + { + "name": "flag and property order do not count", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "before": { + "version": 2, + "flags": { + "alpha": { + "slug": "alpha", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [] + }, + "beta": { + "slug": "beta", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [] + } + } + }, + "after": { + "version": 2, + "flags": { + "beta": { + "rules": [], + "slug": "beta", + "off_value": false, + "default_value": true, + "enabled": true + }, + "alpha": { + "slug": "alpha", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [] + } + } + }, + "changed": [] + }, + { + "name": "a field of an unknown rule kind counts", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "before": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "r", + "kind": "rollout", + "value": true, + "percentage": 10 + } + ] + } + } + }, + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "r", + "kind": "rollout", + "value": true, + "percentage": 20 + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "two non-boolean served values count", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "before": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": "a", + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": "b", + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "values of an unknown operator keep their order", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "before": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "in_order", + "values": [ + "a", + "b" + ] + } + ] + } + ] + } + } + }, + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "in_order", + "values": [ + "b", + "a" + ] + } + ] + } + ] + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "an unknown flag field counts", + "source": "workos-python (new)", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ], + "rollout_seed": "abc" + } + } + }, + "changed": [ + "flag" + ] + }, + { + "name": "identical payloads", + "source": "workos-python (new)", + "after": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "changed": [] + } + ] +} diff --git a/tests/feature_flags_runtime/fixtures/context_cases.json b/tests/feature_flags_runtime/fixtures/context_cases.json new file mode 100644 index 00000000..d1470185 --- /dev/null +++ b/tests/feature_flags_runtime/fixtures/context_cases.json @@ -0,0 +1,204 @@ +{ + "description": "Evaluation context validation. targets is what remains after invalid entries are ignored; warns says whether a warning is logged. Validation never raises.", + "format": 1, + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "workspace", + "operator": "one_of", + "values": [ + "ws_1" + ] + } + ] + }, + { + "id": "r2", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "cases": [ + { + "name": "a valid custom target", + "context": { + "workspace": "ws_1" + }, + "targets": { + "workspace": "ws_1" + }, + "warns": false + }, + { + "name": "built-in types are plain keys", + "context": { + "user": "user_1", + "organization": "org_1" + }, + "targets": { + "user": "user_1", + "organization": "org_1" + }, + "warns": false + }, + { + "name": "null IDs are skipped silently", + "context": { + "user": null, + "workspace": "ws_1" + }, + "targets": { + "workspace": "ws_1" + }, + "warns": false + }, + { + "name": "an uppercase type is ignored", + "context": { + "Workspace": "ws_1" + }, + "targets": {}, + "warns": true + }, + { + "name": "a type starting with a digit is ignored", + "context": { + "1workspace": "ws_1" + }, + "targets": {}, + "warns": true + }, + { + "name": "a 65-character type is ignored", + "context": { + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww": "ws_1" + }, + "targets": {}, + "warns": true + }, + { + "name": "a 64-character type is accepted", + "context": { + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww": "ws_1" + }, + "targets": { + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww": "ws_1" + }, + "warns": false + }, + { + "name": "a dot ID is ignored", + "context": { + "workspace": "." + }, + "targets": {}, + "warns": true + }, + { + "name": "a dot-dot ID is ignored", + "context": { + "workspace": ".." + }, + "targets": {}, + "warns": true + }, + { + "name": "an ID with a space is ignored", + "context": { + "workspace": "ws 1" + }, + "targets": {}, + "warns": true + }, + { + "name": "an ID with a trailing newline is ignored", + "context": { + "workspace": "ws_1\n" + }, + "targets": {}, + "warns": true + }, + { + "name": "an empty ID is ignored", + "context": { + "workspace": "" + }, + "targets": {}, + "warns": true + }, + { + "name": "a 256-character ID is ignored", + "context": { + "workspace": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "targets": {}, + "warns": true + }, + { + "name": "a 255-character ID is accepted", + "context": { + "workspace": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "targets": { + "workspace": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "warns": false + }, + { + "name": "a non-string ID is ignored", + "context": { + "workspace": 1 + }, + "targets": {}, + "warns": true + }, + { + "name": "an object ID is ignored", + "context": { + "workspace": { + "id": "ws_1" + } + }, + "targets": {}, + "warns": true + }, + { + "name": "invalid entries do not affect valid ones", + "context": { + "Workspace": "ws_1", + "user": "user_1" + }, + "targets": { + "user": "user_1" + }, + "warns": true + }, + { + "name": "a non-ASCII ID is ignored", + "context": { + "workspace": "wś_1" + }, + "targets": {}, + "warns": true + } + ] +} diff --git a/tests/feature_flags_runtime/fixtures/evaluation_cases.json b/tests/feature_flags_runtime/fixtures/evaluation_cases.json new file mode 100644 index 00000000..fe21e6ee --- /dev/null +++ b/tests/feature_flags_runtime/fixtures/evaluation_cases.json @@ -0,0 +1,2352 @@ +{ + "description": "Feature flag evaluation cases shaped for the shared cross-SDK corpus (AUTH-7098). Each case is one v2 flag entry; build the payload {version: 2, flags: {: flag}}, where key defaults to the flag's slug, and evaluate by that key. context maps a target type to an exact ID. An evaluation may carry a name describing what it checks. expect is true, false, or \"absent\": the flag has no boolean answer, so the caller's default applies. A runner must evaluate every case with both caller defaults and fail on unknown case fields. malformed marks cases that deliberately leave the v2 schema.", + "format": 1, + "cases": [ + { + "name": "rules are evaluated in payload order and the first match wins", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "name": "an earlier matching false rule wins and stops evaluation", + "context": { + "user": "user_1", + "organization": "org_1" + }, + "expect": false + }, + { + "name": "a later rule matches when earlier rules do not", + "context": { + "user": "user_1" + }, + "expect": true + }, + { + "name": "no match serves the flag default, not the caller default", + "context": {}, + "expect": true + } + ] + }, + { + "name": "off value overrides matching rules and both defaults", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": false, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": false + } + ] + }, + { + "name": "a boolean off value is honored", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": false, + "default_value": true, + "off_value": true, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": {}, + "expect": true + } + ] + }, + { + "name": "an empty rule list serves the flag default", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [] + }, + "evaluations": [ + { + "context": {}, + "expect": false + } + ] + }, + { + "name": "membership matches only the exact ID of the same target type", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "name": "a missing context type does not match", + "context": {}, + "expect": false + }, + { + "name": "membership uses exact, case-sensitive IDs", + "context": { + "user": "USER_1" + }, + "expect": false + }, + { + "name": "an ID from another type does not match", + "context": { + "workspace": "user_1" + }, + "expect": false + } + ] + }, + { + "name": "reordering conflicting target types changes the winner", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + }, + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1", + "organization": "org_1" + }, + "expect": true + } + ] + }, + { + "name": "a later rule can match", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": true + } + ] + }, + { + "name": "custom target membership can match any listed ID", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "workspace", + "operator": "one_of", + "values": [ + "ws_0", + "ws_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "workspace": "ws_1" + }, + "expect": true + }, + { + "context": { + "workspace": "ws_2" + }, + "expect": false + } + ] + }, + { + "name": "a rule matches only when every condition matches", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + }, + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "name": "all conditions must match", + "context": { + "user": "user_1", + "organization": "org_1" + }, + "expect": true + }, + { + "name": "one matching condition cannot override a failed condition", + "context": { + "user": "user_1", + "organization": "org_other" + }, + "expect": false + }, + { + "name": "one matching condition cannot override a missing context type", + "context": { + "user": "user_1" + }, + "expect": false + } + ] + }, + { + "name": "empty conditions never match", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [] + } + ] + }, + "evaluations": [ + { + "context": {}, + "expect": false + }, + { + "context": { + "user": "user_1" + }, + "expect": false + } + ], + "malformed": true + }, + { + "name": "empty membership never matches", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": false + } + ] + }, + { + "name": "unknown rule kinds are skipped even with matching conditions", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "rollout", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": false + } + ], + "malformed": true + }, + { + "name": "unknown rules without conditions allow later rules to match", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "future", + "kind": "rollout", + "value": false + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": true + } + ], + "malformed": true + }, + { + "name": "unknown operators invalidate the whole rule", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + }, + { + "operator": "segment" + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": false + } + ], + "malformed": true + }, + { + "name": "unknown operators allow later rules to match", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "user", + "operator": "not_one_of", + "values": [ + "user_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": true + } + ], + "malformed": true + }, + { + "name": "unparseable matching values use the caller default and stop", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": "true", + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": "absent" + } + ], + "malformed": true + }, + { + "name": "unparseable off values use the caller default", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": false, + "default_value": false, + "off_value": 0, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": {}, + "expect": "absent" + } + ], + "malformed": true + }, + { + "name": "unparseable defaults use the caller default", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": "false", + "off_value": false, + "rules": [] + }, + "evaluations": [ + { + "context": {}, + "expect": "absent" + } + ], + "malformed": true + }, + { + "name": "unparseable values in unmatched rules have no effect", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": null, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_other" + }, + "expect": true + }, + { + "context": { + "organization": "org_1" + }, + "expect": "absent" + } + ], + "malformed": true + }, + { + "name": "membership IDs that are not strings never match", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + 1 + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "1" + }, + "expect": false + } + ], + "malformed": true + }, + { + "name": "a disabled flag serves its off value", + "source": "workos-node src/feature-flags/evaluator.spec.ts", + "flag": { + "slug": "disabled-flag", + "enabled": false, + "default_value": true, + "off_value": false, + "rules": [] + }, + "evaluations": [ + { + "context": {}, + "expect": false + }, + { + "context": { + "user": "user_456" + }, + "expect": false + } + ] + }, + { + "name": "an enabled flag without rules serves its default", + "source": "workos-node src/feature-flags/evaluator.spec.ts", + "flag": { + "slug": "enabled-flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [] + }, + "evaluations": [ + { + "context": {}, + "expect": true + }, + { + "context": { + "user": "user_456" + }, + "expect": true + } + ] + }, + { + "name": "targeted flag matches built-in and custom types", + "source": "workos-node src/feature-flags/evaluator.spec.ts", + "flag": { + "slug": "targeted-flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_organization", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_123" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_456" + ] + } + ] + }, + { + "id": "rule_workspace", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "workspace", + "operator": "one_of", + "values": [ + "ws_123" + ] + } + ] + }, + { + "id": "rule_region", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "region", + "operator": "one_of", + "values": [ + "us-east-1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_123" + }, + "expect": true + }, + { + "context": { + "user": "user_456" + }, + "expect": true + }, + { + "context": { + "user": "user_other", + "organization": "org_123" + }, + "expect": true + }, + { + "context": { + "workspace": "ws_123" + }, + "expect": true + }, + { + "context": { + "region": "us-east-1" + }, + "expect": true + }, + { + "context": { + "workspace": "ws_456" + }, + "expect": false + }, + { + "context": {}, + "expect": false + } + ] + }, + { + "name": "served values: off value wins even if a rule matches", + "source": "workos-node src/feature-flags/evaluator.spec.ts", + "flag": { + "slug": "flag", + "enabled": false, + "default_value": false, + "off_value": true, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_456" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_456", + "organization": "org_123" + }, + "expect": true + } + ] + }, + { + "name": "served values: default when no rule matches", + "source": "workos-node src/feature-flags/evaluator.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_other" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_456", + "organization": "org_123" + }, + "expect": true + } + ] + }, + { + "name": "served values: first matching rule when it is true", + "source": "workos-node src/feature-flags/evaluator.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_456" + ] + } + ] + }, + { + "id": "rule_organization", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_123" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_456", + "organization": "org_123" + }, + "expect": true + } + ] + }, + { + "name": "served values: first matching rule when it is false", + "source": "workos-node src/feature-flags/evaluator.spec.ts", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_456" + ] + } + ] + }, + { + "id": "rule_organization", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_123" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_456", + "organization": "org_123" + }, + "expect": false + } + ] + }, + { + "name": "ordered: first false rule beats a later true rule and a true default", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "flag": { + "slug": "ordered", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_acme" + ] + } + ] + }, + { + "id": "r2", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_jp" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_jp", + "organization": "org_acme" + }, + "expect": false + }, + { + "context": {}, + "expect": true + }, + { + "context": { + "user": "user_jp" + }, + "expect": true + } + ] + }, + { + "name": "disabled: serves off_value even if a rule matches", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "flag": { + "slug": "disabled", + "enabled": false, + "default_value": false, + "off_value": true, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_acme" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_jp", + "organization": "org_acme" + }, + "expect": true + } + ] + }, + { + "name": "both: every condition must match", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "flag": { + "slug": "both", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_acme" + ] + }, + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_jp" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_jp", + "organization": "org_acme" + }, + "expect": true + }, + { + "context": { + "organization": "org_acme" + }, + "expect": false + }, + { + "context": { + "user": "user_jp" + }, + "expect": false + }, + { + "context": { + "user": "USER_JP", + "organization": "org_acme" + }, + "expect": false + } + ] + }, + { + "name": "custom: custom target types match exact IDs", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "flag": { + "slug": "custom", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "project", + "operator": "one_of", + "values": [ + "project_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_jp", + "organization": "org_acme" + }, + "expect": false + }, + { + "context": { + "project": "project_1" + }, + "expect": true + }, + { + "context": { + "project": "different" + }, + "expect": false + } + ] + }, + { + "name": "variant_default: a non-boolean default has no answer", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "malformed": true, + "flag": { + "slug": "variant_default", + "enabled": true, + "default_value": "variant-a", + "off_value": false, + "rules": [] + }, + "evaluations": [ + { + "context": {}, + "expect": "absent" + } + ] + }, + { + "name": "variant_off: a non-boolean off value has no answer", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "malformed": true, + "flag": { + "slug": "variant_off", + "enabled": false, + "default_value": true, + "off_value": "variant-off", + "rules": [] + }, + "evaluations": [ + { + "context": {}, + "expect": "absent" + } + ] + }, + { + "name": "variant_rule: only a matched non-boolean rule has no answer", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "malformed": true, + "flag": { + "slug": "variant_rule", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": "variant-b", + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_acme" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_acme" + }, + "expect": "absent" + }, + { + "context": {}, + "expect": false + } + ] + }, + { + "name": "missing_default: a missing default_value has no answer", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "malformed": true, + "flag": { + "slug": "missing_default", + "enabled": true, + "off_value": false, + "rules": [] + }, + "evaluations": [ + { + "context": {}, + "expect": "absent" + } + ] + }, + { + "name": "unknown kind without conditions never matches everyone", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "malformed": true, + "flag": { + "slug": "test", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "percentage", + "value": true + } + ] + }, + "evaluations": [ + { + "context": {}, + "expect": false + }, + { + "context": { + "organization": "org_acme" + }, + "expect": false + } + ] + }, + { + "name": "empty condition list never matches everyone", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "malformed": true, + "flag": { + "slug": "test", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [] + } + ] + }, + "evaluations": [ + { + "context": {}, + "expect": false + }, + { + "context": { + "organization": "org_acme" + }, + "expect": false + } + ] + }, + { + "name": "unknown operator never matches everyone", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "malformed": true, + "flag": { + "slug": "test", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "operator": "future" + } + ] + } + ] + }, + "evaluations": [ + { + "context": {}, + "expect": false + }, + { + "context": { + "organization": "org_acme" + }, + "expect": false + } + ] + }, + { + "name": "empty one_of membership never matches", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "flag": { + "slug": "test", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [] + } + ] + } + ] + }, + "evaluations": [ + { + "context": {}, + "expect": false + }, + { + "context": { + "organization": "org_acme" + }, + "expect": false + } + ] + }, + { + "name": "server: off consults no rules", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": false, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_1" + }, + "expect": false + } + ] + }, + { + "name": "server: off value disagrees with every rule", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": false, + "default_value": false, + "off_value": true, + "rules": [ + { + "id": "rule_1", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": true + } + ] + }, + { + "name": "server: default when no rules", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [] + }, + "evaluations": [ + { + "context": {}, + "expect": true + } + ] + }, + { + "name": "server: default when no rule matches", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_1", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_2" + }, + "expect": true + } + ] + }, + { + "name": "server: a rule whose target type is absent is skipped", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": false + } + ] + }, + { + "name": "server: user rule first", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + }, + { + "id": "rule_org", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_1", + "user": "user_1" + }, + "expect": false + } + ] + }, + { + "name": "server: organization rule first", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_1", + "user": "user_1" + }, + "expect": true + } + ] + }, + { + "name": "server: passes a non-matching rule to reach a later one", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_1", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_other" + ] + } + ] + }, + { + "id": "rule_2", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_1" + }, + "expect": true + } + ] + }, + { + "name": "server: matches an ID anywhere in the values", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1", + "org_2", + "org_3" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_2" + }, + "expect": true + } + ] + }, + { + "name": "server: custom type matches like a built-in one", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "workspace", + "operator": "one_of", + "values": [ + "ws_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "workspace": "ws_1" + }, + "expect": true + } + ] + }, + { + "name": "server: every condition must match", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + }, + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_1", + "user": "user_other" + }, + "expect": false + }, + { + "context": { + "organization": "org_1" + }, + "expect": false + }, + { + "context": { + "organization": "org_1", + "user": "user_1" + }, + "expect": true + } + ] + }, + { + "name": "server: an empty condition list never matches", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "malformed": true, + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_1", + "kind": "conditions", + "value": true, + "conditions": [] + } + ] + }, + "evaluations": [ + { + "context": { + "organization": "org_1" + }, + "expect": false + } + ] + }, + { + "name": "server: unknown flag fields are ignored", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [], + "rollout_seed": "abc" + }, + "evaluations": [ + { + "context": {}, + "expect": true + } + ] + }, + { + "name": "server: unknown rule fields are ignored", + "source": "workos packages/api-services/src/flags/evaluate-flag.spec.ts", + "flag": { + "slug": "test-flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "rule_1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ], + "percentage": 50 + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": true + } + ] + }, + { + "name": "an unknown kind with an unfamiliar condition shape is skipped, not rejected", + "source": "workos-python (new)", + "malformed": true, + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "rollout", + "value": true, + "conditions": [ + { + "attribute": "plan", + "equals": "pro" + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": true + }, + { + "context": {}, + "expect": false + } + ] + }, + { + "name": "a conditions rule without conditions never matches", + "source": "workos-python (new)", + "malformed": true, + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": true + }, + { + "context": {}, + "expect": false + } + ] + }, + { + "name": "a one_of condition without a target type never matches", + "source": "workos-python (new)", + "malformed": true, + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": false + } + ] + }, + { + "name": "a missing off_value has no answer when the flag is off", + "source": "workos-python (new)", + "malformed": true, + "flag": { + "slug": "flag", + "enabled": false, + "default_value": true, + "rules": [] + }, + "evaluations": [ + { + "context": {}, + "expect": "absent" + } + ] + }, + { + "name": "duplicate IDs in a membership list still match", + "source": "workos-python (new)", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1", + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": true + } + ] + }, + { + "name": "a flag is looked up by its map key when its slug differs", + "source": "workos-python (new)", + "malformed": true, + "key": "renamed-flag", + "flag": { + "slug": "lab-ordered", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1" + }, + "expect": true + }, + { + "context": {}, + "expect": false + } + ] + }, + { + "name": "IDs with every allowed punctuation character match exactly", + "source": "workos-python (new)", + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "deploy-env_v2", + "operator": "one_of", + "values": [ + "a.b_c:d-e" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "deploy-env_v2": "a.b_c:d-e" + }, + "expect": true + }, + { + "context": { + "deploy-env_v2": "a.b_c:d-E" + }, + "expect": false + } + ] + } + ] +} diff --git a/tests/feature_flags_runtime/fixtures/payload_cases.json b/tests/feature_flags_runtime/fixtures/payload_cases.json new file mode 100644 index 00000000..fda0c8dd --- /dev/null +++ b/tests/feature_flags_runtime/fixtures/payload_cases.json @@ -0,0 +1,761 @@ +{ + "description": "Whole-payload validation. valid=false means the payload is rejected and a runtime client keeps its last good configuration. divergence records where Node 12 or .NET 6.6 answers differently.", + "format": 1, + "cases": [ + { + "name": "a populated v2 payload", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "valid": true + }, + { + "name": "an empty v2 payload", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": {} + }, + "valid": true + }, + { + "name": "extra top-level and flag keys are ignored", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ], + "future": 1 + } + }, + "future": { + "version": 9 + } + }, + "valid": true + }, + { + "name": "a future version", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 3, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "valid": false + }, + { + "name": "a string version", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": "2", + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "valid": false + }, + { + "name": "a missing version", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "valid": false + }, + { + "name": "version 1", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "payload": { + "version": 1, + "flags": {} + }, + "valid": false + }, + { + "name": "a missing flags map", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "payload": { + "version": 2 + }, + "valid": false + }, + { + "name": "null flags", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": null + }, + "valid": false + }, + { + "name": "null rules", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": null + } + } + }, + "valid": false + }, + { + "name": "the legacy v1 flat payload", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "targets": { + "users": [ + { + "id": "user_1", + "enabled": true + } + ], + "organizations": [] + } + } + }, + "valid": false + }, + { + "name": "a bare entry map", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_org", + "kind": "conditions", + "value": false, + "conditions": [ + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + }, + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + }, + "valid": false + }, + { + "name": "a one_of with a string instead of a list", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": "user_1" + } + ] + } + ] + } + } + }, + "valid": false + }, + { + "name": "a one_of without values", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of" + } + ] + } + ] + } + } + }, + "valid": false + }, + { + "name": "null", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": null, + "valid": false + }, + { + "name": "an array", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": [], + "valid": false + }, + { + "name": "a scalar", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": false, + "valid": false + }, + { + "name": "a string enabled", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "payload": { + "version": 2, + "flags": { + "test": { + "slug": "test", + "enabled": "true", + "default_value": false, + "off_value": false, + "rules": [] + } + } + }, + "valid": false + }, + { + "name": "a slug that differs from its key is accepted", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": { + "test": { + "slug": "other", + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [] + } + } + }, + "valid": true, + "note": "the map key is the flag's identity", + "divergence": { + "dotnet": "rejects" + } + }, + { + "name": "a missing slug", + "source": "workos-python (new)", + "payload": { + "version": 2, + "flags": { + "test": { + "enabled": true, + "default_value": false, + "off_value": false, + "rules": [] + } + } + }, + "valid": false + }, + { + "name": "version true", + "source": "workos-python (new)", + "payload": { + "version": true, + "flags": {} + }, + "valid": false, + "note": "bool is a subclass of int in Python" + }, + { + "name": "version 2.0", + "source": "workos-python (new)", + "payload": { + "version": 2.0, + "flags": {} + }, + "valid": false, + "note": "2.0 == 2 in Python; .NET also rejects a float token", + "divergence": { + "node": "accepts (JSON 2.0 parses to 2)" + } + }, + { + "name": "a rule without an id", + "source": "workos-python (new)", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "kind": "conditions", + "value": true, + "conditions": [] + } + ] + } + } + }, + "valid": false + }, + { + "name": "a condition without an operator in a conditions rule", + "source": "workos-python (new)", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "valid": false + }, + { + "name": "a missing default_value is accepted", + "source": "workos-dotnet test/WorkOSTests/FeatureFlags/FeatureFlagsRuntimeClientTest.cs", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "off_value": false, + "rules": [] + } + } + }, + "valid": true, + "divergence": { + "node": "rejects" + } + }, + { + "name": "a missing off_value is accepted", + "source": "workos-python (new)", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": false, + "rules": [] + } + } + }, + "valid": true, + "divergence": { + "node": "rejects" + } + }, + { + "name": "conditions of an unknown kind are not validated", + "source": "workos-python (new)", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "rollout", + "value": true, + "conditions": [ + { + "attribute": "plan" + } + ] + } + ] + } + } + }, + "valid": true, + "divergence": { + "node": "rejects" + } + }, + { + "name": "a conditions rule without conditions is accepted", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true + } + ] + } + } + }, + "valid": true, + "divergence": { + "dotnet": "rejects" + } + }, + { + "name": "a one_of without a target type is accepted", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "r1", + "kind": "conditions", + "value": true, + "conditions": [ + { + "operator": "one_of", + "values": [ + "user_1" + ] + } + ] + } + ] + } + } + }, + "valid": true, + "divergence": { + "dotnet": "rejects" + } + }, + { + "name": "non-string one_of IDs are accepted", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "target_type": "user", + "operator": "one_of", + "values": [ + 1 + ] + } + ] + } + ] + } + } + }, + "valid": true, + "divergence": { + "dotnet": "rejects" + } + }, + { + "name": "operands of an unknown operator are not validated", + "source": "workos-python (new)", + "payload": { + "version": 2, + "flags": { + "flag": { + "slug": "flag", + "enabled": true, + "default_value": true, + "off_value": false, + "rules": [ + { + "id": "rule_user", + "kind": "conditions", + "value": true, + "conditions": [ + { + "operator": "segment", + "target_type": { + "nested": true + } + } + ] + } + ] + } + } + }, + "valid": true, + "divergence": { + "node": "rejects a non-string target_type" + } + } + ] +} diff --git a/tests/feature_flags_runtime/helpers.py b/tests/feature_flags_runtime/helpers.py new file mode 100644 index 00000000..c106ff3f --- /dev/null +++ b/tests/feature_flags_runtime/helpers.py @@ -0,0 +1,309 @@ +# @oagen-ignore-file +"""Helpers for the feature flags runtime client tests.""" + +from __future__ import annotations + +import json +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Union + +import httpx2 + +from workos import HTTPResponse + +FIXTURES = Path(__file__).parent / "fixtures" +API_KEY = "sk_test_runtime" +BASE_URL = "https://api.workos.test" + + +def load_fixture(name: str) -> Dict[str, Any]: + return json.loads((FIXTURES / name).read_text()) + + +def payload(**flags: Dict[str, Any]) -> Dict[str, Any]: + return {"version": 2, "flags": flags} + + +def flag( + slug: str = "flag", + *, + enabled: bool = True, + default_value: Any = False, + off_value: Any = False, + rules: Optional[List[Dict[str, Any]]] = None, +) -> Dict[str, Any]: + return { + "slug": slug, + "enabled": enabled, + "default_value": default_value, + "off_value": off_value, + "rules": rules or [], + } + + +def rule( + rule_id: str, value: Any, target_type: str, *ids: str, kind: str = "conditions" +) -> Dict[str, Any]: + return { + "id": rule_id, + "kind": kind, + "value": value, + "conditions": [ + {"target_type": target_type, "operator": "one_of", "values": list(ids)} + ], + } + + +@dataclass +class RecordedRequest: + method: str + url: str + headers: Dict[str, str] + timeout: float + + +Reply = Union[tuple, Exception, Callable[[], Any]] + + +class FlagServer: + """Scripted stand-in for the API behind an HTTPBackend. + + Replies queued with ``enqueue`` are used once, in order; otherwise every + request gets the ``respond`` reply. A reply is ``(status, body)``, an + exception to raise, or ``BLOCK`` to hold the request until ``release()``. + """ + + BLOCK = object() + + def __init__(self) -> None: + self._condition = threading.Condition() + self._queue: List[Any] = [] + self._default: Any = (200, payload()) + self._released = threading.Event() + self.requests: List[RecordedRequest] = [] + self.in_flight = 0 + self.max_in_flight = 0 + + def respond(self, status: int, body: Any = None) -> None: + with self._condition: + self._default = (status, body) + + def enqueue(self, *replies: Any) -> None: + with self._condition: + self._queue.extend(replies) + + def release(self) -> None: + self._released.set() + + def wait_for_requests(self, count: int, timeout: float = 5.0) -> None: + with self._condition: + if not self._condition.wait_for( + lambda: len(self.requests) >= count, timeout + ): + raise AssertionError( + f"expected {count} requests, saw {len(self.requests)}" + ) + + def _next( + self, method: str, url: str, headers: Dict[str, str], timeout: float + ) -> Any: + with self._condition: + self.requests.append(RecordedRequest(method, url, dict(headers), timeout)) + self.in_flight += 1 + self.max_in_flight = max(self.max_in_flight, self.in_flight) + reply = self._queue.pop(0) if self._queue else self._default + self._condition.notify_all() + return reply + + def _finish(self) -> None: + with self._condition: + self.in_flight -= 1 + + def _response(self, reply: Any, method: str, url: str) -> HTTPResponse: + if isinstance(reply, Exception): + raise reply + status, body, *extra = reply + headers = { + "x-request-id": f"req_{len(self.requests)}", + **(extra[0] if extra else {}), + } + content = ( + body + if isinstance(body, bytes) + else b"" + if body is None + else json.dumps(body).encode() + ) + return HTTPResponse( + status_code=status, + headers=httpx2.Headers(headers), + content=content, + request_method=method, + request_url=url, + ) + + +class SyncBackend: + def __init__(self, server: FlagServer) -> None: + self.server = server + + def request( + self, + method: str, + url: str, + *, + headers: Dict[str, str], + content: Optional[bytes], + timeout: float, + ) -> HTTPResponse: + reply = self.server._next(method, url, headers, timeout) + try: + if reply is FlagServer.BLOCK: + self.server._released.wait(10) + reply = (200, payload()) + return self.server._response(reply, method, url) + finally: + self.server._finish() + + def close(self) -> None: + pass + + +class AsyncBackend: + def __init__(self, server: FlagServer) -> None: + self.server = server + + async def request( + self, + method: str, + url: str, + *, + headers: Dict[str, str], + content: Optional[bytes], + timeout: float, + ) -> HTTPResponse: + import asyncio + + reply = self.server._next(method, url, headers, timeout) + try: + if reply is FlagServer.BLOCK: + while not self.server._released.is_set(): + await asyncio.sleep(0.005) + reply = (200, payload()) + return self.server._response(reply, method, url) + finally: + self.server._finish() + + async def close(self) -> None: + pass + + +class Collector: + """Thread-safe list of listener calls with a blocking wait.""" + + def __init__(self) -> None: + self._condition = threading.Condition() + self.items: List[Any] = [] + + def __call__(self, item: Any) -> None: + with self._condition: + self.items.append(item) + self._condition.notify_all() + + def wait_for(self, count: int, timeout: float = 5.0) -> List[Any]: + with self._condition: + if not self._condition.wait_for(lambda: len(self.items) >= count, timeout): + raise AssertionError( + f"expected {count} calls, saw {len(self.items)}: {self.items}" + ) + return list(self.items) + + +def eventually(predicate: Callable[[], bool], timeout: float = 5.0) -> None: + deadline = time.monotonic() + timeout + while not predicate(): + if time.monotonic() > deadline: + raise AssertionError("condition was not met in time") + time.sleep(0.002) + + +class LocalHTTPServer: + """A real HTTP/1.1 server on 127.0.0.1 for transport-level tests. + + ``mode`` selects the next responses: ``"ok"`` serves ``payload``, + ``"stall"`` waits ``stall`` seconds before answering, and ``"drip"`` sends + the body one byte every ``drip_interval`` seconds. Every request is + recorded with its headers and the client's TCP port. + """ + + def __init__(self, payload: Dict[str, Any]) -> None: + import http.server + + self.payload = payload + self.mode = "ok" + self.stall = 0.0 + self.drip_interval = 0.1 + self.requests: List[Dict[str, Any]] = [] + self._lock = threading.Lock() + owner = self + + class Handler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, format: str, *args: Any) -> None: + pass + + def do_GET(self) -> None: + owner._handle(self) + + self._server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self._server.daemon_threads = True + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + + @property + def base_url(self) -> str: + return f"http://127.0.0.1:{self._server.server_address[1]}" + + def ports(self, authorization: str) -> set: + with self._lock: + return { + request["port"] + for request in self.requests + if request["headers"].get("Authorization") == authorization + } + + def close(self) -> None: + self._server.shutdown() + self._server.server_close() + + def _handle(self, handler: Any) -> None: + with self._lock: + self.requests.append( + { + "path": handler.path, + "headers": dict(handler.headers.items()), + "port": handler.client_address[1], + } + ) + mode, stall, interval = self.mode, self.stall, self.drip_interval + if mode == "stall": + time.sleep(stall) + body = json.dumps(self.payload).encode() + handler.send_response(200) + handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(body))) + handler.end_headers() + try: + if mode == "drip": + for byte in body: + handler.wfile.write(bytes([byte])) + handler.wfile.flush() + time.sleep(interval) + else: + handler.wfile.write(body) + except OSError: + pass diff --git a/tests/feature_flags_runtime/test_async_client.py b/tests/feature_flags_runtime/test_async_client.py new file mode 100644 index 00000000..a31093eb --- /dev/null +++ b/tests/feature_flags_runtime/test_async_client.py @@ -0,0 +1,343 @@ +# @oagen-ignore-file +"""AsyncFeatureFlagsRuntimeClient: polling, readiness, notifications and shutdown.""" + +from __future__ import annotations + +import asyncio +import traceback +from typing import Any, Callable, List + +import pytest + +from workos import AsyncWorkOSClient +from workos._errors import AuthenticationError, NotFoundError, WorkOSTimeoutError +from workos.feature_flags.runtime import ( + AsyncFeatureFlagsRuntimeClient, + FeatureFlagsConfiguration, + FeatureFlagsConfigurationError, + FlagsChangedEvent, +) + +from tests.feature_flags_runtime.helpers import ( + API_KEY, + BASE_URL, + FlagServer, + flag, + payload, + rule, +) + +pytestmark = pytest.mark.asyncio + +Make = Callable[..., AsyncFeatureFlagsRuntimeClient] + +ORDERED = payload( + ordered=flag( + "ordered", + default_value=True, + rules=[ + rule("flag_rule_1", False, "organization", "org_1"), + rule("flag_rule_2", True, "user", "user_1"), + ], + ) +) +CHANGED = payload( + ordered=flag("ordered", default_value=False), + added=flag("added", default_value=True), +) + + +async def until(predicate: Callable[[], bool], timeout: float = 5.0) -> None: + async def poll() -> None: + while not predicate(): + await asyncio.sleep(0.002) + + await asyncio.wait_for(poll(), timeout) + + +async def test_polls_the_v2_endpoint_and_becomes_ready( + server: FlagServer, make_async_runtime: Make +) -> None: + server.respond(200, ORDERED) + runtime = make_async_runtime(request_timeout=7) + assert server.requests == [] + await runtime.start() + await runtime.wait_until_ready(timeout=5) + + request = server.requests[0] + assert request.url == f"{BASE_URL}/sdk/feature-flags?payload_version=2" + assert request.headers["Authorization"] == f"Bearer {API_KEY}" + assert 0 < request.timeout <= 7 + assert ( + runtime.is_enabled("ordered", {"user": "user_1", "organization": "org_1"}) + is False + ) + assert runtime.is_enabled("ordered", {"user": "user_1"}) is True + assert runtime.get_all_flags() == {"ordered": True} + assert runtime.get_flag("ordered") is not None + assert runtime.status.refresh_count == 1 + + +async def test_bootstrap_is_ready_without_a_request( + server: FlagServer, make_async_runtime: Make +) -> None: + runtime = make_async_runtime(bootstrap=FeatureFlagsConfiguration.from_dict(ORDERED)) + await runtime.wait_until_ready(timeout=0) + assert runtime.is_enabled("ordered") is True + assert server.requests == [] + + +async def test_wait_times_out_without_a_configuration(make_async_runtime: Make) -> None: + with pytest.raises(TimeoutError): + await make_async_runtime().wait_until_ready(timeout=0.01) + + +async def test_a_wait_timeout_names_the_last_refresh_error( + server: FlagServer, make_async_runtime: Make +) -> None: + server.respond(404, {"message": "Not Found"}) + runtime = make_async_runtime() + with pytest.raises(NotFoundError): + await runtime.refresh() + with pytest.raises( + TimeoutError, match="the last refresh failed: NotFoundError" + ) as raised: + await runtime.wait_until_ready(timeout=0.01) + assert raised.value.__cause__ is runtime.status.last_error + + +async def test_each_wait_after_a_401_raises_a_fresh_error( + server: FlagServer, make_async_runtime: Make +) -> None: + server.respond(401, {"message": "Unauthorized"}) + runtime = make_async_runtime() + with pytest.raises(AuthenticationError): + await runtime.refresh() + errors: List[BaseException] = [] + for _ in range(4): + try: + await runtime.wait_until_ready(timeout=1) + except AuthenticationError as error: + errors.append(error) + assert len({id(error) for error in errors}) == 4 + stored = errors[0].__cause__ + assert stored is not None and all(error.__cause__ is stored for error in errors) + depths = {len(traceback.extract_tb(error.__traceback__)) for error in errors} + assert len(depths) == 1 + + +async def test_awaits_coroutine_and_plain_listeners( + server: FlagServer, make_async_runtime: Make, fast_polling: List[int] +) -> None: + server.respond(200, ORDERED) + seen: List[Any] = [] + + async def on_change(event: FlagsChangedEvent) -> None: + await asyncio.sleep(0) + seen.append(("change", event.changed_flags)) + + runtime = make_async_runtime(on_change=on_change) + runtime.add_status_listener( + lambda status: seen.append(("status", status.refresh_count)) + ) + await runtime.start() + await until(lambda: len(seen) >= 2) + assert seen[:2] == [("status", 1), ("change", frozenset({"ordered"}))] + + server.respond(200, CHANGED) + await until( + lambda: any( + item == ("change", frozenset({"ordered", "added"})) for item in seen + ) + ) + + +async def test_listeners_can_await_refresh_and_events_stay_in_order( + server: FlagServer, make_async_runtime: Make +) -> None: + server.respond(200, ORDERED) + log: List[str] = [] + holder: List[AsyncFeatureFlagsRuntimeClient] = [] + + async def on_change(event: FlagsChangedEvent) -> None: + label = "first-load" if event.previous is None else "refresh" + log.append(f"start {label}") + if event.previous is None: + server.respond(200, CHANGED) + await holder[0].refresh() + log.append(f"end {label}") + + runtime = make_async_runtime(on_change=on_change) + holder.append(runtime) + await asyncio.wait_for(runtime.refresh(), 2) + + assert log == ["start first-load", "end first-load", "start refresh", "end refresh"] + assert runtime.is_enabled("added") is True + + +@pytest.mark.parametrize( + "status, body, error_type", + [ + (404, {"message": "Not Found"}, NotFoundError), + (200, {"version": 3, "flags": {}}, FeatureFlagsConfigurationError), + ], +) +async def test_failures_keep_the_last_good_flags_and_recover( + server: FlagServer, + make_async_runtime: Make, + fast_polling: List[int], + status: int, + body: Any, + error_type: type, +) -> None: + server.respond(200, ORDERED) + errors: List[Exception] = [] + runtime = make_async_runtime(on_error=errors.append) + await runtime.start() + await runtime.wait_until_ready(timeout=5) + good = runtime.configuration + + server.respond(status, body) + await until(lambda: len(errors) >= 1) + assert isinstance(errors[0], error_type) + assert runtime.configuration is good + + server.respond(200, CHANGED) + await until(lambda: runtime.is_enabled("added")) + assert runtime.status.last_error is None + + +async def test_a_401_stops_polling( + server: FlagServer, make_async_runtime: Make, fast_polling: List[int] +) -> None: + server.respond(401, {"message": "Unauthorized"}) + runtime = make_async_runtime() + await runtime.start() + with pytest.raises(AuthenticationError): + await runtime.wait_until_ready(timeout=5) + await asyncio.sleep(0.05) + assert len(server.requests) == 1 + assert runtime.status.polling_stopped + + +async def test_a_401_from_refresh_stops_polling( + server: FlagServer, make_async_runtime: Make, fast_polling: List[int] +) -> None: + server.respond(200, ORDERED) + runtime = make_async_runtime() + await runtime.start() + await until(lambda: len(server.requests) >= 2) + + server.respond(401, {"message": "Unauthorized"}) + with pytest.raises(AuthenticationError): + await runtime.refresh() + assert runtime.status.polling_stopped + await until(lambda: runtime._task is not None and runtime._task.done()) + sent = len(server.requests) + await asyncio.sleep(0.05) + assert len(server.requests) == sent + assert runtime.is_enabled("ordered", {"user": "user_1"}) is True + + +async def test_a_429_retry_after_delays_the_next_poll( + server: FlagServer, make_async_runtime: Make, fast_polling: List[int] +) -> None: + server.enqueue((429, {"message": "slow down"}, {"Retry-After": "30"})) + server.respond(200, ORDERED) + runtime = make_async_runtime(request_timeout=1) + await runtime.start() + await until(lambda: len(server.requests) == 1) + await asyncio.sleep(0.3) + assert len(server.requests) == 1 + assert runtime.status.failed_refresh_count == 1 + + +async def test_a_403_keeps_polling( + server: FlagServer, make_async_runtime: Make, fast_polling: List[int] +) -> None: + server.respond(403, {"message": "Forbidden"}) + runtime = make_async_runtime() + await runtime.start() + await until(lambda: len(server.requests) >= 3) + assert runtime.status.polling_stopped is False + + +async def test_a_refresh_is_bounded_by_its_request_timeout( + server: FlagServer, make_async_runtime: Make +) -> None: + server.enqueue(FlagServer.BLOCK) + runtime = make_async_runtime(request_timeout=0.05) + with pytest.raises(WorkOSTimeoutError, match="request timeout"): + await runtime.refresh() + server.release() + assert isinstance(runtime.status.last_error, WorkOSTimeoutError) + assert runtime.status.failed_refresh_count == 1 + + +async def test_manual_refreshes_are_serialized( + server: FlagServer, make_async_runtime: Make +) -> None: + server.respond(200, ORDERED) + runtime = make_async_runtime() + await asyncio.gather(*(runtime.refresh() for _ in range(5))) + assert server.max_in_flight == 1 + assert runtime.status.refresh_count == 5 + + +async def test_close_stops_polling_and_keeps_the_last_flags( + server: FlagServer, make_async_runtime: Make, fast_polling: List[int] +) -> None: + server.respond(200, ORDERED) + runtime = make_async_runtime() + await runtime.start() + await until(lambda: len(server.requests) >= 2) + await runtime.close() + sent = len(server.requests) + await asyncio.sleep(0.05) + + assert len(server.requests) == sent + assert runtime.is_enabled("ordered", {"user": "user_1"}) is True + with pytest.raises(RuntimeError): + await runtime.start() + with pytest.raises(RuntimeError): + await runtime.refresh() + await runtime.close() + + +async def test_close_cancels_a_refresh_in_flight( + server: FlagServer, make_async_runtime: Make +) -> None: + server.enqueue(FlagServer.BLOCK) + runtime = make_async_runtime() + await runtime.start() + await until(lambda: len(server.requests) == 1) + await asyncio.wait_for(runtime.close(), 1) + server.release() + assert runtime.is_ready is False + + +async def test_close_can_be_called_from_a_listener( + server: FlagServer, make_async_runtime: Make +) -> None: + server.respond(200, ORDERED) + holder: List[AsyncFeatureFlagsRuntimeClient] = [] + closed = asyncio.Event() + + async def close_on_change(event: FlagsChangedEvent) -> None: + await holder[0].close() + closed.set() + + runtime = make_async_runtime(on_change=close_on_change) + holder.append(runtime) + await runtime.start() + await asyncio.wait_for(closed.wait(), 5) + await until(lambda: runtime._task is not None and runtime._task.done()) + + +async def test_context_manager_starts_and_closes( + server: FlagServer, async_client: AsyncWorkOSClient +) -> None: + server.respond(200, ORDERED) + async with async_client.create_feature_flags_runtime_client() as runtime: + await runtime.wait_until_ready(timeout=5) + assert runtime.is_closed diff --git a/tests/feature_flags_runtime/test_configuration.py b/tests/feature_flags_runtime/test_configuration.py new file mode 100644 index 00000000..271c022c --- /dev/null +++ b/tests/feature_flags_runtime/test_configuration.py @@ -0,0 +1,170 @@ +# @oagen-ignore-file +"""FeatureFlagsConfiguration: persistence and flag inspection.""" + +from __future__ import annotations + +import copy +import json +import pickle +from datetime import datetime, timezone + +import pytest + +from workos._errors import AuthenticationError +from workos.feature_flags.runtime import ( + ConditionDefinition, + FeatureFlagsConfiguration, + FeatureFlagsConfigurationError, + FlagDefinition, + FlagsChangedEvent, + RuleDefinition, + RuntimeStatus, +) + +from tests.feature_flags_runtime.helpers import flag, payload, rule + +PAYLOAD = { + **payload( + checkout=flag( + "checkout", + default_value=True, + rules=[ + rule("flag_rule_1", False, "organization", "org_2", "org_1"), + { + "id": "flag_rule_2", + "kind": "rollout", + "value": "variant-b", + "percentage": 10, + "conditions": [{"attribute": "plan", "values": ["pro", 3]}], + }, + { + "id": "flag_rule_3", + "kind": "conditions", + "value": True, + "conditions": [ + { + "operator": "segment", + "target_type": "user", + "values": ["seg"], + } + ], + }, + ], + ) + ), + "future_top_level": {"anything": [1, 2]}, +} + + +def test_round_trips_through_json_and_dict_with_unknown_fields() -> None: + configuration = FeatureFlagsConfiguration.from_dict(PAYLOAD) + restored = FeatureFlagsConfiguration.from_json(configuration.to_json()) + + assert configuration.to_dict() == PAYLOAD + assert restored.to_dict() == PAYLOAD + assert json.loads(restored.to_json()) == PAYLOAD + + +def test_owns_a_copy_of_the_payload() -> None: + source = json.loads(json.dumps(PAYLOAD)) + configuration = FeatureFlagsConfiguration.from_dict(source) + source["flags"]["checkout"]["enabled"] = False + exported = configuration.to_dict() + exported["flags"].clear() + + assert configuration.get_flag("checkout").enabled is True # type: ignore[union-attr] + assert len(configuration) == 1 + + +def test_inspects_flags_as_received_including_rules_it_cannot_evaluate() -> None: + configuration = FeatureFlagsConfiguration.from_dict(PAYLOAD) + + assert configuration.get_flag("checkout") == FlagDefinition( + slug="checkout", + enabled=True, + default_value=True, + off_value=False, + rules=( + RuleDefinition( + id="flag_rule_1", + kind="conditions", + value=False, + conditions=( + ConditionDefinition("organization", "one_of", ("org_2", "org_1")), + ), + ), + RuleDefinition( + id="flag_rule_2", + kind="rollout", + value=None, + conditions=(ConditionDefinition(None, None, ("pro",)),), + ), + RuleDefinition( + id="flag_rule_3", + kind="conditions", + value=True, + conditions=(ConditionDefinition("user", "segment", ("seg",)),), + ), + ), + ) + assert configuration.get_flag("missing") is None + assert configuration.slugs == ("checkout",) + assert "checkout" in configuration and 1 not in configuration + assert list(configuration) == ["checkout"] + + +def test_invalid_json_is_a_configuration_error() -> None: + with pytest.raises(FeatureFlagsConfigurationError, match="not valid JSON"): + FeatureFlagsConfiguration.from_json("{not json") + with pytest.raises(FeatureFlagsConfigurationError, match="(?i)expected an object"): + FeatureFlagsConfiguration.from_json("[]") + + +def test_configuration_errors_are_value_errors() -> None: + with pytest.raises(ValueError): + FeatureFlagsConfiguration.from_dict({"version": 1, "flags": {}}) + + +def test_values_that_are_not_json_are_rejected() -> None: + with pytest.raises(FeatureFlagsConfigurationError, match="JSON-serializable"): + FeatureFlagsConfiguration.from_dict( + {"version": 2, "flags": {}, "when": object()} + ) + + +def test_snapshots_pickle_and_copy() -> None: + configuration = FeatureFlagsConfiguration.from_dict(PAYLOAD) + + restored = pickle.loads(pickle.dumps(configuration)) + assert restored is not configuration + assert restored.to_json() == configuration.to_json() + assert restored.get_flag("checkout") == configuration.get_flag("checkout") + # Immutable, so copies can share the instance. + assert copy.copy(configuration) is configuration + assert copy.deepcopy(configuration) is configuration + + +def test_status_and_change_events_pickle_and_copy() -> None: + configuration = FeatureFlagsConfiguration.from_dict(PAYLOAD) + error = AuthenticationError( + "Invalid API key", request_id="req_1", response_json={"message": "x"} + ) + status = RuntimeStatus( + configuration=configuration, + last_successful_refresh_at=datetime(2026, 11, 2, tzinfo=timezone.utc), + last_error=error, + refresh_count=3, + failed_refresh_count=1, + ) + event = FlagsChangedEvent(None, configuration, frozenset({"checkout"})) + + for clone in (pickle.loads(pickle.dumps(status)), copy.deepcopy(status)): + assert clone.refresh_count == 3 + assert clone.flag_count == 1 + assert clone.configuration is not None + assert clone.configuration.to_json() == configuration.to_json() + assert isinstance(clone.last_error, AuthenticationError) + assert clone.last_error.request_id == "req_1" + for clone_event in (pickle.loads(pickle.dumps(event)), copy.deepcopy(event)): + assert clone_event.changed_flags == {"checkout"} + assert clone_event.current.slugs == ("checkout",) diff --git a/tests/feature_flags_runtime/test_corpus.py b/tests/feature_flags_runtime/test_corpus.py new file mode 100644 index 00000000..bb592d2d --- /dev/null +++ b/tests/feature_flags_runtime/test_corpus.py @@ -0,0 +1,144 @@ +# @oagen-ignore-file +"""Runs the JSON fixture corpus (shaped for the shared AUTH-7098 corpus).""" + +from __future__ import annotations + +import logging +from contextlib import contextmanager +from typing import Any, Dict, Iterator, List + +import pytest + +from workos import WorkOSClient +from workos.feature_flags.runtime import ( + FeatureFlagsConfiguration, + FeatureFlagsConfigurationError, + FeatureFlagsRuntimeClient, +) +from workos.feature_flags.runtime._configuration import changed_flags +from workos.feature_flags.runtime._context import ContextWarnings, normalize_context + +from tests.feature_flags_runtime.helpers import API_KEY, load_fixture + +EVALUATION = load_fixture("evaluation_cases.json") +PAYLOADS = load_fixture("payload_cases.json") +CHANGES = load_fixture("change_cases.json") +CONTEXTS = load_fixture("context_cases.json") + +_CASE_FIELDS = {"name", "source", "key", "flag", "evaluations", "malformed"} +_EVALUATION_FIELDS = {"context", "expect"} +_OPTIONAL_EVALUATION_FIELDS = {"name"} +_CHANGE_FIELDS = {"name", "source", "before", "after", "changed"} + + +@contextmanager +def _runtime( + configuration: FeatureFlagsConfiguration, +) -> Iterator[FeatureFlagsRuntimeClient]: + workos = WorkOSClient(api_key=API_KEY) + runtime = FeatureFlagsRuntimeClient(workos, bootstrap=configuration) + try: + yield runtime + finally: + runtime.close() + workos.close() + + +def _observed( + runtime: FeatureFlagsRuntimeClient, slug: str, context: Dict[str, Any] +) -> Any: + with_false = runtime.is_enabled(slug, context, default=False) + with_true = runtime.is_enabled(slug, context, default=True) + if with_false == with_true: + return with_false + return "absent" if (with_false, with_true) == (False, True) else "inverted" + + +@pytest.mark.parametrize("case", EVALUATION["cases"], ids=lambda case: case["name"]) +def test_evaluation_corpus(case: Dict[str, Any]) -> None: + # An unrecognized field is a hard failure, never a silent skip. + assert set(case) <= _CASE_FIELDS, f"unknown case fields: {set(case) - _CASE_FIELDS}" + flag = case["flag"] + key = case.get("key", flag["slug"]) + configuration = FeatureFlagsConfiguration.from_dict( + {"version": 2, "flags": {key: flag}} + ) + with _runtime(configuration) as runtime: + for evaluation in case["evaluations"]: + fields = set(evaluation) + assert ( + _EVALUATION_FIELDS + <= fields + <= _EVALUATION_FIELDS | _OPTIONAL_EVALUATION_FIELDS + ), f"unexpected evaluation fields: {fields}" + observed = _observed(runtime, key, evaluation["context"]) + assert observed == evaluation["expect"], evaluation + + +def test_get_all_flags_agrees_with_is_enabled_and_omits_flags_without_an_answer() -> ( + None +): + flags = { + case.get("key", case["flag"]["slug"]) + str(index): { + **case["flag"], + "slug": case.get("key", case["flag"]["slug"]) + str(index), + } + for index, case in enumerate(EVALUATION["cases"]) + } + contexts: List[Dict[str, Any]] = [ + {}, + {"user": "user_1", "organization": "org_1"}, + {"organization": "org_acme"}, + ] + configuration = FeatureFlagsConfiguration.from_dict({"version": 2, "flags": flags}) + with _runtime(configuration) as runtime: + for context in contexts: + all_flags = runtime.get_all_flags(context) + for slug in flags: + answer = _observed(runtime, slug, context) + if answer == "absent": + assert slug not in all_flags + else: + assert all_flags[slug] is answer + + +@pytest.mark.parametrize("case", PAYLOADS["cases"], ids=lambda case: case["name"]) +def test_payload_corpus(case: Dict[str, Any]) -> None: + if case["valid"]: + FeatureFlagsConfiguration(case["payload"]) + else: + with pytest.raises(FeatureFlagsConfigurationError): + FeatureFlagsConfiguration(case["payload"]) + + +@pytest.mark.parametrize("case", CHANGES["cases"], ids=lambda case: case["name"]) +def test_change_corpus(case: Dict[str, Any]) -> None: + assert set(case) <= _CHANGE_FIELDS, ( + f"unknown case fields: {set(case) - _CHANGE_FIELDS}" + ) + before = FeatureFlagsConfiguration(case.get("before", CHANGES["baseline"])) + after = FeatureFlagsConfiguration(case["after"]) + assert sorted(changed_flags(before, after)) == case["changed"] + + +@pytest.mark.parametrize("case", CONTEXTS["cases"], ids=lambda case: case["name"]) +def test_context_corpus(case: Dict[str, Any], caplog: pytest.LogCaptureFixture) -> None: + logger = logging.getLogger("tests.feature_flags_runtime.context") + with caplog.at_level(logging.WARNING, logger=logger.name): + targets = normalize_context(case["context"], ContextWarnings(logger)) + assert targets == case["targets"] + assert bool(caplog.records) is case["warns"] + + +def test_context_corpus_flag_matches_only_the_surviving_targets() -> None: + configuration = FeatureFlagsConfiguration.from_dict( + {"version": 2, "flags": {"flag": CONTEXTS["flag"]}} + ) + with _runtime(configuration) as runtime: + for case in CONTEXTS["cases"]: + expected = ( + case["targets"].get("workspace") == "ws_1" + or case["targets"].get("user") == "user_1" + ) + observed = runtime.is_enabled("flag", case["context"]) + assert observed is expected, case["name"] diff --git a/tests/feature_flags_runtime/test_fork.py b/tests/feature_flags_runtime/test_fork.py new file mode 100644 index 00000000..f92e364a --- /dev/null +++ b/tests/feature_flags_runtime/test_fork.py @@ -0,0 +1,387 @@ +# @oagen-ignore-file +"""What runtime clients do in a forked child process. + +Each scenario runs in a subprocess so a hang or a fork cannot wedge pytest. +""" + +from __future__ import annotations + +import logging +import os +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +from workos import WorkOSClient +from workos.feature_flags.runtime import _core + +from tests.feature_flags_runtime.helpers import LocalHTTPServer, flag, payload + +ROOT = Path(__file__).resolve().parents[2] + +PRELUDE = """ +import asyncio +import os +import time +import warnings + +from workos import AsyncWorkOSClient, WorkOSClient +from workos.feature_flags.runtime import _policy +from tests.feature_flags_runtime.helpers import LocalHTTPServer, flag, payload + +_policy.next_delay = lambda interval, failures, max_retry_delay: 0.05 +server = LocalHTTPServer(payload(f=flag("f", default_value=True))) + + +def wait_for(predicate, timeout=5.0): + deadline = time.monotonic() + timeout + while not predicate(): + if time.monotonic() > deadline: + raise SystemExit("timed out") + time.sleep(0.01) + + +def fork(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + return os.fork() + + +def finish(pid): + _, status = os.waitpid(pid, 0) + raise SystemExit(os.waitstatus_to_exitcode(status)) +""" + +# The parent and child poll the parent's server with different API keys, so +# the server can tell whose connection each request came on. +DEFAULT_CLIENT = """ +workos = WorkOSClient(api_key="sk_parent", base_url=server.base_url) +runtime = workos.create_feature_flags_runtime_client() +runtime.start() +runtime.wait_until_ready(timeout=5) +wait_for(lambda: len(server.ports("Bearer sk_parent")) >= 1 and len(server.requests) >= 3) +pid = fork() +if pid == 0: + workos._api_key = "sk_child" + # Reading any property is a use of the client that resumes polling. + assert runtime.is_ready + time.sleep(1.0) + runtime.close() + os._exit(0) +_, status = os.waitpid(pid, 0) +assert os.waitstatus_to_exitcode(status) == 0 +time.sleep(0.2) +runtime.close() +child_requests = [r for r in server.requests if r["headers"].get("Authorization") == "Bearer sk_child"] +parent_ports = server.ports("Bearer sk_parent") +child_ports = server.ports("Bearer sk_child") +assert len(child_requests) >= 2, f"the child sent {len(child_requests)} polls" +assert not parent_ports & child_ports, f"shared connections: {parent_ports & child_ports}" +print(f"parent ports {sorted(parent_ports)}, child ports {sorted(child_ports)}") +""" + +CALLER_CLIENT = """ +import httpx2 +import logging + +logging.basicConfig(level=logging.WARNING) +workos = WorkOSClient(api_key="sk_parent", base_url=server.base_url, http_client=httpx2.Client()) +runtime = workos.create_feature_flags_runtime_client() +runtime.start() +runtime.wait_until_ready(timeout=5) +pid = fork() +if pid == 0: + workos._api_key = "sk_child" + assert runtime.is_enabled("f") is True + try: + runtime.refresh() + os._exit(2) + except RuntimeError as error: + assert "caller-supplied HTTP client" in str(error), error + time.sleep(0.5) + os._exit(0) +_, status = os.waitpid(pid, 0) +assert os.waitstatus_to_exitcode(status) == 0 +runtime.close() +assert server.ports("Bearer sk_child") == set(), "the child polled" +""" + +# asyncio refuses to run a loop inherited across fork, so the parent runs its +# loop on a thread (which the child does not inherit) and forks from the main +# thread, as a pre-fork server would. +ASYNC_CLIENT = """ +import signal +import threading + +loop = asyncio.new_event_loop() +threading.Thread(target=loop.run_forever, daemon=True).start() + + +def call(coroutine): + return asyncio.run_coroutine_threadsafe(coroutine, loop).result(10) + + +workos = AsyncWorkOSClient(api_key="sk_parent", base_url=server.base_url) +runtime = call(_make_runtime(workos)) +call(runtime.start()) +call(runtime.wait_until_ready(timeout=5)) +pid = fork() +if pid == 0: + workos._api_key = "sk_child" + assert runtime.is_enabled("f") is True + for method in ("refresh", "start"): + try: + asyncio.run(getattr(runtime, method)()) + os._exit(2) + except RuntimeError as error: + assert "was used in the parent" in str(error), error + # The parent's polling task is on a loop that does not run here, so + # close() must not wait for it. + signal.signal(signal.SIGALRM, lambda *args: os._exit(3)) + signal.alarm(5) + asyncio.run(runtime.close()) + signal.alarm(0) + time.sleep(0.5) + os._exit(0) +_, status = os.waitpid(pid, 0) +assert os.waitstatus_to_exitcode(status) == 0 +call(runtime.close()) +call(workos.close()) +assert server.ports("Bearer sk_child") == set(), "the child polled" +""" + +MAKE_ASYNC_RUNTIME = """ +async def _make_runtime(workos): + return workos.create_feature_flags_runtime_client() +""" + +MACOS_DEFAULT_CLIENT = """ +import logging + +logging.basicConfig(level=logging.WARNING) +workos = WorkOSClient(api_key="sk_parent", base_url=server.base_url) +runtime = workos.create_feature_flags_runtime_client() +runtime.start() +runtime.wait_until_ready(timeout=5) +pid = fork() +if pid == 0: + workos._api_key = "sk_child" + assert runtime.is_enabled("f") is True + time.sleep(0.5) + os._exit(0) +_, status = os.waitpid(pid, 0) +assert os.waitstatus_to_exitcode(status) == 0 +runtime.close() +assert server.ports("Bearer sk_child") == set(), "the child polled" +""" + +# The parent never polls, as when an app is preloaded and each worker starts +# the runtime client. +CALLER_CLIENT_UNUSED = """ +import httpx2 + +workos = WorkOSClient(api_key="sk_parent", base_url=server.base_url, http_client=httpx2.Client()) +runtime = workos.create_feature_flags_runtime_client() +pid = fork() +if pid == 0: + workos._api_key = "sk_child" + runtime.start() + runtime.wait_until_ready(timeout=5) + runtime.close() + os._exit(0) +_, status = os.waitpid(pid, 0) +assert os.waitstatus_to_exitcode(status) == 0 +assert server.ports("Bearer sk_child"), "the child never polled" +""" + +ASYNC_CLIENT_UNUSED = """ +workos = AsyncWorkOSClient(api_key="sk_parent", base_url=server.base_url) +runtime = workos.create_feature_flags_runtime_client() +pid = fork() +if pid == 0: + workos._api_key = "sk_child" + + async def serve(): + await runtime.start() + await runtime.wait_until_ready(timeout=5) + await runtime.close() + await workos.close() + + asyncio.run(serve()) + os._exit(0) +_, status = os.waitpid(pid, 0) +assert os.waitstatus_to_exitcode(status) == 0 +assert server.ports("Bearer sk_child"), "the child never polled" +""" + +# The runtime client never polls in the parent, but another SDK call does, on +# the HTTP client the runtime client would poll through. +CALLER_CLIENT_SDK_USED = """ +import httpx2 + +workos = WorkOSClient(api_key="sk_parent", base_url=server.base_url, http_client=httpx2.Client()) +try: + workos.organizations.list_organizations() +except Exception: + pass +assert server.ports("Bearer sk_parent"), "the parent sent no request" +runtime = workos.create_feature_flags_runtime_client() +pid = fork() +if pid == 0: + workos._api_key = "sk_child" + try: + runtime.start() + os._exit(2) + except RuntimeError as error: + assert "caller-supplied HTTP client" in str(error), error + os._exit(0) +_, status = os.waitpid(pid, 0) +assert os.waitstatus_to_exitcode(status) == 0 +assert server.ports("Bearer sk_child") == set(), "the child polled" +""" + +ASYNC_CLIENT_SDK_USED = """ +workos = AsyncWorkOSClient(api_key="sk_parent", base_url=server.base_url) + + +async def call_api(): + try: + await workos.organizations.list_organizations() + except Exception: + pass + + +asyncio.run(call_api()) +assert server.ports("Bearer sk_parent"), "the parent sent no request" +runtime = workos.create_feature_flags_runtime_client() +pid = fork() +if pid == 0: + workos._api_key = "sk_child" + try: + asyncio.run(runtime.start()) + os._exit(2) + except RuntimeError as error: + assert "was used in the parent" in str(error), error + os._exit(0) +_, status = os.waitpid(pid, 0) +assert os.waitstatus_to_exitcode(status) == 0 +assert server.ports("Bearer sk_child") == set(), "the child polled" +""" + +# On macOS a forked child never polls, so to check what runs elsewhere, this +# pretends the platform is fork-safe. The scenarios reach the server by IP over +# plain HTTP, and NO_PROXY keeps httpx2 from reading macOS proxy settings, so +# the child opens connections without the system libraries that crash there. +FORCE_FORK_SAFE = """ +from workos.feature_flags.runtime import _core + +_core.FORK_SAFE_PLATFORM = True +""" + + +def run(*scenario: str) -> subprocess.CompletedProcess: + script = "".join(textwrap.dedent(part) for part in (PRELUDE, *scenario)) + return subprocess.run( + [sys.executable, "-c", script], + cwd=ROOT, + env={**os.environ, "NO_PROXY": "*"}, + capture_output=True, + text=True, + timeout=60, + ) + + +pytestmark = pytest.mark.skipif(not hasattr(os, "fork"), reason="requires os.fork") + + +def test_a_child_polls_on_its_own_connections_once_used() -> None: + result = run(FORCE_FORK_SAFE, DEFAULT_CLIENT) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS behavior") +def test_on_macos_a_child_does_not_poll() -> None: + result = run(MACOS_DEFAULT_CLIENT) + assert result.returncode == 0, result.stdout + result.stderr + assert "cannot safely open network connections" in result.stderr + + +def test_a_child_does_not_poll_through_a_caller_supplied_client() -> None: + result = run(FORCE_FORK_SAFE, CALLER_CLIENT) + assert result.returncode == 0, result.stdout + result.stderr + assert "does not run in forked process" in result.stderr + + +def test_an_async_client_stops_polling_in_a_child() -> None: + result = run(FORCE_FORK_SAFE, MAKE_ASYNC_RUNTIME, ASYNC_CLIENT) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize( + "scenario", + [CALLER_CLIENT_UNUSED, ASYNC_CLIENT_UNUSED], + ids=["sync-caller-supplied-client", "async"], +) +def test_a_client_that_never_polled_starts_in_a_child(scenario: str) -> None: + result = run(FORCE_FORK_SAFE, scenario) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize( + "scenario", + [CALLER_CLIENT_SDK_USED, ASYNC_CLIENT_SDK_USED], + ids=["sync-caller-supplied-client", "async"], +) +def test_a_child_does_not_poll_once_another_call_used_the_http_client( + scenario: str, +) -> None: + result = run(FORCE_FORK_SAFE, scenario) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_on_macos_a_client_that_never_polled_raises_from_start_without_warning( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.setattr(_core, "FORK_SAFE_PLATFORM", False) + workos = WorkOSClient(api_key="sk_test") + runtime = workos.create_feature_flags_runtime_client() + + runtime._reinitialize_after_fork() + with caplog.at_level(logging.WARNING): + assert runtime.is_enabled("f", default=True) is True + assert caplog.records == [] + with pytest.raises(RuntimeError, match="Create the WorkOSClient and the runtime"): + runtime.start() + runtime.close() + workos.close() + + +@pytest.mark.parametrize( + "read", ["is_ready", "configuration", "is_closed", "status", "get_all_flags"] +) +def test_every_read_resumes_polling_after_a_fork( + read: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(_core, "FORK_SAFE_PLATFORM", True) + server = LocalHTTPServer(payload(f=flag("f", default_value=True))) + workos = WorkOSClient(api_key="sk_test", base_url=server.base_url) + runtime = workos.create_feature_flags_runtime_client() + runtime.start() + runtime.wait_until_ready(timeout=5) + first_poller = runtime._thread + + # What the at-fork hook runs in a child, without forking pytest. + runtime._reinitialize_after_fork() + assert runtime._fork_restart_pending + value = getattr(runtime, read) + if callable(value): + value() + + assert runtime._thread is not None and runtime._thread is not first_poller + assert runtime._thread.is_alive() + runtime.close() + workos.close() + server.close() diff --git a/tests/feature_flags_runtime/test_policy.py b/tests/feature_flags_runtime/test_policy.py new file mode 100644 index 00000000..9c05471e --- /dev/null +++ b/tests/feature_flags_runtime/test_policy.py @@ -0,0 +1,154 @@ +# @oagen-ignore-file +"""Polling delays and retry decisions shared by both runtime clients.""" + +from __future__ import annotations + +from typing import Optional + +import pytest + +from workos._errors import ( + NotFoundError, + RateLimitExceededError, + ServerError, + WorkOSConnectionError, + WorkOSError, + WorkOSTimeoutError, +) +from workos._http import TransportError +from workos.feature_flags.runtime import _policy + + +@pytest.fixture +def random_value(monkeypatch: pytest.MonkeyPatch) -> "RandomValue": + value = RandomValue() + monkeypatch.setattr(_policy.random, "random", lambda: value.value) + return value + + +class RandomValue: + value = 0.5 + + +class TestNextDelay: + def test_a_healthy_poll_waits_one_interval(self, random_value: RandomValue) -> None: + assert _policy.next_delay(30, 0, 60) == 30 + + @pytest.mark.parametrize("value, expected", [(0.0, 27.0), (1.0, 33.0)]) + def test_jitter_is_ten_percent_either_way( + self, random_value: RandomValue, value: float, expected: float + ) -> None: + random_value.value = value + assert _policy.next_delay(30, 0, 60) == pytest.approx(expected) + + def test_failures_never_poll_sooner_than_the_interval( + self, random_value: RandomValue + ) -> None: + assert [_policy.next_delay(30, failures, 60) for failures in range(1, 9)] == [ + 30, + 30, + 30, + 30, + 30, + 32, + 60, + 60, + ] + + def test_backoff_is_capped_by_max_retry_delay( + self, random_value: RandomValue + ) -> None: + assert _policy.next_delay(5, 20, 12) == 12 + assert _policy.next_delay(5, 1000, 60) == 60 + + def test_delays_have_a_one_second_floor(self, random_value: RandomValue) -> None: + random_value.value = 0.0 + assert _policy.next_delay(1, 0, 60) == 1.0 + + +class TestAttemptRetryDelay: + @pytest.mark.parametrize( + "error", + [ + ServerError("busy", status_code=503), + RateLimitExceededError("slow down"), + WorkOSTimeoutError(), + WorkOSConnectionError(), + ], + ) + def test_transient_failures_are_retried( + self, random_value: RandomValue, error: Exception + ) -> None: + assert _policy.attempt_retry_delay(error, 0, 10, 3) == pytest.approx(0.5) + assert _policy.attempt_retry_delay(error, 2, 10, 3) == pytest.approx(1.125) + + def test_network_errors_from_the_transport_are_retried( + self, random_value: RandomValue + ) -> None: + error = WorkOSError("Network error") + error.__cause__ = TransportError("reset") + assert _policy.attempt_retry_delay(error, 0, 10, 3) is not None + + @pytest.mark.parametrize( + "error", + [ + NotFoundError("missing"), + ServerError("not implemented", status_code=501), + ValueError("bug"), + ], + ) + def test_other_failures_are_not_retried(self, error: Exception) -> None: + assert _policy.attempt_retry_delay(error, 0, 10, 3) is None + + @pytest.mark.parametrize("max_retries, attempt", [(3, 3), (1, 1), (0, 0)]) + def test_retries_follow_the_clients_max_retries( + self, max_retries: int, attempt: int + ) -> None: + error = ServerError("busy", status_code=503) + assert _policy.attempt_retry_delay(error, attempt, 10, max_retries) is None + if attempt: + assert _policy.attempt_retry_delay(error, attempt - 1, 10, max_retries) + + def test_retry_after_is_honored_when_it_fits(self) -> None: + error = RateLimitExceededError("slow down", retry_after=2.0) + assert _policy.attempt_retry_delay(error, 0, 10, 3) == 2.0 + assert _policy.attempt_retry_delay(error, 0, 1.5, 3) is None + + @pytest.mark.parametrize("remaining, expected", [(1.0, 0.5), (0.99, None)]) + def test_a_retry_must_leave_time_for_the_request_itself( + self, random_value: RandomValue, remaining: float, expected: Optional[float] + ) -> None: + assert ( + _policy.attempt_retry_delay( + ServerError("busy", status_code=503), 0, remaining, 3 + ) + == expected + ) + + +class TestDurations: + @pytest.mark.parametrize("value", [True, "10", None]) + def test_non_numbers_are_rejected(self, value: object) -> None: + with pytest.raises(TypeError): + _policy.validate_duration(value, "request_timeout") + + def test_the_polling_interval_has_a_five_second_minimum(self) -> None: + assert _policy.polling_interval(0.5) == 5.0 + assert _policy.polling_interval(45) == 45.0 + + +class TestRetryAfter: + def test_a_429_asks_the_next_poll_to_wait(self) -> None: + assert _policy.retry_after(RateLimitExceededError("slow", retry_after=20)) == 20 + + def test_the_wait_is_capped_at_sixty_seconds(self) -> None: + assert ( + _policy.retry_after(RateLimitExceededError("slow", retry_after=600)) == 60 + ) + + @pytest.mark.parametrize( + "error", + [RateLimitExceededError("slow"), ServerError("busy", status_code=503)], + ) + def test_other_failures_ask_for_nothing(self, error: Exception) -> None: + assert _policy.retry_after(error) is None diff --git a/tests/feature_flags_runtime/test_sync_client.py b/tests/feature_flags_runtime/test_sync_client.py new file mode 100644 index 00000000..1a04d071 --- /dev/null +++ b/tests/feature_flags_runtime/test_sync_client.py @@ -0,0 +1,821 @@ +# @oagen-ignore-file +"""FeatureFlagsRuntimeClient: polling, readiness, notifications and shutdown.""" + +from __future__ import annotations + +import json +import logging +import threading +import traceback +from typing import Any, Callable, List + +import pytest + +import httpx2 + +from workos import HTTPResponse, TransportTimeout, WorkOSClient, create_public_client +from workos._errors import ( + AuthenticationError, + AuthorizationError, + ConfigurationError, + NotFoundError, + ServerError, +) +from workos.feature_flags.runtime import _configuration +from workos.feature_flags.runtime import ( + FeatureFlagsConfiguration, + FeatureFlagsConfigurationError, + FeatureFlagsRuntimeClient, + FlagsChangedEvent, + RuntimeStatus, +) + +from tests.feature_flags_runtime.helpers import ( + API_KEY, + BASE_URL, + Collector, + FlagServer, + SyncBackend, + eventually, + flag, + payload, + rule, +) + +Make = Callable[..., FeatureFlagsRuntimeClient] + +ORDERED = payload( + ordered=flag( + "ordered", + default_value=True, + rules=[ + rule("flag_rule_1", False, "organization", "org_1"), + rule("flag_rule_2", True, "user", "user_1"), + ], + ), + workspace=flag("workspace", rules=[rule("flag_rule_3", True, "workspace", "ws_1")]), +) +CHANGED = payload( + ordered=flag("ordered", default_value=False), + added=flag("added", default_value=True), +) + + +class TestRequests: + def test_polls_the_v2_endpoint_with_the_sdk_transport( + self, server: FlagServer, make_runtime: Make + ) -> None: + server.respond(200, ORDERED) + runtime = make_runtime(request_timeout=7) + runtime.start() + runtime.wait_until_ready(timeout=5) + + request = server.requests[0] + assert request.method == "GET" + assert request.url == f"{BASE_URL}/sdk/feature-flags?payload_version=2" + assert request.headers["Authorization"] == f"Bearer {API_KEY}" + assert "Python SDK/" in request.headers["User-Agent"] + assert 0 < request.timeout <= 7 + + def test_makes_no_request_until_started( + self, server: FlagServer, make_runtime: Make + ) -> None: + runtime = make_runtime() + assert runtime.is_enabled("ordered") is False + assert server.requests == [] + + def test_start_is_idempotent( + self, server: FlagServer, make_runtime: Make, fast_polling: List[int] + ) -> None: + server.respond(200, ORDERED) + runtime = make_runtime() + runtime.start() + runtime.start() + server.wait_for_requests(3) + assert server.max_in_flight == 1 + + +class TestReadiness: + def test_ready_after_the_first_successful_poll( + self, server: FlagServer, make_runtime: Make + ) -> None: + server.respond(200, ORDERED) + runtime = make_runtime() + assert runtime.is_ready is False + runtime.start() + runtime.wait_until_ready(timeout=5) + + assert runtime.is_ready + assert ( + runtime.is_enabled("ordered", {"user": "user_1", "organization": "org_1"}) + is False + ) + assert runtime.is_enabled("ordered", {"user": "user_1"}) is True + assert runtime.get_all_flags({"workspace": "ws_1"}) == { + "ordered": True, + "workspace": True, + } + status = runtime.status + assert status.flag_count == 2 + assert status.refresh_count == 1 + assert status.last_successful_refresh_at is not None + assert status.cache_age() is not None + + def test_bootstrap_is_ready_immediately_without_a_request( + self, server: FlagServer, make_runtime: Make + ) -> None: + bootstrap = FeatureFlagsConfiguration.from_dict(ORDERED) + runtime = make_runtime(bootstrap=bootstrap) + runtime.wait_until_ready(timeout=0) + + assert runtime.is_enabled("ordered") is True + assert runtime.status.last_successful_refresh_at is None + assert runtime.status.cache_age() is None + assert runtime.configuration is bootstrap + assert server.requests == [] + + def test_wait_times_out_without_a_configuration(self, make_runtime: Make) -> None: + runtime = make_runtime() + with pytest.raises(TimeoutError): + runtime.wait_until_ready(timeout=0.01) + + def test_unready_client_serves_caller_defaults(self, make_runtime: Make) -> None: + runtime = make_runtime() + assert runtime.is_enabled("ordered", default=True) is True + assert runtime.get_all_flags() == {} + assert runtime.get_flag("ordered") is None + + def test_a_401_before_readiness_fails_the_wait( + self, server: FlagServer, make_runtime: Make + ) -> None: + server.respond(401, {"message": "Unauthorized"}) + runtime = make_runtime() + runtime.start() + with pytest.raises(AuthenticationError): + runtime.wait_until_ready(timeout=5) + assert runtime.status.polling_stopped + assert len(server.requests) == 1 + + def test_a_wait_timeout_names_the_last_refresh_error( + self, server: FlagServer, make_runtime: Make + ) -> None: + server.respond(404, {"message": "Not Found"}) + runtime = make_runtime() + with pytest.raises(NotFoundError): + runtime.refresh() + with pytest.raises( + TimeoutError, match="the last refresh failed: NotFoundError" + ) as raised: + runtime.wait_until_ready(timeout=0.01) + assert raised.value.__cause__ is runtime.status.last_error + + def test_each_wait_after_a_401_raises_a_fresh_error( + self, server: FlagServer, make_runtime: Make + ) -> None: + server.respond(401, {"message": "Unauthorized"}) + runtime = make_runtime() + with pytest.raises(AuthenticationError): + runtime.refresh() + errors: List[BaseException] = [] + + def wait() -> None: + try: + runtime.wait_until_ready(timeout=1) + except BaseException as error: + errors.append(error) + + for _ in range(3): + wait() + waiters = [threading.Thread(target=wait) for _ in range(3)] + for waiter in waiters: + waiter.start() + for waiter in waiters: + waiter.join(5) + + assert len(errors) == 6 + assert all(isinstance(error, AuthenticationError) for error in errors) + assert len({id(error) for error in errors}) == 6 + stored = errors[0].__cause__ + assert stored is not None and all(error.__cause__ is stored for error in errors) + assert all(error.status_code == 401 for error in errors) # type: ignore[attr-defined] + depths = {len(traceback.extract_tb(error.__traceback__)) for error in errors} + assert len(depths) == 1 + + def test_closing_before_readiness_wakes_waiters(self, make_runtime: Make) -> None: + runtime = make_runtime() + errors: List[BaseException] = [] + + def wait() -> None: + try: + runtime.wait_until_ready(timeout=5) + except BaseException as error: + errors.append(error) + + waiter = threading.Thread(target=wait) + waiter.start() + runtime.close() + waiter.join(5) + assert len(errors) == 1 and isinstance(errors[0], RuntimeError) + + +class TestChangeNotifications: + def test_first_load_reports_every_flag( + self, server: FlagServer, make_runtime: Make + ) -> None: + server.respond(200, ORDERED) + changes = Collector() + runtime = make_runtime(on_change=changes) + runtime.start() + + (event,) = changes.wait_for(1) + assert isinstance(event, FlagsChangedEvent) + assert event.previous is None + assert event.changed_flags == {"ordered", "workspace"} + assert event.current is runtime.configuration + + def test_reports_only_real_changes( + self, server: FlagServer, make_runtime: Make, fast_polling: List[int] + ) -> None: + server.respond(200, ORDERED) + changes = Collector() + runtime = make_runtime() + runtime.add_change_listener(changes) + runtime.start() + changes.wait_for(1) + server.wait_for_requests(4) + assert len(changes.items) == 1 + + server.respond(200, CHANGED) + events = changes.wait_for(2) + assert events[1].changed_flags == {"ordered", "workspace", "added"} + assert events[1].previous is not None + assert events[1].previous.to_json() == events[0].current.to_json() + assert runtime.is_enabled("added") is True + assert runtime.get_flag("workspace") is None + + def test_first_refresh_after_bootstrap_compares_with_the_bootstrap( + self, server: FlagServer, make_runtime: Make + ) -> None: + bootstrap = FeatureFlagsConfiguration.from_dict(ORDERED) + changes = Collector() + statuses = Collector() + server.respond(200, CHANGED) + runtime = make_runtime( + bootstrap=bootstrap, on_change=changes, on_status=statuses + ) + runtime.refresh() + + (event,) = changes.wait_for(1) + assert event.previous is bootstrap + assert event.changed_flags == {"ordered", "workspace", "added"} + + runtime.refresh() + assert len(changes.items) == 1 + assert len(statuses.items) == 2 + + def test_an_unchanged_payload_keeps_the_snapshot_without_parsing_it( + self, server: FlagServer, make_runtime: Make, monkeypatch: pytest.MonkeyPatch + ) -> None: + server.respond(200, ORDERED) + changes = Collector() + runtime = make_runtime(on_change=changes) + runtime.refresh() + snapshot = runtime.configuration + first_refresh = runtime.status.last_successful_refresh_at + parses: List[object] = [] + original = _configuration._parse_payload + monkeypatch.setattr( + _configuration, + "_parse_payload", + lambda payload: parses.append(payload) or original(payload), + ) + # Same content, different key order. + server.respond(200, json.loads(json.dumps(ORDERED, sort_keys=True))) + runtime.refresh() + + assert runtime.configuration is snapshot + assert parses == [] + assert runtime.status.refresh_count == 2 + assert runtime.status.last_successful_refresh_at != first_refresh + assert len(changes.items) == 1 + + server.respond(200, CHANGED) + runtime.refresh() + assert len(parses) == 1 + assert runtime.configuration is not snapshot + assert len(changes.items) == 2 + + def test_an_identical_bootstrap_reports_no_change( + self, server: FlagServer, make_runtime: Make + ) -> None: + server.respond(200, ORDERED) + changes = Collector() + runtime = make_runtime( + bootstrap=FeatureFlagsConfiguration.from_dict(ORDERED), on_change=changes + ) + runtime.refresh() + assert changes.items == [] + + def test_a_change_outside_the_flags_is_reported_for_persisted_caches( + self, server: FlagServer, make_runtime: Make + ) -> None: + server.respond(200, ORDERED) + changes = Collector() + runtime = make_runtime(on_change=changes) + runtime.refresh() + server.respond(200, {**ORDERED, "served_by": "a newer server"}) + runtime.refresh() + + event = changes.wait_for(2)[1] + assert event.changed_flags == frozenset() + assert event.current.to_dict()["served_by"] == "a newer server" + + def test_listeners_can_refresh_and_events_stay_in_refresh_order( + self, server: FlagServer, make_runtime: Make + ) -> None: + server.respond(200, ORDERED) + log: List[str] = [] + holder: List[FeatureFlagsRuntimeClient] = [] + refreshed = threading.Event() + + def on_change(event: FlagsChangedEvent) -> None: + label = "first-load" if event.previous is None else "refresh" + log.append(f"start {label}") + if event.previous is None: + # Refresh from another thread and wait for it, which needs the + # refresh lock to be free while listeners run. + server.respond(200, CHANGED) + refresher = threading.Thread(target=holder[0].refresh) + refresher.start() + refresher.join(5) + if not refresher.is_alive(): + refreshed.set() + log.append(f"end {label}") + + runtime = make_runtime(on_change=on_change) + holder.append(runtime) + runtime.refresh() + + assert refreshed.is_set() + eventually(lambda: len(log) == 4) + assert log == [ + "start first-load", + "end first-load", + "start refresh", + "end refresh", + ] + + def test_listener_failures_are_logged_and_do_not_stop_polling( + self, + server: FlagServer, + make_runtime: Make, + fast_polling: List[int], + caplog: pytest.LogCaptureFixture, + ) -> None: + server.respond(200, ORDERED) + later = Collector() + + def broken(event: FlagsChangedEvent) -> None: + raise RuntimeError("listener bug") + + runtime = make_runtime(on_change=broken) + runtime.add_change_listener(later) + with caplog.at_level(logging.ERROR, logger="workos.feature_flags.runtime"): + runtime.start() + later.wait_for(1) + server.respond(200, CHANGED) + later.wait_for(2) + assert "listener raised" in caplog.text + + def test_removed_listeners_are_not_called( + self, server: FlagServer, make_runtime: Make + ) -> None: + server.respond(200, ORDERED) + changes = Collector() + runtime = make_runtime(on_change=changes) + runtime.remove_change_listener(changes) + runtime.refresh() + assert changes.items == [] + + def test_async_listeners_are_rejected_with_a_warning( + self, server: FlagServer, make_runtime: Make, caplog: pytest.LogCaptureFixture + ) -> None: + server.respond(200, ORDERED) + + async def listener(event: FlagsChangedEvent) -> None: + raise AssertionError("never run") + + runtime = make_runtime() + runtime.add_change_listener(listener) # type: ignore[arg-type] + with caplog.at_level(logging.WARNING, logger="workos.feature_flags.runtime"): + runtime.refresh() + assert "async listener" in caplog.text + + +class TestFailures: + def test_transient_failures_are_retried_inside_one_refresh( + self, server: FlagServer, make_runtime: Make, fast_polling: List[int] + ) -> None: + server.enqueue((503, {"message": "busy"}), (500, None)) + server.respond(200, ORDERED) + runtime = make_runtime() + runtime.refresh() + + assert len(server.requests) == 3 + assert runtime.status.refresh_count == 1 + assert runtime.status.failed_refresh_count == 0 + + def test_retries_stop_when_the_request_timeout_is_spent( + self, server: FlagServer, make_runtime: Make + ) -> None: + server.respond(503, {"message": "busy"}) + runtime = make_runtime(request_timeout=0.05) + with pytest.raises(ServerError): + runtime.refresh() + # The shortest retry wait (0.25 s) does not fit in a 50 ms budget. + assert len(server.requests) == 1 + + @pytest.mark.parametrize("max_retries, requests", [(0, 1), (1, 2)]) + def test_retries_follow_the_clients_max_retries( + self, + server: FlagServer, + fast_polling: List[int], + max_retries: int, + requests: int, + ) -> None: + server.respond(503, {"message": "busy"}) + workos = WorkOSClient( + api_key=API_KEY, + base_url=BASE_URL, + http_client=SyncBackend(server), + max_retries=max_retries, + ) + runtime = workos.create_feature_flags_runtime_client() + with pytest.raises(ServerError): + runtime.refresh() + assert len(server.requests) == requests + runtime.close() + workos.close() + + def test_a_slow_failing_server_surfaces_its_error_not_a_timeout( + self, fast_polling: List[int] + ) -> None: + class SlowFailingBackend: + """Answers 503 after 0.3 s, and times out when given less than that.""" + + def __init__(self) -> None: + self.timeouts: List[float] = [] + + def request(self, method: str, url: str, **kwargs: Any) -> HTTPResponse: + self.timeouts.append(kwargs["timeout"]) + if kwargs["timeout"] < 0.3: + threading.Event().wait(kwargs["timeout"]) + raise TransportTimeout("read timed out") + threading.Event().wait(0.3) + return HTTPResponse(503, httpx2.Headers(), b"", method, url) + + def close(self) -> None: + pass + + backend = SlowFailingBackend() + workos = WorkOSClient(api_key=API_KEY, base_url=BASE_URL, http_client=backend) + runtime = workos.create_feature_flags_runtime_client(request_timeout=1.0) + with pytest.raises(ServerError): + runtime.refresh() + assert all(timeout >= 0.3 for timeout in backend.timeouts) + runtime.close() + workos.close() + + def test_a_429_retry_after_delays_the_next_poll( + self, server: FlagServer, make_runtime: Make, fast_polling: List[int] + ) -> None: + # Retry-After does not fit in the refresh budget, so the refresh fails; + # without honoring it the next poll would follow 10 ms later. + server.enqueue((429, {"message": "slow down"}, {"Retry-After": "30"})) + server.respond(200, ORDERED) + runtime = make_runtime(request_timeout=1) + runtime.start() + server.wait_for_requests(1) + threading.Event().wait(0.3) + assert len(server.requests) == 1 + assert runtime.status.failed_refresh_count == 1 + + def test_errors_that_retrying_cannot_fix_are_not_retried( + self, server: FlagServer, make_runtime: Make, fast_polling: List[int] + ) -> None: + server.respond(404, {"message": "Not Found"}) + runtime = make_runtime() + with pytest.raises(NotFoundError): + runtime.refresh() + assert len(server.requests) == 1 + + @pytest.mark.parametrize( + "status, body, error_type", + [ + (404, {"message": "Not Found"}, NotFoundError), + (403, {"message": "Forbidden"}, AuthorizationError), + (500, {"message": "Boom"}, ServerError), + (200, {"version": 3, "flags": {}}, FeatureFlagsConfigurationError), + (200, b"not json", FeatureFlagsConfigurationError), + ], + ) + def test_background_failures_keep_the_last_good_flags_and_recover( + self, + server: FlagServer, + make_runtime: Make, + fast_polling: List[int], + status: int, + body: Any, + error_type: type, + ) -> None: + server.respond(200, ORDERED) + errors = Collector() + changes = Collector() + runtime = make_runtime(on_error=errors, on_change=changes) + runtime.start() + changes.wait_for(1) + good = runtime.configuration + + server.respond(status, body) + (error, *_) = errors.wait_for(1) + assert isinstance(error, error_type) + assert runtime.configuration is good + assert runtime.is_enabled("ordered", {"user": "user_1"}) is True + assert isinstance(runtime.status.last_error, error_type) + assert runtime.status.failed_refresh_count >= 1 + + server.respond(200, CHANGED) + changes.wait_for(2) + eventually(lambda: runtime.status.last_error is None) + assert runtime.status.polling_stopped is False + + def test_failures_back_off_and_reset_after_success( + self, server: FlagServer, make_runtime: Make, fast_polling: List[int] + ) -> None: + server.enqueue(*[(404, {"message": "Not Found"})] * 3) + server.respond(200, ORDERED) + runtime = make_runtime() + runtime.start() + server.wait_for_requests(5) + assert fast_polling[:4] == [1, 2, 3, 0] + + def test_a_401_stops_polling_and_keeps_the_last_good_flags( + self, server: FlagServer, make_runtime: Make, fast_polling: List[int] + ) -> None: + server.respond(200, ORDERED) + errors = Collector() + statuses = Collector() + runtime = make_runtime(on_error=errors, on_status=statuses) + runtime.start() + runtime.wait_until_ready(timeout=5) + + server.respond(401, {"message": "Unauthorized"}) + (error,) = errors.wait_for(1) + assert isinstance(error, AuthenticationError) + eventually(lambda: runtime.status.polling_stopped) + sent = len(server.requests) + threading.Event().wait(0.1) + assert len(server.requests) == sent + assert runtime.is_enabled("ordered", {"user": "user_1"}) is True + runtime.wait_until_ready(timeout=0) + last: RuntimeStatus = statuses.items[-1] + assert last.polling_stopped and isinstance(last.last_error, AuthenticationError) + + def test_a_401_from_refresh_stops_polling( + self, server: FlagServer, make_runtime: Make, fast_polling: List[int] + ) -> None: + server.respond(200, ORDERED) + errors = Collector() + statuses = Collector() + runtime = make_runtime(on_error=errors, on_status=statuses) + runtime.start() + server.wait_for_requests(2) + + server.respond(401, {"message": "Unauthorized"}) + with pytest.raises(AuthenticationError): + runtime.refresh() + assert runtime.status.polling_stopped + sent = len(server.requests) + threading.Event().wait(0.1) + assert len(server.requests) == sent + assert errors.items == [] + assert statuses.items[-1].polling_stopped + assert runtime.is_enabled("ordered", {"user": "user_1"}) is True + + def test_a_401_from_refresh_before_start_fails_readiness_and_start( + self, server: FlagServer, make_runtime: Make, caplog: pytest.LogCaptureFixture + ) -> None: + server.respond(401, {"message": "Unauthorized"}) + runtime = make_runtime() + with pytest.raises(AuthenticationError): + runtime.refresh() + with pytest.raises(AuthenticationError): + runtime.wait_until_ready(timeout=1) + with caplog.at_level(logging.WARNING, logger="workos.feature_flags.runtime"): + runtime.start() + assert "Not starting feature flag polling" in caplog.text + threading.Event().wait(0.05) + assert len(server.requests) == 1 + + def test_failure_logs_carry_the_http_status_of_an_empty_body( + self, + server: FlagServer, + make_runtime: Make, + fast_polling: List[int], + caplog: pytest.LogCaptureFixture, + ) -> None: + server.respond(500, None) + with caplog.at_level(logging.WARNING, logger="workos.feature_flags.runtime"): + runtime = make_runtime() + runtime.start() + eventually(lambda: "ServerError: HTTP 500" in caplog.text) + assert "message=" not in caplog.text + + def test_a_403_keeps_polling( + self, server: FlagServer, make_runtime: Make, fast_polling: List[int] + ) -> None: + server.respond(403, {"message": "Forbidden"}) + runtime = make_runtime() + runtime.start() + server.wait_for_requests(3) + assert runtime.status.polling_stopped is False + + def test_unobserved_failures_are_logged( + self, + server: FlagServer, + make_runtime: Make, + fast_polling: List[int], + caplog: pytest.LogCaptureFixture, + ) -> None: + server.respond(500, {"message": "Boom"}) + with caplog.at_level(logging.WARNING, logger="workos.feature_flags.runtime"): + runtime = make_runtime() + runtime.start() + eventually( + lambda: "keeping the last good flags and retrying" in caplog.text + ) + + +class TestManualRefresh: + def test_works_without_polling( + self, server: FlagServer, make_runtime: Make + ) -> None: + server.respond(200, ORDERED) + runtime = make_runtime() + runtime.refresh() + assert runtime.is_ready + assert len(server.requests) == 1 + + def test_raises_without_notifying_error_listeners( + self, server: FlagServer, make_runtime: Make + ) -> None: + server.respond(404, {"message": "Not Found"}) + errors = Collector() + runtime = make_runtime(on_error=errors) + with pytest.raises(NotFoundError): + runtime.refresh() + assert errors.items == [] + assert runtime.status.failed_refresh_count == 1 + + def test_is_serialized_with_polling( + self, server: FlagServer, make_runtime: Make, fast_polling: List[int] + ) -> None: + server.respond(200, ORDERED) + runtime = make_runtime() + runtime.start() + refreshers = [threading.Thread(target=runtime.refresh) for _ in range(4)] + for refresher in refreshers: + refresher.start() + for refresher in refreshers: + refresher.join(5) + assert server.max_in_flight == 1 + + +class TestClose: + def test_stops_polling_and_keeps_serving_the_last_flags( + self, server: FlagServer, make_runtime: Make, fast_polling: List[int] + ) -> None: + server.respond(200, ORDERED) + runtime = make_runtime() + runtime.start() + server.wait_for_requests(2) + runtime.close() + sent = len(server.requests) + threading.Event().wait(0.1) + + assert len(server.requests) == sent + assert runtime.is_closed + assert runtime.is_enabled("ordered", {"user": "user_1"}) is True + with pytest.raises(RuntimeError): + runtime.start() + with pytest.raises(RuntimeError): + runtime.refresh() + runtime.close() + + def test_discards_a_refresh_in_flight( + self, server: FlagServer, make_runtime: Make + ) -> None: + server.enqueue(FlagServer.BLOCK) + changes = Collector() + runtime = make_runtime(on_change=changes) + runtime.start() + server.wait_for_requests(1) + closer = threading.Thread(target=runtime.close) + closer.start() + eventually(lambda: runtime.is_closed) + server.release() + closer.join(5) + + assert not closer.is_alive() + assert runtime.is_ready is False + assert changes.items == [] + + def test_can_be_called_from_a_listener( + self, server: FlagServer, make_runtime: Make + ) -> None: + server.respond(200, ORDERED) + closed = threading.Event() + holder: List[FeatureFlagsRuntimeClient] = [] + + def close_on_change(event: FlagsChangedEvent) -> None: + holder[0].close() + closed.set() + + runtime = make_runtime(on_change=close_on_change) + holder.append(runtime) + runtime.start() + assert closed.wait(5) + assert runtime.is_closed + + def test_context_manager_starts_and_closes( + self, server: FlagServer, client: WorkOSClient + ) -> None: + server.respond(200, ORDERED) + with client.create_feature_flags_runtime_client() as runtime: + runtime.wait_until_ready(timeout=5) + assert runtime.is_closed + + +class TestConcurrency: + def test_readers_always_see_one_complete_snapshot( + self, server: FlagServer, make_runtime: Make, fast_polling: List[int] + ) -> None: + # Every snapshot holds flags a and b with equal values, alternating + # between all-true and all-false; a torn read would mix them. + def snapshot(value: bool) -> dict: + return payload( + a=flag("a", default_value=value), b=flag("b", default_value=value) + ) + + server.respond(200, snapshot(True)) + runtime = make_runtime() + runtime.refresh() + runtime.start() + torn: List[dict] = [] + stop = threading.Event() + + def read() -> None: + while not stop.is_set(): + flags = runtime.get_all_flags() + if len(set(flags.values())) != 1: + torn.append(flags) + + readers = [threading.Thread(target=read) for _ in range(4)] + for reader in readers: + reader.start() + for index in range(20): + server.respond(200, snapshot(index % 2 == 0)) + server.wait_for_requests(len(server.requests) + 1) + stop.set() + for reader in readers: + reader.join(5) + assert torn == [] + + +class TestConfiguration: + def test_rejects_a_client_without_an_api_key(self) -> None: + public = create_public_client(client_id="client_123") + with pytest.raises(ConfigurationError): + FeatureFlagsRuntimeClient(public) # type: ignore[arg-type] + public.close() + + def test_rejects_an_unparsed_bootstrap(self, client: WorkOSClient) -> None: + with pytest.raises(TypeError, match="from_json"): + client.create_feature_flags_runtime_client(bootstrap=ORDERED) # type: ignore[arg-type] + + def test_clamps_the_polling_interval(self, make_runtime: Make) -> None: + assert make_runtime(polling_interval=1)._polling_interval == 5.0 + + @pytest.mark.parametrize("value", [0, -1, float("nan"), float("inf")]) + def test_rejects_unusable_durations( + self, client: WorkOSClient, value: float + ) -> None: + with pytest.raises(ValueError): + client.create_feature_flags_runtime_client(request_timeout=value) + + def test_constructs_directly(self, server: FlagServer) -> None: + workos = WorkOSClient(api_key=API_KEY, base_url=BASE_URL) + runtime = FeatureFlagsRuntimeClient(workos, polling_interval=60) + assert runtime._polling_interval == 60.0 + runtime.close() + workos.close() diff --git a/tests/feature_flags_runtime/test_transport.py b/tests/feature_flags_runtime/test_transport.py new file mode 100644 index 00000000..af0555f4 --- /dev/null +++ b/tests/feature_flags_runtime/test_transport.py @@ -0,0 +1,104 @@ +# @oagen-ignore-file +"""The sync client's poll request over a real HTTP connection.""" + +from __future__ import annotations + +import time +from typing import Iterator + +import httpx2 +import pytest + +from workos import WorkOSClient +from workos._errors import WorkOSTimeoutError + +from tests.feature_flags_runtime.helpers import ( + API_KEY, + LocalHTTPServer, + flag, + payload, +) + +PAYLOAD = payload(checkout=flag("checkout", default_value=True)) + + +@pytest.fixture +def local() -> Iterator[LocalHTTPServer]: + server = LocalHTTPServer(PAYLOAD) + yield server + server.close() + + +def test_sends_the_sdk_url_and_headers_and_closes_its_client( + local: LocalHTTPServer, +) -> None: + workos = WorkOSClient(api_key=API_KEY, base_url=local.base_url) + runtime = workos.create_feature_flags_runtime_client() + runtime.refresh() + + request = local.requests[0] + assert request["path"] == "/sdk/feature-flags?payload_version=2" + assert request["headers"]["Authorization"] == f"Bearer {API_KEY}" + assert request["headers"]["User-Agent"].startswith("WorkOS Python/") + assert runtime.is_enabled("checkout") is True + + runtime.close() + assert runtime._transport._owned is None + workos.close() + + +def test_a_poll_after_close_opens_no_client(local: LocalHTTPServer) -> None: + workos = WorkOSClient(api_key=API_KEY, base_url=local.base_url) + runtime = workos.create_feature_flags_runtime_client() + runtime.close() + + # As a poll does when close() stopped waiting for it. + with pytest.raises(RuntimeError, match="closed"): + runtime._transport.get(time.monotonic() + 5) + assert runtime._transport._owned is None + assert local.requests == [] + workos.close() + + +def test_uses_a_caller_supplied_httpx_client(local: LocalHTTPServer) -> None: + http_client = httpx2.Client(headers={"X-Caller-Client": "yes"}) + workos = WorkOSClient( + api_key=API_KEY, base_url=local.base_url, http_client=http_client + ) + runtime = workos.create_feature_flags_runtime_client() + runtime.refresh() + + assert local.requests[0]["headers"]["X-Caller-Client"] == "yes" + runtime.close() + assert not http_client.is_closed + http_client.close() + + +@pytest.mark.parametrize( + "mode, setting", + [ + # Headers arrive late: each phase only gets the time left. + ("stall", 3.0), + # Every read succeeds well within its own timeout, but the body as a + # whole would take several seconds. + ("drip", 0.1), + ], +) +def test_a_refresh_is_bounded_by_its_request_timeout( + local: LocalHTTPServer, mode: str, setting: float +) -> None: + local.mode = mode + local.stall = setting + local.drip_interval = setting + workos = WorkOSClient(api_key=API_KEY, base_url=local.base_url, max_retries=0) + runtime = workos.create_feature_flags_runtime_client(request_timeout=0.5) + + started = time.monotonic() + with pytest.raises(WorkOSTimeoutError): + runtime.refresh() + elapsed = time.monotonic() - started + + # The documented bound is request_timeout plus at most one read. + assert elapsed < 1.2 + runtime.close() + workos.close() diff --git a/tests/smoke_test.py b/tests/smoke_test.py index e630a96f..3ce23670 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -198,6 +198,30 @@ def test_pagination_importable() -> None: print("✓ Pagination and RequestOptions importable") +def test_feature_flags_runtime_client_importable() -> None: + """Verify both runtime clients construct without making a request.""" + from workos import ( + AsyncFeatureFlagsRuntimeClient, + AsyncWorkOSClient, + FeatureFlagsConfiguration, + FeatureFlagsRuntimeClient, + WorkOSClient, + ) + + configuration = FeatureFlagsConfiguration.from_dict({"version": 2, "flags": {}}) + sync_runtime = WorkOSClient( + api_key="sk_test_smoke" + ).create_feature_flags_runtime_client(bootstrap=configuration) + async_runtime = AsyncWorkOSClient( + api_key="sk_test_smoke" + ).create_feature_flags_runtime_client(bootstrap=configuration) + assert isinstance(sync_runtime, FeatureFlagsRuntimeClient) + assert isinstance(async_runtime, AsyncFeatureFlagsRuntimeClient) + assert sync_runtime.is_enabled("missing", default=True) is True + sync_runtime.close() + print("✓ Feature flags runtime clients importable") + + def test_dependencies_available() -> None: """Verify core runtime dependencies are installed and importable.""" import cryptography @@ -225,6 +249,7 @@ def main() -> int: test_core_types_importable, test_errors_importable, test_pagination_importable, + test_feature_flags_runtime_client_importable, test_dependencies_available, ]