From ad328da29d7c1bb4e6f5b3eb3d5d5f08f08dd4c9 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Mon, 2 Nov 2026 14:57:24 -0800 Subject: [PATCH 01/13] feat(feature-flags): Evaluate feature flags locally with a polling runtime client Add FeatureFlagsRuntimeClient, which polls GET /sdk/feature-flags (payload_version=2) on a daemon thread and evaluates flags from an immutable in-memory snapshot, matching the Node and .NET runtime clients: ordered rules where the first match wins (including rules that serve false), off_value/default_value, custom targets, a context that never raises, bootstrap, readiness, change/error/status listeners, manual refresh, status with refresh counts, flag inspection, get_all_flags and close. Python specifics: polling restarts in forked workers on first use, each refresh is bounded by request_timeout and retries transient failures up to the client's max_retries, context warnings are logged once per distinct problem, and payload validation never fails on rule kinds or operators the SDK does not know. The evaluation, payload, change and context cases live in JSON fixtures shaped for the shared cross-SDK corpus (AUTH-7098). Co-Authored-By: Claude Opus 5.5 --- README.md | 40 + src/workos/__init__.py | 3 + src/workos/_client.py | 6 + src/workos/feature_flags/runtime/__init__.py | 31 + .../feature_flags/runtime/_configuration.py | 425 +++ src/workos/feature_flags/runtime/_context.py | 137 + src/workos/feature_flags/runtime/_core.py | 298 ++ src/workos/feature_flags/runtime/_factory.py | 49 + src/workos/feature_flags/runtime/_policy.py | 105 + src/workos/feature_flags/runtime/_sync.py | 430 +++ src/workos/feature_flags/runtime/_types.py | 70 + tests/feature_flags_runtime/__init__.py | 0 tests/feature_flags_runtime/conftest.py | 67 + .../fixtures/change_cases.json | 1450 ++++++++++ .../fixtures/context_cases.json | 204 ++ .../fixtures/evaluation_cases.json | 2560 +++++++++++++++++ .../fixtures/payload_cases.json | 761 +++++ tests/feature_flags_runtime/helpers.py | 197 ++ .../test_configuration.py | 126 + tests/feature_flags_runtime/test_corpus.py | 127 + tests/feature_flags_runtime/test_fork.py | 72 + tests/feature_flags_runtime/test_policy.py | 137 + .../feature_flags_runtime/test_sync_client.py | 646 +++++ tests/smoke_test.py | 19 + 24 files changed, 7960 insertions(+) create mode 100644 src/workos/feature_flags/runtime/__init__.py create mode 100644 src/workos/feature_flags/runtime/_configuration.py create mode 100644 src/workos/feature_flags/runtime/_context.py create mode 100644 src/workos/feature_flags/runtime/_core.py create mode 100644 src/workos/feature_flags/runtime/_factory.py create mode 100644 src/workos/feature_flags/runtime/_policy.py create mode 100644 src/workos/feature_flags/runtime/_sync.py create mode 100644 src/workos/feature_flags/runtime/_types.py create mode 100644 tests/feature_flags_runtime/__init__.py create mode 100644 tests/feature_flags_runtime/conftest.py create mode 100644 tests/feature_flags_runtime/fixtures/change_cases.json create mode 100644 tests/feature_flags_runtime/fixtures/context_cases.json create mode 100644 tests/feature_flags_runtime/fixtures/evaluation_cases.json create mode 100644 tests/feature_flags_runtime/fixtures/payload_cases.json create mode 100644 tests/feature_flags_runtime/helpers.py create mode 100644 tests/feature_flags_runtime/test_configuration.py create mode 100644 tests/feature_flags_runtime/test_corpus.py create mode 100644 tests/feature_flags_runtime/test_fork.py create mode 100644 tests/feature_flags_runtime/test_policy.py create mode 100644 tests/feature_flags_runtime/test_sync_client.py diff --git a/README.md b/README.md index ab4923ba4..6400b7ba1 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,46 @@ 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) through the client's API key, base URL and HTTP client. 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:` +``` + +**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, 5xx, timeouts, connection errors) are also retried within one refresh, up to the client's `max_retries` and within `request_timeout`. 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. + +**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.** Close the runtime client before the `WorkOSClient` it polls through. Polling resumes in forked workers (for example gunicorn with `--preload`) the first time the client is used after the fork. 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 1a1182f45..d0138fc27 100644 --- a/src/workos/__init__.py +++ b/src/workos/__init__.py @@ -23,6 +23,7 @@ TransportTimeout, ) from ._pagination import AsyncPage, ListMetadata, SyncPage +from .feature_flags.runtime import FeatureFlagsConfiguration, FeatureFlagsRuntimeClient from .public_client import create_public_client from ._types import NOT_GIVEN, NotGiven, RequestOptions @@ -51,4 +52,6 @@ "TransportError", "TransportTimeout", "TransportConnectError", + "FeatureFlagsRuntimeClient", + "FeatureFlagsConfiguration", ] diff --git a/src/workos/_client.py b/src/workos/_client.py index 9776dd230..592c64803 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 diff --git a/src/workos/feature_flags/runtime/__init__.py b/src/workos/feature_flags/runtime/__init__.py new file mode 100644 index 000000000..1b4889226 --- /dev/null +++ b/src/workos/feature_flags/runtime/__init__.py @@ -0,0 +1,31 @@ +# @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`. +""" + +from ._configuration import ( + ConditionDefinition, + FeatureFlagsConfiguration, + FeatureFlagsConfigurationError, + FlagDefinition, + RuleDefinition, +) +from ._context import EvaluationContext +from ._sync import FeatureFlagsRuntimeClient +from ._types import FlagsChangedEvent, RuntimeStatus + +__all__ = [ + "ConditionDefinition", + "EvaluationContext", + "FeatureFlagsConfiguration", + "FeatureFlagsConfigurationError", + "FeatureFlagsRuntimeClient", + "FlagDefinition", + "FlagsChangedEvent", + "RuleDefinition", + "RuntimeStatus", +] diff --git a/src/workos/feature_flags/runtime/_configuration.py b/src/workos/feature_flags/runtime/_configuration.py new file mode 100644 index 000000000..1cabc1136 --- /dev/null +++ b/src/workos/feature_flags/runtime/_configuration.py @@ -0,0 +1,425 @@ +# @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", "_flags") + + _json: 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. + """ + try: + owned_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + except (TypeError, ValueError) as error: + raise FeatureFlagsConfigurationError( + f"Feature flags configuration is not JSON-serializable: {error}" + ) from error + self._json = owned_json + self._flags = MappingProxyType(_parse_payload(json.loads(owned_json))) + + @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 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) + 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) + + +# --- Parsing ------------------------------------------------------------------ + + +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 000000000..a3bb333e5 --- /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 000000000..bb7be6248 --- /dev/null +++ b/src/workos/feature_flags/runtime/_core.py @@ -0,0 +1,298 @@ +# @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 logging +import threading +from dataclasses import replace +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any, Callable, Mapping, Optional, Tuple, cast + +from ..._errors import APIError +from . import _policy +from ._configuration import ( + FeatureFlagsConfiguration, + FeatureFlagsConfigurationError, + FlagDefinition, + changed_flags, +) +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] + + +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._closed = False + + # --- 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()``.""" + return self._status.configuration + + @property + def is_ready(self) -> bool: + """Whether a validated configuration is available for local evaluation.""" + return self._status.configuration is not None + + @property + def is_closed(self) -> bool: + """Whether ``close()`` has been called.""" + 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) + changed = changed_flags(previous.configuration, configuration) + if previous.configuration is None or changed: + 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 FeatureFlagsConfiguration(cast(Mapping[str, Any], payload)) + 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 _before_use(self) -> None: + """Hook run before public calls; the sync client restarts polling after a fork.""" + + 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 000000000..f9966a80d --- /dev/null +++ b/src/workos/feature_flags/runtime/_factory.py @@ -0,0 +1,49 @@ +# @oagen-ignore-file +# This file is hand-maintained. The factory method that WorkOSClient exposes +# as `create_feature_flags_runtime_client`. +# pyright: strict + +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable, Optional + +from . import _policy +from ._configuration import FeatureFlagsConfiguration +from ._context import LoggerLike +from ._sync import FeatureFlagsRuntimeClient +from ._types import FlagsChangedEvent, RuntimeStatus + +if TYPE_CHECKING: + from ..._base_client import 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, + ) diff --git a/src/workos/feature_flags/runtime/_policy.py b/src/workos/feature_flags/runtime/_policy.py new file mode 100644 index 000000000..00fa46677 --- /dev/null +++ b/src/workos/feature_flags/runtime/_policy.py @@ -0,0 +1,105 @@ +# @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 +_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: the error is not transient, + ``max_retries`` retries were already made, or the wait would not fit in the + refresh's budget. + """ + 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 < remaining else 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 000000000..5c2d2da5a --- /dev/null +++ b/src/workos/feature_flags/runtime/_sync.py @@ -0,0 +1,430 @@ +# @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, utc_now +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"}) + + Close this client before closing the ``WorkOSClient`` it polls through. + """ + + 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. + 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._started = False + self._thread: Optional[threading.Thread] = None + self._readiness_error: Optional[Exception] = None + self._delivering: Optional[int] = None + self._fork_restart_pending = 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() + # `_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. + """ + with self._lifecycle_lock: + if self._closed: + raise RuntimeError( + "This feature flags runtime client is closed; create a new one" + ) + 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 TimeoutError(f"Feature flags were not ready within {timeout} seconds") + self._raise_if_unready() + + 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. + """ + self._before_use() + if self._closed: + raise RuntimeError( + "This feature flags runtime client is closed; create a new one" + ) + 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 request to finish. + 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) + + 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 on the refreshing thread, 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: 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: + 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) + delay = _policy.next_delay( + self._polling_interval, failures, self._max_retry_delay + ) + if self._wake.wait(delay): + return + + def _refresh(self, *, background: bool) -> None: + with self._refresh_lock: + 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._deliver( + 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._deliver(notifications) + + def _fetch(self) -> Any: + deadline = time.monotonic() + self._request_timeout + attempt = 0 + while True: + remaining = max(deadline - time.monotonic(), 0.001) + try: + return 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 + 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._deliver(notifications) + + def _deliver(self, notifications: list[Notification]) -> None: + if self._closed: + return + outer = self._delivering + self._delivering = threading.get_ident() + try: + 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; register a plain function instead" + ) + finally: + self._delivering = outer + + def _raise_if_unready(self) -> None: + if self._status.ready: + return + if self._readiness_error is not None: + raise self._readiness_error + raise RuntimeError( + "The feature flags runtime client was closed before it became ready" + ) + + # --- Fork safety ---------------------------------------------------------- + + def _before_use(self) -> None: + if self._fork_restart_pending: + self._restart_after_fork() + + 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, and the poller thread is gone, so + # rebuild them and restart polling on the next use of this client. + self._status_lock = threading.Lock() + self._listeners_lock = threading.Lock() + self._context_warnings.reinitialize_after_fork() + self._delivering = None + self._thread = None + self._init_synchronization() + self._fork_restart_pending = ( + self._started and not self._closed and not self._status.polling_stopped + ) + + +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/_types.py b/src/workos/feature_flags/runtime/_types.py new file mode 100644 index 000000000..33d19d084 --- /dev/null +++ b/src/workos/feature_flags/runtime/_types.py @@ -0,0 +1,70 @@ +# @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.""" + + +@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 000000000..e69de29bb diff --git a/tests/feature_flags_runtime/conftest.py b/tests/feature_flags_runtime/conftest.py new file mode 100644 index 000000000..76e5f8d6a --- /dev/null +++ b/tests/feature_flags_runtime/conftest.py @@ -0,0 +1,67 @@ +# @oagen-ignore-file +"""Fixtures for the feature flags runtime client tests.""" + +from __future__ import annotations + +from typing import Any, Callable, Iterator, List + +import pytest + +from workos import WorkOSClient +from workos.feature_flags.runtime import FeatureFlagsRuntimeClient, _policy + +from tests.feature_flags_runtime.helpers import ( + API_KEY, + BASE_URL, + 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 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() 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 000000000..7e8d82095 --- /dev/null +++ b/tests/feature_flags_runtime/fixtures/change_cases.json @@ -0,0 +1,1450 @@ +{ + "description": "Change detection between two valid payloads. changed lists the slugs a change notification reports.", + "format": 1, + "cases": [ + { + "name": "rule order", + "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_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": 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", + "before": { + "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" + ] + } + ] + } + ] + } + } + }, + "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", + "before": { + "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" + ] + } + ] + } + ] + } + } + }, + "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", + "before": { + "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" + ] + } + ] + } + ] + } + } + }, + "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", + "before": { + "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" + ] + } + ] + } + ] + } + } + }, + "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", + "before": { + "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" + ] + } + ] + } + ] + } + } + }, + "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", + "before": { + "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" + ] + } + ] + } + ] + } + } + }, + "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", + "before": { + "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" + ] + } + ] + } + ] + } + } + }, + "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", + "before": { + "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" + ] + } + ] + } + ] + } + } + }, + "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", + "before": { + "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" + ] + } + ] + } + ] + } + } + }, + "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", + "before": { + "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" + ] + } + ] + } + ] + } + } + }, + "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)", + "before": { + "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" + ] + } + ] + } + ] + } + } + }, + "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)", + "before": { + "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" + ] + } + ] + } + ] + } + } + }, + "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 000000000..d1470185e --- /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 000000000..f58db6763 --- /dev/null +++ b/tests/feature_flags_runtime/fixtures/evaluation_cases.json @@ -0,0 +1,2560 @@ +{ + "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. 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": "requests v2 and evaluates exclusions in payload order", + "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": [ + { + "context": { + "user": "user_1", + "organization": "org_1" + }, + "expect": false + }, + { + "context": { + "user": "user_1" + }, + "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": "no match serves the flag default, not the caller default", + "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": [ + { + "context": {}, + "expect": true + } + ] + }, + { + "name": "a missing context type does not 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": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": {}, + "expect": false + } + ] + }, + { + "name": "a matching false rule stops evaluation", + "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": [ + { + "context": { + "user": "user_1", + "organization": "org_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": "membership uses exact, case-sensitive IDs", + "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": [ + { + "context": { + "user": "USER_1" + }, + "expect": false + } + ] + }, + { + "name": "an ID from another type does not 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": [ + "user_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "workspace": "user_1" + }, + "expect": false + } + ] + }, + { + "name": "all conditions must 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": [ + "user_1" + ] + }, + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "context": { + "user": "user_1", + "organization": "org_1" + }, + "expect": true + } + ] + }, + { + "name": "one matching condition cannot override a failed condition", + "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": [ + { + "context": { + "user": "user_1", + "organization": "org_other" + }, + "expect": false + } + ] + }, + { + "name": "one matching condition cannot override a missing context 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" + ] + }, + { + "target_type": "organization", + "operator": "one_of", + "values": [ + "org_1" + ] + } + ] + } + ] + }, + "evaluations": [ + { + "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 000000000..fda0c8dd5 --- /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 000000000..4ac2c3052 --- /dev/null +++ b/tests/feature_flags_runtime/helpers.py @@ -0,0 +1,197 @@ +# @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 = reply + 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({"x-request-id": f"req_{len(self.requests)}"}), + 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 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) diff --git a/tests/feature_flags_runtime/test_configuration.py b/tests/feature_flags_runtime/test_configuration.py new file mode 100644 index 000000000..2fda407a0 --- /dev/null +++ b/tests/feature_flags_runtime/test_configuration.py @@ -0,0 +1,126 @@ +# @oagen-ignore-file +"""FeatureFlagsConfiguration: persistence and flag inspection.""" + +from __future__ import annotations + +import json + +import pytest + +from workos.feature_flags.runtime import ( + ConditionDefinition, + FeatureFlagsConfiguration, + FeatureFlagsConfigurationError, + FlagDefinition, + RuleDefinition, +) + +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()} + ) diff --git a/tests/feature_flags_runtime/test_corpus.py b/tests/feature_flags_runtime/test_corpus.py new file mode 100644 index 000000000..2080133fe --- /dev/null +++ b/tests/feature_flags_runtime/test_corpus.py @@ -0,0 +1,127 @@ +# @oagen-ignore-file +"""Runs the JSON fixture corpus (shaped for the shared AUTH-7098 corpus).""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, 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"} + + +def _runtime(configuration: FeatureFlagsConfiguration) -> FeatureFlagsRuntimeClient: + return FeatureFlagsRuntimeClient( + WorkOSClient(api_key=API_KEY), bootstrap=configuration + ) + + +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"]) + runtime = _runtime( + FeatureFlagsConfiguration.from_dict({"version": 2, "flags": {key: flag}}) + ) + for evaluation in case["evaluations"]: + assert set(evaluation) == _EVALUATION_FIELDS + assert _observed(runtime, key, evaluation["context"]) == 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"]) + } + runtime = _runtime( + FeatureFlagsConfiguration.from_dict({"version": 2, "flags": flags}) + ) + contexts: List[Dict[str, Any]] = [ + {}, + {"user": "user_1", "organization": "org_1"}, + {"organization": "org_acme"}, + ] + 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: + before = FeatureFlagsConfiguration(case["before"]) + 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: + runtime = _runtime( + FeatureFlagsConfiguration.from_dict( + {"version": 2, "flags": {"flag": CONTEXTS["flag"]}} + ) + ) + for case in CONTEXTS["cases"]: + expected = ( + case["targets"].get("workspace") == "ws_1" + or case["targets"].get("user") == "user_1" + ) + assert runtime.is_enabled("flag", case["context"]) 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 000000000..68d369108 --- /dev/null +++ b/tests/feature_flags_runtime/test_fork.py @@ -0,0 +1,72 @@ +# @oagen-ignore-file +"""A forked worker process resumes polling with its own thread.""" + +from __future__ import annotations + +import os +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] + +SCRIPT = textwrap.dedent( + """ + import os + import threading + import time + import warnings + + from workos import WorkOSClient + from workos.feature_flags.runtime import _policy + from tests.feature_flags_runtime.helpers import FlagServer, SyncBackend, flag, payload + + _policy.next_delay = lambda interval, failures, max_retry_delay: 0.01 + server = FlagServer() + server.respond(200, payload(f=flag("f", default_value=True))) + + def reset_server_lock(): + # The parent's poller may hold the fake server's lock at fork time. + server._condition = threading.Condition() + + os.register_at_fork(after_in_child=reset_server_lock) + workos = WorkOSClient(api_key="sk_test", base_url="https://api.workos.test", http_client=SyncBackend(server)) + runtime = workos.create_feature_flags_runtime_client() + runtime.start() + runtime.wait_until_ready(timeout=5) + server.wait_for_requests(2) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + pid = os.fork() + if pid == 0: + before = len(server.requests) + time.sleep(0.1) + idle = len(server.requests) == before + enabled = runtime.is_enabled("f") + deadline = time.monotonic() + 5 + while len(server.requests) < before + 3 and time.monotonic() < deadline: + time.sleep(0.01) + resumed = len(server.requests) >= before + 3 + runtime.close() + os._exit(0 if idle and enabled and resumed else 1) + _, status = os.waitpid(pid, 0) + runtime.close() + raise SystemExit(os.waitstatus_to_exitcode(status)) + """ +) + + +@pytest.mark.skipif(not hasattr(os, "fork"), reason="requires os.fork") +def test_a_forked_child_restarts_polling_on_first_use() -> None: + result = subprocess.run( + [sys.executable, "-c", SCRIPT], + cwd=ROOT, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, result.stderr diff --git a/tests/feature_flags_runtime/test_policy.py b/tests/feature_flags_runtime/test_policy.py new file mode 100644 index 000000000..ab9876b10 --- /dev/null +++ b/tests/feature_flags_runtime/test_policy.py @@ -0,0 +1,137 @@ +# @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", [(0.6, 0.5), (0.5, None)]) + def test_a_wait_must_fit_in_the_remaining_budget( + 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 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 000000000..c6ebe5e59 --- /dev/null +++ b/tests/feature_flags_runtime/test_sync_client.py @@ -0,0 +1,646 @@ +# @oagen-ignore-file +"""FeatureFlagsRuntimeClient: polling, readiness, notifications and shutdown.""" + +from __future__ import annotations + +import logging +import threading +from typing import Any, Callable, List + +import pytest + +from workos import WorkOSClient, create_public_client +from workos._errors import ( + AuthenticationError, + AuthorizationError, + ConfigurationError, + NotFoundError, + ServerError, +) +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_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_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_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() + + 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] + + 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, client: WorkOSClient) -> None: + runtime = client.create_feature_flags_runtime_client(polling_interval=1) + assert runtime._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/smoke_test.py b/tests/smoke_test.py index e630a96fb..03eee2583 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -198,6 +198,24 @@ def test_pagination_importable() -> None: print("✓ Pagination and RequestOptions importable") +def test_feature_flags_runtime_client_importable() -> None: + """Verify the runtime client constructs without making a request.""" + from workos import ( + 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) + assert isinstance(sync_runtime, FeatureFlagsRuntimeClient) + assert sync_runtime.is_enabled("missing", default=True) is True + sync_runtime.close() + print("✓ Feature flags runtime client importable") + + def test_dependencies_available() -> None: """Verify core runtime dependencies are installed and importable.""" import cryptography @@ -225,6 +243,7 @@ def main() -> int: test_core_types_importable, test_errors_importable, test_pagination_importable, + test_feature_flags_runtime_client_importable, test_dependencies_available, ] From 0913dab5be3b0dc52d3320d33006b7689aa6a4f3 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Mon, 2 Nov 2026 14:57:55 -0800 Subject: [PATCH 02/13] feat(feature-flags): Add an async feature flags runtime client Add AsyncFeatureFlagsRuntimeClient for AsyncWorkOSClient. It shares the parser, evaluator, status and listener handling of the sync client and polls in an asyncio task: start(), wait_until_ready(), refresh() and close() are coroutines, evaluation stays synchronous, coroutine listeners are awaited, and each refresh is also bounded with asyncio.wait_for so close() cancels a request in flight. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- src/workos/__init__.py | 7 +- src/workos/_client.py | 6 + src/workos/feature_flags/runtime/__init__.py | 5 +- src/workos/feature_flags/runtime/_async.py | 342 ++++++++++++++++++ src/workos/feature_flags/runtime/_factory.py | 44 ++- src/workos/feature_flags/runtime/_sync.py | 2 +- tests/feature_flags_runtime/conftest.py | 36 +- tests/feature_flags_runtime/helpers.py | 29 ++ .../test_async_client.py | 272 ++++++++++++++ tests/smoke_test.py | 10 +- 11 files changed, 745 insertions(+), 12 deletions(-) create mode 100644 src/workos/feature_flags/runtime/_async.py create mode 100644 tests/feature_flags_runtime/test_async_client.py diff --git a/README.md b/README.md index 6400b7ba1..b840fd8b4 100644 --- a/README.md +++ b/README.md @@ -258,11 +258,13 @@ 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, 5xx, timeouts, connection errors) are also retried within one refresh, up to the client's `max_retries` and within `request_timeout`. 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. +**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: diff --git a/src/workos/__init__.py b/src/workos/__init__.py index d0138fc27..fc234f954 100644 --- a/src/workos/__init__.py +++ b/src/workos/__init__.py @@ -23,7 +23,11 @@ TransportTimeout, ) from ._pagination import AsyncPage, ListMetadata, SyncPage -from .feature_flags.runtime import FeatureFlagsConfiguration, FeatureFlagsRuntimeClient +from .feature_flags.runtime import ( + AsyncFeatureFlagsRuntimeClient, + FeatureFlagsConfiguration, + FeatureFlagsRuntimeClient, +) from .public_client import create_public_client from ._types import NOT_GIVEN, NotGiven, RequestOptions @@ -53,5 +57,6 @@ "TransportTimeout", "TransportConnectError", "FeatureFlagsRuntimeClient", + "AsyncFeatureFlagsRuntimeClient", "FeatureFlagsConfiguration", ] diff --git a/src/workos/_client.py b/src/workos/_client.py index 592c64803..8425e8f83 100644 --- a/src/workos/_client.py +++ b/src/workos/_client.py @@ -344,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 index 1b4889226..e13e2b126 100644 --- a/src/workos/feature_flags/runtime/__init__.py +++ b/src/workos/feature_flags/runtime/__init__.py @@ -4,9 +4,11 @@ """Local evaluation of WorkOS feature flags from a polled configuration. -Use :class:`FeatureFlagsRuntimeClient` with :class:`workos.WorkOSClient`. +Use :class:`FeatureFlagsRuntimeClient` with :class:`workos.WorkOSClient` and +:class:`AsyncFeatureFlagsRuntimeClient` with :class:`workos.AsyncWorkOSClient`. """ +from ._async import AsyncFeatureFlagsRuntimeClient from ._configuration import ( ConditionDefinition, FeatureFlagsConfiguration, @@ -19,6 +21,7 @@ from ._types import FlagsChangedEvent, RuntimeStatus __all__ = [ + "AsyncFeatureFlagsRuntimeClient", "ConditionDefinition", "EvaluationContext", "FeatureFlagsConfiguration", diff --git a/src/workos/feature_flags/runtime/_async.py b/src/workos/feature_flags/runtime/_async.py new file mode 100644 index 000000000..9b4a6266c --- /dev/null +++ b/src/workos/feature_flags/runtime/_async.py @@ -0,0 +1,342 @@ +# @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 time +from types import TracebackType +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Type, Union + +from ..._errors import WorkOSTimeoutError +from . import _policy +from ._configuration import FeatureFlagsConfiguration +from ._context import LoggerLike +from ._core import Notification, RuntimeClientCore, 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``. + """ + + 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._started = False + self._task: Optional[asyncio.Task[None]] = None + self._readiness_error: Optional[Exception] = None + # Neither primitive binds to an event loop until first awaited. + self._refresh_lock = asyncio.Lock() + self._ready = asyncio.Event() + if self._status.ready: + self._ready.set() + 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) + + # --- 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 or no event loop is running. + """ + if self._closed: + raise RuntimeError( + "This feature flags runtime client is closed; create a new one" + ) + 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: + raise TimeoutError( + f"Feature flags were not ready within {timeout} seconds" + ) from None + if self._status.ready: + return + if self._readiness_error is not None: + raise self._readiness_error + raise RuntimeError( + "The feature flags runtime client was closed before it became ready" + ) + + 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. + """ + if self._closed: + raise RuntimeError( + "This feature flags runtime client is closed; create a new one" + ) + 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: + 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) + if self._closed: + return + await asyncio.sleep( + _policy.next_delay( + self._polling_interval, failures, self._max_retry_delay + ) + ) + + async def _refresh(self, *, background: bool) -> None: + async with self._refresh_lock: + if self._closed 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" + ) + await self._deliver( + self._record_failure(timeout, attempted, background=background) + ) + raise timeout from error + except Exception as error: + if self._closed: + raise _Closed() from error + await self._deliver( + self._record_failure(error, attempted, background=background) + ) + raise + if self._closed: + raise _Closed() + notifications = self._record_success( + configuration, attempted, background=background + ) + self._ready.set() + await self._deliver(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() + task = self._task + if task is not None and task is not asyncio.current_task(): + task.cancel() + await self._deliver(notifications) + + async def _deliver(self, notifications: list[Notification]) -> None: + if self._closed: + return + for listener, argument in notifications: + try: + result = listener(argument) + if inspect.isawaitable(result): + await result + except Exception: + self._log_listener_error() diff --git a/src/workos/feature_flags/runtime/_factory.py b/src/workos/feature_flags/runtime/_factory.py index f9966a80d..393b3e02e 100644 --- a/src/workos/feature_flags/runtime/_factory.py +++ b/src/workos/feature_flags/runtime/_factory.py @@ -1,6 +1,6 @@ # @oagen-ignore-file -# This file is hand-maintained. The factory method that WorkOSClient exposes -# as `create_feature_flags_runtime_client`. +# This file is hand-maintained. Factory methods that WorkOSClient and +# AsyncWorkOSClient expose as `create_feature_flags_runtime_client`. # pyright: strict from __future__ import annotations @@ -8,13 +8,19 @@ 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 WorkOSClient + from ..._base_client import AsyncWorkOSClient, WorkOSClient def create_feature_flags_runtime_client( @@ -47,3 +53,35 @@ def create_feature_flags_runtime_client( 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/_sync.py b/src/workos/feature_flags/runtime/_sync.py index 5c2d2da5a..5f79cb2a0 100644 --- a/src/workos/feature_flags/runtime/_sync.py +++ b/src/workos/feature_flags/runtime/_sync.py @@ -365,7 +365,7 @@ def _deliver(self, notifications: list[Notification]) -> None: _discard_awaitable(result) self._logger.warning( "An async listener was registered on the sync FeatureFlagsRuntimeClient " - "and was not run; register a plain function instead" + "and was not run; use AsyncFeatureFlagsRuntimeClient for async listeners" ) finally: self._delivering = outer diff --git a/tests/feature_flags_runtime/conftest.py b/tests/feature_flags_runtime/conftest.py index 76e5f8d6a..8106ef8be 100644 --- a/tests/feature_flags_runtime/conftest.py +++ b/tests/feature_flags_runtime/conftest.py @@ -3,16 +3,22 @@ from __future__ import annotations -from typing import Any, Callable, Iterator, List +from typing import Any, AsyncIterator, Callable, Iterator, List import pytest +import pytest_asyncio -from workos import WorkOSClient -from workos.feature_flags.runtime import FeatureFlagsRuntimeClient, _policy +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, ) @@ -32,6 +38,13 @@ def client(server: FlagServer) -> Iterator[WorkOSClient]: 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. @@ -65,3 +78,20 @@ def make(**options: Any) -> FeatureFlagsRuntimeClient: 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/helpers.py b/tests/feature_flags_runtime/helpers.py index 4ac2c3052..8bb7b86be 100644 --- a/tests/feature_flags_runtime/helpers.py +++ b/tests/feature_flags_runtime/helpers.py @@ -168,6 +168,35 @@ 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.""" 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 000000000..2a0fdad31 --- /dev/null +++ b/tests/feature_flags_runtime/test_async_client.py @@ -0,0 +1,272 @@ +# @oagen-ignore-file +"""AsyncFeatureFlagsRuntimeClient: polling, readiness, notifications and shutdown.""" + +from __future__ import annotations + +import asyncio +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_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 + ) + ) + + +@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_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/smoke_test.py b/tests/smoke_test.py index 03eee2583..3ce236707 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -199,8 +199,10 @@ def test_pagination_importable() -> None: def test_feature_flags_runtime_client_importable() -> None: - """Verify the runtime client constructs without making a request.""" + """Verify both runtime clients construct without making a request.""" from workos import ( + AsyncFeatureFlagsRuntimeClient, + AsyncWorkOSClient, FeatureFlagsConfiguration, FeatureFlagsRuntimeClient, WorkOSClient, @@ -210,10 +212,14 @@ def test_feature_flags_runtime_client_importable() -> None: 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 client importable") + print("✓ Feature flags runtime clients importable") def test_dependencies_available() -> None: From 4937046712bb50d9f05b7f419a21fe4c9459240a Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Mon, 2 Nov 2026 16:29:32 -0800 Subject: [PATCH 03/13] test(feature-flags): Merge evaluation cases that share a flag and share the change baseline Three groups of evaluation cases used the same flag definition and differed only in the context they evaluated. Each group is now one case with several evaluations, and each evaluation keeps the original case name in an optional `name` field. Thirteen of the eighteen change cases repeated the same `before` payload. It now lives once as the top-level `baseline`, and a case without `before` starts from it. The corpus runner rejects unknown change-case fields, as it already does for evaluation cases. Co-Authored-By: Claude Opus 5.5 --- .../fixtures/change_cases.json | 696 +++--------------- .../fixtures/evaluation_cases.json | 260 +------ tests/feature_flags_runtime/test_corpus.py | 14 +- 3 files changed, 140 insertions(+), 830 deletions(-) diff --git a/tests/feature_flags_runtime/fixtures/change_cases.json b/tests/feature_flags_runtime/fixtures/change_cases.json index 7e8d82095..25c90f81d 100644 --- a/tests/feature_flags_runtime/fixtures/change_cases.json +++ b/tests/feature_flags_runtime/fixtures/change_cases.json @@ -1,442 +1,52 @@ { - "description": "Change detection between two valid payloads. changed lists the slugs a change notification reports.", + "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, - "cases": [ - { - "name": "rule order", - "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_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": 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", - "before": { - "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" - ] - } - ] - } - ] - } - } - }, - "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", - "before": { - "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" - ] - } - ] - } - ] - } - } - }, - "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", - "before": { - "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" - ] - } - ] - } - ] - } - } - }, - "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", - "before": { - "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" - ] - } - ] - } - ] - } - } - }, - "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" - ] - } + "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": [ { - "id": "rule_user", - "kind": "conditions", - "value": true, - "conditions": [ - { - "target_type": "user", - "operator": "one_of", - "values": [ - "user_1" - ] - } + "target_type": "user", + "operator": "one_of", + "values": [ + "user_1" ] } ] } - } - }, - "changed": [ - "flag" - ] - }, + ] + } + } + }, + "cases": [ { - "name": "enabled", + "name": "rule order", "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", - "before": { + "after": { "version": 2, "flags": { "flag": { @@ -446,29 +56,29 @@ "off_value": false, "rules": [ { - "id": "rule_org", + "id": "rule_user", "kind": "conditions", - "value": false, + "value": true, "conditions": [ { - "target_type": "organization", + "target_type": "user", "operator": "one_of", "values": [ - "org_1" + "user_1" ] } ] }, { - "id": "rule_user", + "id": "rule_org", "kind": "conditions", - "value": true, + "value": false, "conditions": [ { - "target_type": "user", + "target_type": "organization", "operator": "one_of", "values": [ - "user_1" + "org_1" ] } ] @@ -477,19 +87,26 @@ } } }, + "changed": [ + "flag" + ] + }, + { + "name": "rule value", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", "after": { "version": 2, "flags": { "flag": { "slug": "flag", - "enabled": false, + "enabled": true, "default_value": true, "off_value": false, "rules": [ { "id": "rule_org", "kind": "conditions", - "value": false, + "value": true, "conditions": [ { "target_type": "organization", @@ -523,9 +140,9 @@ ] }, { - "name": "rule kind", + "name": "membership", "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", - "before": { + "after": { "version": 2, "flags": { "flag": { @@ -534,20 +151,6 @@ "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", @@ -557,7 +160,7 @@ "target_type": "user", "operator": "one_of", "values": [ - "user_1" + "user_2" ] } ] @@ -566,6 +169,13 @@ } } }, + "changed": [ + "flag" + ] + }, + { + "name": "off value", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", "after": { "version": 2, "flags": { @@ -573,11 +183,11 @@ "slug": "flag", "enabled": true, "default_value": true, - "off_value": false, + "off_value": true, "rules": [ { "id": "rule_org", - "kind": "rollout", + "kind": "conditions", "value": false, "conditions": [ { @@ -612,15 +222,15 @@ ] }, { - "name": "condition operator", + "name": "default value", "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", - "before": { + "after": { "version": 2, "flags": { "flag": { "slug": "flag", "enabled": true, - "default_value": true, + "default_value": false, "off_value": false, "rules": [ { @@ -655,12 +265,19 @@ } } }, + "changed": [ + "flag" + ] + }, + { + "name": "enabled", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", "after": { "version": 2, "flags": { "flag": { "slug": "flag", - "enabled": true, + "enabled": false, "default_value": true, "off_value": false, "rules": [ @@ -671,7 +288,7 @@ "conditions": [ { "target_type": "organization", - "operator": "not_one_of", + "operator": "one_of", "values": [ "org_1" ] @@ -701,9 +318,9 @@ ] }, { - "name": "target type", + "name": "rule kind", "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", - "before": { + "after": { "version": 2, "flags": { "flag": { @@ -714,7 +331,7 @@ "rules": [ { "id": "rule_org", - "kind": "conditions", + "kind": "rollout", "value": false, "conditions": [ { @@ -744,6 +361,13 @@ } } }, + "changed": [ + "flag" + ] + }, + { + "name": "condition operator", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", "after": { "version": 2, "flags": { @@ -759,8 +383,8 @@ "value": false, "conditions": [ { - "target_type": "workspace", - "operator": "one_of", + "target_type": "organization", + "operator": "not_one_of", "values": [ "org_1" ] @@ -790,9 +414,9 @@ ] }, { - "name": "removed rule", + "name": "target type", "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", - "before": { + "after": { "version": 2, "flags": { "flag": { @@ -807,7 +431,7 @@ "value": false, "conditions": [ { - "target_type": "organization", + "target_type": "workspace", "operator": "one_of", "values": [ "org_1" @@ -833,6 +457,13 @@ } } }, + "changed": [ + "flag" + ] + }, + { + "name": "removed rule", + "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", "after": { "version": 2, "flags": { @@ -942,47 +573,6 @@ { "name": "additions and removals", "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_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": { @@ -1272,47 +862,6 @@ { "name": "an unknown flag field counts", "source": "workos-python (new)", - "before": { - "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" - ] - } - ] - } - ] - } - } - }, "after": { "version": 2, "flags": { @@ -1362,47 +911,6 @@ { "name": "identical payloads", "source": "workos-python (new)", - "before": { - "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" - ] - } - ] - } - ] - } - } - }, "after": { "version": 2, "flags": { diff --git a/tests/feature_flags_runtime/fixtures/evaluation_cases.json b/tests/feature_flags_runtime/fixtures/evaluation_cases.json index f58db6763..fe21e6ee6 100644 --- a/tests/feature_flags_runtime/fixtures/evaluation_cases.json +++ b/tests/feature_flags_runtime/fixtures/evaluation_cases.json @@ -1,9 +1,9 @@ { - "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. 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.", + "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": "requests v2 and evaluates exclusions in payload order", + "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", @@ -43,6 +43,7 @@ }, "evaluations": [ { + "name": "an earlier matching false rule wins and stops evaluation", "context": { "user": "user_1", "organization": "org_1" @@ -50,10 +51,16 @@ "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 } ] }, @@ -169,53 +176,7 @@ ] }, { - "name": "no match serves the flag default, not the caller default", - "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": [ - { - "context": {}, - "expect": true - } - ] - }, - { - "name": "a missing context type does not match", + "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", @@ -241,55 +202,21 @@ }, "evaluations": [ { + "name": "a missing context type does not match", "context": {}, "expect": false - } - ] - }, - { - "name": "a matching false rule stops evaluation", - "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" - ] - } - ] + }, + { + "name": "membership uses exact, case-sensitive IDs", + "context": { + "user": "USER_1" }, - { - "id": "rule_user", - "kind": "conditions", - "value": true, - "conditions": [ - { - "target_type": "user", - "operator": "one_of", - "values": [ - "user_1" - ] - } - ] - } - ] - }, - "evaluations": [ + "expect": false + }, { + "name": "an ID from another type does not match", "context": { - "user": "user_1", - "organization": "org_1" + "workspace": "user_1" }, "expect": false } @@ -434,75 +361,7 @@ ] }, { - "name": "membership uses exact, case-sensitive IDs", - "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": [ - { - "context": { - "user": "USER_1" - }, - "expect": false - } - ] - }, - { - "name": "an ID from another type does not 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": [ - "user_1" - ] - } - ] - } - ] - }, - "evaluations": [ - { - "context": { - "workspace": "user_1" - }, - "expect": false - } - ] - }, - { - "name": "all conditions must match", + "name": "a rule matches only when every condition matches", "source": "workos-node src/feature-flags/runtime-client-rules.spec.ts", "flag": { "slug": "flag", @@ -535,90 +394,23 @@ }, "evaluations": [ { + "name": "all conditions must match", "context": { "user": "user_1", "organization": "org_1" }, "expect": true - } - ] - }, - { - "name": "one matching condition cannot override a failed condition", - "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": "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", - "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": "one matching condition cannot override a missing context type", "context": { "user": "user_1" }, diff --git a/tests/feature_flags_runtime/test_corpus.py b/tests/feature_flags_runtime/test_corpus.py index 2080133fe..752178057 100644 --- a/tests/feature_flags_runtime/test_corpus.py +++ b/tests/feature_flags_runtime/test_corpus.py @@ -26,6 +26,8 @@ _CASE_FIELDS = {"name", "source", "key", "flag", "evaluations", "malformed"} _EVALUATION_FIELDS = {"context", "expect"} +_OPTIONAL_EVALUATION_FIELDS = {"name"} +_CHANGE_FIELDS = {"name", "source", "before", "after", "changed"} def _runtime(configuration: FeatureFlagsConfiguration) -> FeatureFlagsRuntimeClient: @@ -54,7 +56,12 @@ def test_evaluation_corpus(case: Dict[str, Any]) -> None: FeatureFlagsConfiguration.from_dict({"version": 2, "flags": {key: flag}}) ) for evaluation in case["evaluations"]: - assert set(evaluation) == _EVALUATION_FIELDS + fields = set(evaluation) + assert ( + _EVALUATION_FIELDS + <= fields + <= _EVALUATION_FIELDS | _OPTIONAL_EVALUATION_FIELDS + ), f"unexpected evaluation fields: {fields}" assert _observed(runtime, key, evaluation["context"]) == evaluation["expect"], ( evaluation ) @@ -99,7 +106,10 @@ def test_payload_corpus(case: Dict[str, Any]) -> None: @pytest.mark.parametrize("case", CHANGES["cases"], ids=lambda case: case["name"]) def test_change_corpus(case: Dict[str, Any]) -> None: - before = FeatureFlagsConfiguration(case["before"]) + 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"] From 56cc91aeb64ee5ba3babe2c703b86ed76b3953bf Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Mon, 2 Nov 2026 17:55:13 -0800 Subject: [PATCH 04/13] test(feature-flags): Close the clients the runtime tests create The corpus tests built a WorkOSClient and a runtime client for every case and never closed either, and a few sync-client tests did the same. Leaked clients hold HTTP pools and surface as ResourceWarnings, so each test now closes what it creates. Co-Authored-By: Claude Opus 5.5 --- tests/feature_flags_runtime/test_corpus.py | 83 ++++++++++--------- .../feature_flags_runtime/test_sync_client.py | 7 +- 2 files changed, 49 insertions(+), 41 deletions(-) diff --git a/tests/feature_flags_runtime/test_corpus.py b/tests/feature_flags_runtime/test_corpus.py index 752178057..bb592d2df 100644 --- a/tests/feature_flags_runtime/test_corpus.py +++ b/tests/feature_flags_runtime/test_corpus.py @@ -4,7 +4,8 @@ from __future__ import annotations import logging -from typing import Any, Dict, List +from contextlib import contextmanager +from typing import Any, Dict, Iterator, List import pytest @@ -30,10 +31,17 @@ _CHANGE_FIELDS = {"name", "source", "before", "after", "changed"} -def _runtime(configuration: FeatureFlagsConfiguration) -> FeatureFlagsRuntimeClient: - return FeatureFlagsRuntimeClient( - WorkOSClient(api_key=API_KEY), bootstrap=configuration - ) +@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( @@ -52,19 +60,19 @@ def test_evaluation_corpus(case: Dict[str, Any]) -> None: assert set(case) <= _CASE_FIELDS, f"unknown case fields: {set(case) - _CASE_FIELDS}" flag = case["flag"] key = case.get("key", flag["slug"]) - runtime = _runtime( - FeatureFlagsConfiguration.from_dict({"version": 2, "flags": {key: flag}}) + configuration = FeatureFlagsConfiguration.from_dict( + {"version": 2, "flags": {key: flag}} ) - for evaluation in case["evaluations"]: - fields = set(evaluation) - assert ( - _EVALUATION_FIELDS - <= fields - <= _EVALUATION_FIELDS | _OPTIONAL_EVALUATION_FIELDS - ), f"unexpected evaluation fields: {fields}" - assert _observed(runtime, key, evaluation["context"]) == evaluation["expect"], ( - evaluation - ) + 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() -> ( @@ -77,22 +85,21 @@ def test_get_all_flags_agrees_with_is_enabled_and_omits_flags_without_an_answer( } for index, case in enumerate(EVALUATION["cases"]) } - runtime = _runtime( - FeatureFlagsConfiguration.from_dict({"version": 2, "flags": flags}) - ) contexts: List[Dict[str, Any]] = [ {}, {"user": "user_1", "organization": "org_1"}, {"organization": "org_acme"}, ] - 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 + 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"]) @@ -124,14 +131,14 @@ def test_context_corpus(case: Dict[str, Any], caplog: pytest.LogCaptureFixture) def test_context_corpus_flag_matches_only_the_surviving_targets() -> None: - runtime = _runtime( - FeatureFlagsConfiguration.from_dict( - {"version": 2, "flags": {"flag": CONTEXTS["flag"]}} - ) + configuration = FeatureFlagsConfiguration.from_dict( + {"version": 2, "flags": {"flag": CONTEXTS["flag"]}} ) - for case in CONTEXTS["cases"]: - expected = ( - case["targets"].get("workspace") == "ws_1" - or case["targets"].get("user") == "user_1" - ) - assert runtime.is_enabled("flag", case["context"]) is expected, case["name"] + 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_sync_client.py b/tests/feature_flags_runtime/test_sync_client.py index c6ebe5e59..86d5f9c09 100644 --- a/tests/feature_flags_runtime/test_sync_client.py +++ b/tests/feature_flags_runtime/test_sync_client.py @@ -324,6 +324,7 @@ def test_retries_follow_the_clients_max_retries( runtime.refresh() assert len(server.requests) == requests runtime.close() + workos.close() def test_errors_that_retrying_cannot_fix_are_not_retried( self, server: FlagServer, make_runtime: Make, fast_polling: List[int] @@ -622,14 +623,14 @@ 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, client: WorkOSClient) -> None: - runtime = client.create_feature_flags_runtime_client(polling_interval=1) - assert runtime._polling_interval == 5.0 + 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( From 1ee4c9d49e053982db3465c3e8822df643a6855e Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Mon, 2 Nov 2026 17:56:23 -0800 Subject: [PATCH 05/13] fix(feature-flags): Keep unchanged snapshots and make snapshots picklable Most polls return the configuration the client already has, yet every poll re-serialized, re-validated and re-canonicalized it and then diffed every flag. A snapshot now carries a fingerprint of its content (the payload serialized with sorted keys); a poll whose payload has the same fingerprint keeps the existing snapshot and skips parsing and the diff. Refresh counts, timestamps and readiness update exactly as before. FeatureFlagsConfiguration holds a MappingProxyType, which cannot be pickled, so the snapshot, and any RuntimeStatus or FlagsChangedEvent holding one, failed pickle and copy.deepcopy. It now pickles by rebuilding from its JSON, and copy/deepcopy return the same immutable instance. Co-Authored-By: Claude Opus 5.5 --- .../feature_flags/runtime/_configuration.py | 66 ++++++++++++++++--- src/workos/feature_flags/runtime/_core.py | 5 +- .../test_configuration.py | 44 +++++++++++++ .../feature_flags_runtime/test_sync_client.py | 34 ++++++++++ 4 files changed, 138 insertions(+), 11 deletions(-) diff --git a/src/workos/feature_flags/runtime/_configuration.py b/src/workos/feature_flags/runtime/_configuration.py index 1cabc1136..b034ddbcc 100644 --- a/src/workos/feature_flags/runtime/_configuration.py +++ b/src/workos/feature_flags/runtime/_configuration.py @@ -124,9 +124,12 @@ class FeatureFlagsConfiguration: new runtime client usable before its first successful poll. """ - __slots__ = ("_json", "_flags") + __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: @@ -138,14 +141,22 @@ def __init__(self, payload: Mapping[str, Any]) -> None: accepted and never match, and a served value that is not a boolean leaves the flag without an answer. """ - try: - owned_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) - except (TypeError, ValueError) as error: - raise FeatureFlagsConfigurationError( - f"Feature flags configuration is not JSON-serializable: {error}" - ) from error - self._json = owned_json - self._flags = MappingProxyType(_parse_payload(json.loads(owned_json))) + 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: @@ -204,6 +215,22 @@ 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]: @@ -215,6 +242,8 @@ def changed_flags( """ if previous is None: return frozenset(current.slugs) + if previous is current: + return frozenset() changed = { slug for slug, flag in current._compiled_items() @@ -225,9 +254,28 @@ def changed_flags( 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}" diff --git a/src/workos/feature_flags/runtime/_core.py b/src/workos/feature_flags/runtime/_core.py index bb7be6248..e229d8b9f 100644 --- a/src/workos/feature_flags/runtime/_core.py +++ b/src/workos/feature_flags/runtime/_core.py @@ -9,7 +9,7 @@ import threading from dataclasses import replace from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, Callable, Mapping, Optional, Tuple, cast +from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple from ..._errors import APIError from . import _policy @@ -18,6 +18,7 @@ FeatureFlagsConfigurationError, FlagDefinition, changed_flags, + configuration_for, ) from ._context import ContextWarnings, EvaluationContext, LoggerLike, normalize_context from ._types import FlagsChangedEvent, RuntimeStatus @@ -253,7 +254,7 @@ 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 FeatureFlagsConfiguration(cast(Mapping[str, Any], payload)) + return configuration_for(payload, self._status.configuration) except FeatureFlagsConfigurationError as error: raise FeatureFlagsConfigurationError( "Feature flag poll returned a payload this SDK can't read; " diff --git a/tests/feature_flags_runtime/test_configuration.py b/tests/feature_flags_runtime/test_configuration.py index 2fda407a0..271c022c8 100644 --- a/tests/feature_flags_runtime/test_configuration.py +++ b/tests/feature_flags_runtime/test_configuration.py @@ -3,16 +3,22 @@ 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 @@ -124,3 +130,41 @@ def test_values_that_are_not_json_are_rejected() -> None: 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_sync_client.py b/tests/feature_flags_runtime/test_sync_client.py index 86d5f9c09..b3610a6b9 100644 --- a/tests/feature_flags_runtime/test_sync_client.py +++ b/tests/feature_flags_runtime/test_sync_client.py @@ -3,6 +3,7 @@ from __future__ import annotations +import json import logging import threading from typing import Any, Callable, List @@ -17,6 +18,7 @@ NotFoundError, ServerError, ) +from workos.feature_flags.runtime import _configuration from workos.feature_flags.runtime import ( FeatureFlagsConfiguration, FeatureFlagsConfigurationError, @@ -223,6 +225,38 @@ def test_first_refresh_after_bootstrap_compares_with_the_bootstrap( 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: From 37dca3202050615eec445817f3b691b73f710595 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Mon, 2 Nov 2026 17:57:18 -0800 Subject: [PATCH 06/13] fix(feature-flags): Explain readiness timeouts and raise a fresh error per waiter wait_until_ready() raised a bare TimeoutError when flags never arrived, dropping the refresh error that explains why. The timeout now names the last refresh error in its message and chains it as __cause__. After polling stopped on a 401, every waiter re-raised the one stored AuthenticationError, so each raise appended to that instance's shared __traceback__ from whatever thread was waiting. Each waiter now gets its own copy of the error (same type and attributes, empty traceback), with the stored error as its cause. Co-Authored-By: Claude Opus 5.5 --- src/workos/feature_flags/runtime/_async.py | 15 ++---- src/workos/feature_flags/runtime/_core.py | 31 +++++++++++++ src/workos/feature_flags/runtime/_sync.py | 15 ++---- .../test_async_client.py | 35 ++++++++++++++ .../feature_flags_runtime/test_sync_client.py | 46 +++++++++++++++++++ 5 files changed, 119 insertions(+), 23 deletions(-) diff --git a/src/workos/feature_flags/runtime/_async.py b/src/workos/feature_flags/runtime/_async.py index 9b4a6266c..7254cad6c 100644 --- a/src/workos/feature_flags/runtime/_async.py +++ b/src/workos/feature_flags/runtime/_async.py @@ -78,7 +78,6 @@ def __init__( self._client = client self._started = False self._task: Optional[asyncio.Task[None]] = None - self._readiness_error: Optional[Exception] = None # Neither primitive binds to an event loop until first awaited. self._refresh_lock = asyncio.Lock() self._ready = asyncio.Event() @@ -138,16 +137,10 @@ async def wait_until_ready(self, timeout: Optional[float] = None) -> None: try: await asyncio.wait_for(self._ready.wait(), timeout) except asyncio.TimeoutError: - raise TimeoutError( - f"Feature flags were not ready within {timeout} seconds" - ) from None - if self._status.ready: - return - if self._readiness_error is not None: - raise self._readiness_error - raise RuntimeError( - "The feature flags runtime client was closed before it became ready" - ) + # 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. diff --git a/src/workos/feature_flags/runtime/_core.py b/src/workos/feature_flags/runtime/_core.py index e229d8b9f..7c607ac52 100644 --- a/src/workos/feature_flags/runtime/_core.py +++ b/src/workos/feature_flags/runtime/_core.py @@ -5,6 +5,7 @@ from __future__ import annotations +import copy import logging import threading from dataclasses import replace @@ -81,6 +82,8 @@ def __init__( "status": (), } self._closed = False + # The 401 that stopped polling before any configuration was available. + self._readiness_error: Optional[Exception] = None # --- Evaluation ----------------------------------------------------------- @@ -261,6 +264,34 @@ def _parse(self, payload: object) -> FeatureFlagsConfiguration: 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.""" diff --git a/src/workos/feature_flags/runtime/_sync.py b/src/workos/feature_flags/runtime/_sync.py index 5f79cb2a0..f498ce19e 100644 --- a/src/workos/feature_flags/runtime/_sync.py +++ b/src/workos/feature_flags/runtime/_sync.py @@ -88,7 +88,6 @@ def __init__( self._client = client self._started = False self._thread: Optional[threading.Thread] = None - self._readiness_error: Optional[Exception] = None self._delivering: Optional[int] = None self._fork_restart_pending = False self._init_synchronization() @@ -161,8 +160,9 @@ def wait_until_ready(self, timeout: Optional[float] = None) -> None: if self._status.ready: return if not self._ready.wait(timeout): - raise TimeoutError(f"Feature flags were not ready within {timeout} seconds") - self._raise_if_unready() + 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. @@ -370,15 +370,6 @@ def _deliver(self, notifications: list[Notification]) -> None: finally: self._delivering = outer - def _raise_if_unready(self) -> None: - if self._status.ready: - return - if self._readiness_error is not None: - raise self._readiness_error - raise RuntimeError( - "The feature flags runtime client was closed before it became ready" - ) - # --- Fork safety ---------------------------------------------------------- def _before_use(self) -> None: diff --git a/tests/feature_flags_runtime/test_async_client.py b/tests/feature_flags_runtime/test_async_client.py index 2a0fdad31..1e7ee476a 100644 --- a/tests/feature_flags_runtime/test_async_client.py +++ b/tests/feature_flags_runtime/test_async_client.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio +import traceback from typing import Any, Callable, List import pytest @@ -91,6 +92,40 @@ async def test_wait_times_out_without_a_configuration(make_async_runtime: Make) 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: diff --git a/tests/feature_flags_runtime/test_sync_client.py b/tests/feature_flags_runtime/test_sync_client.py index b3610a6b9..f3b012344 100644 --- a/tests/feature_flags_runtime/test_sync_client.py +++ b/tests/feature_flags_runtime/test_sync_client.py @@ -6,6 +6,7 @@ import json import logging import threading +import traceback from typing import Any, Callable, List import pytest @@ -153,6 +154,51 @@ def test_a_401_before_readiness_fails_the_wait( 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] = [] From 2bfa1358fbd824751c5049cfbab69a6de82472c6 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Mon, 2 Nov 2026 17:58:22 -0800 Subject: [PATCH 07/13] fix(feature-flags): Honor Retry-After across polls and keep time for each retry A 429 whose Retry-After did not fit in the refresh budget failed the refresh, and the next poll then ignored the server's request and polled again on the normal schedule. The next poll now waits at least Retry-After, capped at 60 seconds like the Node SDK's MAXIMUM_RETRY_AFTER_TIME_IN_MILLISECONDS, in both clients. A retry inside one refresh only checked that its sleep fit in the remaining budget, so the last retry could go out with almost no time left, time out, and be reported as WorkOSTimeoutError instead of the server's real error. A retry is now only sent when at least half a second of the budget is left for the request itself; otherwise the refresh fails with the last real error. Co-Authored-By: Claude Opus 5.5 --- src/workos/feature_flags/runtime/_async.py | 9 ++-- src/workos/feature_flags/runtime/_policy.py | 26 ++++++++-- src/workos/feature_flags/runtime/_sync.py | 4 +- tests/feature_flags_runtime/helpers.py | 8 +++- .../test_async_client.py | 13 +++++ tests/feature_flags_runtime/test_policy.py | 21 ++++++++- .../feature_flags_runtime/test_sync_client.py | 47 ++++++++++++++++++- 7 files changed, 114 insertions(+), 14 deletions(-) diff --git a/src/workos/feature_flags/runtime/_async.py b/src/workos/feature_flags/runtime/_async.py index 7254cad6c..63ce2feb6 100644 --- a/src/workos/feature_flags/runtime/_async.py +++ b/src/workos/feature_flags/runtime/_async.py @@ -236,6 +236,7 @@ def remove_status_listener(self, listener: StatusListener) -> None: 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 @@ -248,13 +249,13 @@ async def _poll_forever(self) -> None: await self._stop_polling(error) return failures = min(failures + 1, _policy.MAX_COUNTED_FAILURES) + retry_after = _policy.retry_after(error) if self._closed: return - await asyncio.sleep( - _policy.next_delay( - self._polling_interval, failures, self._max_retry_delay - ) + delay = _policy.next_delay( + self._polling_interval, failures, self._max_retry_delay ) + await asyncio.sleep(max(delay, retry_after or 0.0)) async def _refresh(self, *, background: bool) -> None: async with self._refresh_lock: diff --git a/src/workos/feature_flags/runtime/_policy.py b/src/workos/feature_flags/runtime/_policy.py index 00fa46677..09ce54152 100644 --- a/src/workos/feature_flags/runtime/_policy.py +++ b/src/workos/feature_flags/runtime/_policy.py @@ -31,6 +31,11 @@ # 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") @@ -75,9 +80,11 @@ def attempt_retry_delay( ) -> float | None: """Seconds to wait before retrying a failed request inside one refresh. - ``None`` means give up and fail the refresh: the error is not transient, - ``max_retries`` retries were already made, or the wait would not fit in the - refresh's budget. + ``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 @@ -92,7 +99,18 @@ def attempt_retry_delay( * _ATTEMPT_RETRY_MULTIPLIER**attempt * (0.5 + random.random()) ) - return delay if delay < remaining else None + 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: diff --git a/src/workos/feature_flags/runtime/_sync.py b/src/workos/feature_flags/runtime/_sync.py index f498ce19e..aa2435160 100644 --- a/src/workos/feature_flags/runtime/_sync.py +++ b/src/workos/feature_flags/runtime/_sync.py @@ -274,6 +274,7 @@ def _spawn_poller(self) -> None: 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 @@ -286,10 +287,11 @@ def _poll_forever(self) -> None: 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(delay): + if self._wake.wait(max(delay, retry_after or 0.0)): return def _refresh(self, *, background: bool) -> None: diff --git a/tests/feature_flags_runtime/helpers.py b/tests/feature_flags_runtime/helpers.py index 8bb7b86be..9c6c70c20 100644 --- a/tests/feature_flags_runtime/helpers.py +++ b/tests/feature_flags_runtime/helpers.py @@ -125,7 +125,11 @@ def _finish(self) -> None: def _response(self, reply: Any, method: str, url: str) -> HTTPResponse: if isinstance(reply, Exception): raise reply - status, body = 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) @@ -135,7 +139,7 @@ def _response(self, reply: Any, method: str, url: str) -> HTTPResponse: ) return HTTPResponse( status_code=status, - headers=httpx2.Headers({"x-request-id": f"req_{len(self.requests)}"}), + headers=httpx2.Headers(headers), content=content, request_method=method, request_url=url, diff --git a/tests/feature_flags_runtime/test_async_client.py b/tests/feature_flags_runtime/test_async_client.py index 1e7ee476a..356cb73a8 100644 --- a/tests/feature_flags_runtime/test_async_client.py +++ b/tests/feature_flags_runtime/test_async_client.py @@ -216,6 +216,19 @@ async def test_a_401_from_refresh_stops_polling( 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: diff --git a/tests/feature_flags_runtime/test_policy.py b/tests/feature_flags_runtime/test_policy.py index ab9876b10..9c05471e6 100644 --- a/tests/feature_flags_runtime/test_policy.py +++ b/tests/feature_flags_runtime/test_policy.py @@ -114,8 +114,8 @@ def test_retry_after_is_honored_when_it_fits(self) -> None: 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", [(0.6, 0.5), (0.5, None)]) - def test_a_wait_must_fit_in_the_remaining_budget( + @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 ( @@ -135,3 +135,20 @@ def test_non_numbers_are_rejected(self, value: object) -> None: 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 index f3b012344..e5808abdd 100644 --- a/tests/feature_flags_runtime/test_sync_client.py +++ b/tests/feature_flags_runtime/test_sync_client.py @@ -11,7 +11,9 @@ import pytest -from workos import WorkOSClient, create_public_client +import httpx2 + +from workos import HTTPResponse, TransportTimeout, WorkOSClient, create_public_client from workos._errors import ( AuthenticationError, AuthorizationError, @@ -406,6 +408,49 @@ def test_retries_follow_the_clients_max_retries( 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: From 249b5b55eebefbf7ff6fad4a839b443ec381e4ea Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Mon, 2 Nov 2026 18:00:05 -0800 Subject: [PATCH 08/13] fix(feature-flags): Deliver listeners after releasing the refresh lock Both clients called listeners while holding the refresh lock. The async client's asyncio.Lock is not reentrant, so a listener that awaited runtime.refresh() deadlocked the polling task; in the sync client a listener that waited on a refresh from another thread deadlocked the same way. A refresh now queues its notifications while it holds the lock, which keeps them in refresh order, and delivers them after releasing it. One thread (or task) delivers at a time: a batch that arrives while another is being delivered, including one from a listener's own refresh, is delivered next by the same deliverer, so batches never interleave. Listener errors are still logged and isolated. A sync refresh can now return before its notifications run when another thread is already delivering; they then run on that thread. The async polling task now sleeps on an event that a 401 sets, instead of being cancelled mid-delivery when refresh() stops polling. Co-Authored-By: Claude Opus 5.5 --- src/workos/feature_flags/runtime/_async.py | 101 ++++++++++------ src/workos/feature_flags/runtime/_core.py | 7 +- src/workos/feature_flags/runtime/_sync.py | 108 +++++++++++------- .../test_async_client.py | 23 ++++ .../feature_flags_runtime/test_sync_client.py | 35 ++++++ 5 files changed, 197 insertions(+), 77 deletions(-) diff --git a/src/workos/feature_flags/runtime/_async.py b/src/workos/feature_flags/runtime/_async.py index 63ce2feb6..9702ef79d 100644 --- a/src/workos/feature_flags/runtime/_async.py +++ b/src/workos/feature_flags/runtime/_async.py @@ -81,6 +81,9 @@ def __init__( # Neither primitive 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() + self._draining = False if self._status.ready: self._ready.set() if on_change is not None: @@ -255,40 +258,51 @@ async def _poll_forever(self) -> None: delay = _policy.next_delay( self._polling_interval, failures, self._max_retry_delay ) - await asyncio.sleep(max(delay, retry_after or 0.0)) - - async def _refresh(self, *, background: bool) -> None: - async with self._refresh_lock: - if self._closed 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" - ) - await self._deliver( - self._record_failure(timeout, attempted, background=background) - ) - raise timeout from error - except Exception as error: - if self._closed: - raise _Closed() from error - await self._deliver( - self._record_failure(error, attempted, background=background) + await asyncio.wait_for( + self._wake.wait(), timeout=max(delay, retry_after or 0.0) ) - raise + 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 (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() - notifications = self._record_success( - configuration, attempted, background=background + raise _Closed() from error + timeout = WorkOSTimeoutError( + f"Feature flag refresh exceeded its request timeout of {self._request_timeout} seconds" ) - self._ready.set() - await self._deliver(notifications) + 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 @@ -319,14 +333,29 @@ async def _stop_polling(self, error: Exception) -> None: if not self._status.ready: self._readiness_error = error self._ready.set() - task = self._task - if task is not None and task is not asyncio.current_task(): - task.cancel() - await self._deliver(notifications) + self._wake.set() + self._pending.append(notifications) + await self._drain() - async def _deliver(self, notifications: list[Notification]) -> None: - if self._closed: + 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) diff --git a/src/workos/feature_flags/runtime/_core.py b/src/workos/feature_flags/runtime/_core.py index 7c607ac52..6de0f3191 100644 --- a/src/workos/feature_flags/runtime/_core.py +++ b/src/workos/feature_flags/runtime/_core.py @@ -8,9 +8,10 @@ import copy import logging import threading +from collections import deque from dataclasses import replace from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple +from typing import TYPE_CHECKING, Any, Callable, Deque, Optional, Tuple from ..._errors import APIError from . import _policy @@ -82,6 +83,10 @@ def __init__( "status": (), } 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 diff --git a/src/workos/feature_flags/runtime/_sync.py b/src/workos/feature_flags/runtime/_sync.py index aa2435160..8b1ce4dc1 100644 --- a/src/workos/feature_flags/runtime/_sync.py +++ b/src/workos/feature_flags/runtime/_sync.py @@ -102,6 +102,7 @@ def __init__( 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() @@ -295,28 +296,34 @@ def _poll_forever(self) -> None: return def _refresh(self, *, background: bool) -> None: - with self._refresh_lock: - 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._deliver( - self._record_failure(error, attempted, background=background) - ) - raise + 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() - notifications = self._record_success( - configuration, attempted, background=background + raise _Closed() from error + self._pending.append( + self._record_failure(error, attempted, background=background) ) - self._ready.set() - self._deliver(notifications) + 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 @@ -349,28 +356,48 @@ def _stop_polling(self, error: Exception) -> None: self._readiness_error = error self._wake.set() self._ready.set() - self._deliver(notifications) + self._pending.append(notifications) + self._drain() - def _deliver(self, notifications: list[Notification]) -> None: - if self._closed: - return - outer = self._delivering - self._delivering = threading.get_ident() + 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: - 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" - ) - finally: - self._delivering = outer + 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 ---------------------------------------------------------- @@ -398,6 +425,7 @@ def _reinitialize_after_fork(self) -> None: self._listeners_lock = threading.Lock() self._context_warnings.reinitialize_after_fork() self._delivering = None + self._pending.clear() self._thread = None self._init_synchronization() self._fork_restart_pending = ( diff --git a/tests/feature_flags_runtime/test_async_client.py b/tests/feature_flags_runtime/test_async_client.py index 356cb73a8..a31093eb7 100644 --- a/tests/feature_flags_runtime/test_async_client.py +++ b/tests/feature_flags_runtime/test_async_client.py @@ -152,6 +152,29 @@ async def on_change(event: FlagsChangedEvent) -> None: ) +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", [ diff --git a/tests/feature_flags_runtime/test_sync_client.py b/tests/feature_flags_runtime/test_sync_client.py index e5808abdd..846e47a14 100644 --- a/tests/feature_flags_runtime/test_sync_client.py +++ b/tests/feature_flags_runtime/test_sync_client.py @@ -316,6 +316,41 @@ def test_an_identical_bootstrap_reports_no_change( runtime.refresh() assert changes.items == [] + 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, From 946a6f4d508831a682aa0f9a76c8301779255ef9 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Mon, 2 Nov 2026 18:07:20 -0800 Subject: [PATCH 09/13] fix(feature-flags): Poll on private connections with a total time budget Fork safety. A forked child restarted polling but kept the parent's WorkOSClient HTTP backend, so parent and child polled over the same pooled keep-alive connection (one client TCP port for both), and a pool lock held at fork time could hang the child's poller for good. With the SDK's default HTTP client, the sync runtime now polls through an httpx2 client of its own with the same settings, abandons it in a forked child and builds a new one there. A caller-supplied HTTP client cannot be rebuilt, so polling does not resume in the child; neither does it on macOS, where a forked process crashes in system libraries (DNS, proxy settings, the trust store) when it opens a connection. In both cases the client logs a warning, refresh() and start() raise, and evaluation keeps serving the last configuration. An async client in a forked child stops polling the same way. is_ready, configuration and is_closed now resume polling after a fork like every other read. Total timeout. request_timeout was passed to httpx as a per-phase timeout, so a server sending the body slowly could hold a refresh far longer. The sync poll now streams the body, gives each network wait only the time left via timeouts httpcore reads at the start of each phase, and abandons the response once the deadline passes, so a refresh ends within request_timeout plus at most one read. A custom non-httpx HTTPBackend still gets the remaining time per request. The request keeps the client's URL, headers, User-Agent, base_url and max_retries behavior. The async client keeps asyncio.wait_for. The fork test now polls a real local server with distinct API keys for parent and child and fails if they share a connection. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- src/workos/feature_flags/runtime/_async.py | 58 +++- src/workos/feature_flags/runtime/_core.py | 3 + src/workos/feature_flags/runtime/_sync.py | 59 +++- .../feature_flags/runtime/_transport.py | 200 +++++++++++++ tests/feature_flags_runtime/helpers.py | 79 ++++++ tests/feature_flags_runtime/test_fork.py | 264 +++++++++++++++--- tests/feature_flags_runtime/test_transport.py | 91 ++++++ 8 files changed, 698 insertions(+), 60 deletions(-) create mode 100644 src/workos/feature_flags/runtime/_transport.py create mode 100644 tests/feature_flags_runtime/test_transport.py diff --git a/README.md b/README.md index b840fd8b4..d91cc44b9 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ With `AsyncWorkOSClient`, polling runs in an asyncio task: `await flags.start()` **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, 5xx, timeouts, connection errors) are also retried within one refresh, up to the client's `max_retries` and within `request_timeout`. 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()`. +**Failures.** A failed poll keeps the last good configuration and retries with exponential backoff. Transient failures (429, 5xx, 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. @@ -279,7 +279,7 @@ flags = workos.create_feature_flags_runtime_client( **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.** Close the runtime client before the `WorkOSClient` it polls through. Polling resumes in forked workers (for example gunicorn with `--preload`) the first time the client is used after the fork. Background threads must be enabled, so run uWSGI with `--enable-threads`. +**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. In a forked worker (for example gunicorn with `--preload`), polling resumes on new connections the first time the runtime client is used. It does not resume if the `WorkOSClient` uses an HTTP client you supplied, whose connections the worker would share with its parent, or on macOS, where a forked process cannot safely open network connections; the client then logs a warning and keeps serving its last configuration, so create it after forking instead. An async client never polls in a forked process. Background threads must be enabled, so run uWSGI with `--enable-threads`. ## Type Safety diff --git a/src/workos/feature_flags/runtime/_async.py b/src/workos/feature_flags/runtime/_async.py index 9702ef79d..67492ca24 100644 --- a/src/workos/feature_flags/runtime/_async.py +++ b/src/workos/feature_flags/runtime/_async.py @@ -7,7 +7,9 @@ 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 @@ -84,6 +86,9 @@ def __init__( # Set when polling stops after a 401, to end the polling task's sleep. self._wake = asyncio.Event() self._draining = False + # Set in a forked child; see _mark_forked. + self._forked = False + self._fork_warned = False if self._status.ready: self._ready.set() if on_change is not None: @@ -92,6 +97,7 @@ def __init__( self.add_error_listener(on_error) if on_status is not None: self.add_status_listener(on_status) + _clients.add(self) # --- Lifecycle ------------------------------------------------------------ @@ -103,12 +109,14 @@ async def start(self) -> None: polling permanently; the last good configuration stays usable. Raises: - RuntimeError: If the client is closed or no event loop is running. + RuntimeError: If the client is closed, no event loop is running, or + this is a forked copy of the client. """ if self._closed: raise RuntimeError( "This feature flags runtime client is closed; create a new one" ) + self._raise_if_forked() if self._started: return loop = asyncio.get_running_loop() @@ -154,12 +162,13 @@ async def refresh(self) -> None: Raises: WorkOSError: If the request fails or the payload cannot be read. - RuntimeError: If the client is closed. + RuntimeError: If the client is closed or is a forked copy. """ if self._closed: raise RuntimeError( "This feature flags runtime client is closed; create a new one" ) + self._raise_if_forked() try: await self._refresh(background=False) except _Closed: @@ -273,7 +282,11 @@ async def _refresh(self, *, background: bool) -> None: await self._drain() async def _refresh_locked(self, *, background: bool) -> None: - if self._closed or (background and self._status.polling_stopped): + if ( + self._closed + or self._forked + or (background and self._status.polling_stopped) + ): raise _Closed() attempted = utc_now() try: @@ -363,3 +376,42 @@ async def _deliver(self, notifications: list[Notification]) -> None: await result except Exception: self._log_listener_error() + + # --- Fork safety ---------------------------------------------------------- + + def _mark_forked(self) -> None: + # An event loop, its tasks and the AsyncWorkOSClient's connections all + # belong to the parent. The copy in a forked child stops polling, even + # if the child keeps running a copy of the loop, and keeps serving its + # last configuration. + self._forked = True + + def _before_use(self) -> None: + if self._forked and not self._fork_warned: + self._fork_warned = True + self._logger.warning( + "%s Evaluation keeps serving the last configuration.", + _FORKED_MESSAGE.format(pid=os.getpid()), + ) + + def _raise_if_forked(self) -> None: + if self._forked: + raise RuntimeError(_FORKED_MESSAGE.format(pid=os.getpid())) + + +_FORKED_MESSAGE = ( + "Feature flag polling does not run in forked process {pid}: this " + "AsyncFeatureFlagsRuntimeClient was created in the parent process, whose event " + "loop and HTTP connections it uses. Create a new one after forking instead." +) + +_clients: weakref.WeakSet[AsyncFeatureFlagsRuntimeClient] = weakref.WeakSet() + + +def _mark_clients_forked() -> None: + for client in list(_clients): + client._mark_forked() + + +if hasattr(os, "register_at_fork"): + os.register_at_fork(after_in_child=_mark_clients_forked) diff --git a/src/workos/feature_flags/runtime/_core.py b/src/workos/feature_flags/runtime/_core.py index 6de0f3191..ae6761653 100644 --- a/src/workos/feature_flags/runtime/_core.py +++ b/src/workos/feature_flags/runtime/_core.py @@ -157,16 +157,19 @@ def status(self) -> RuntimeStatus: @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 ------------------------------------------------------------ diff --git a/src/workos/feature_flags/runtime/_sync.py b/src/workos/feature_flags/runtime/_sync.py index 8b1ce4dc1..140c25fcc 100644 --- a/src/workos/feature_flags/runtime/_sync.py +++ b/src/workos/feature_flags/runtime/_sync.py @@ -17,6 +17,7 @@ from ._configuration import FeatureFlagsConfiguration from ._context import LoggerLike from ._core import Notification, RuntimeClientCore, utc_now +from ._transport import PollTransport from ._types import FlagsChangedEvent, RuntimeStatus if TYPE_CHECKING: @@ -39,7 +40,12 @@ class FeatureFlagsRuntimeClient(RuntimeClientCore): flags.wait_until_ready(timeout=5) flags.is_enabled("new-checkout", {"organization": "org_123"}) - Close this client before closing the ``WorkOSClient`` it polls through. + 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, and create it after + forking, since polling does not resume in a forked child. Polling does not + resume in a forked child on macOS either. """ def __init__( @@ -61,7 +67,11 @@ def __init__( 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. + 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 @@ -86,10 +96,14 @@ def __init__( logger=logger, ) self._client = client + self._transport = PollTransport(client) self._started = False 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) @@ -126,11 +140,13 @@ def start(self) -> None: Raises: RuntimeError: If the client is closed. """ + 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 @@ -181,6 +197,7 @@ def refresh(self) -> None: 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: @@ -195,7 +212,8 @@ def refresh(self) -> None: def close(self) -> None: """Stop polling and release the background thread. Idempotent. - Waits up to ``request_timeout`` for an in-flight request to finish. + Waits up to ``request_timeout`` for an in-flight request to finish, + then closes the HTTP client polling used, if the runtime created it. Evaluation keeps serving the last configuration after closing. """ with self._lifecycle_lock: @@ -213,6 +231,7 @@ def close(self) -> None: and thread.is_alive() ): thread.join(self._request_timeout + 1.0) + self._transport.close() def __enter__(self) -> FeatureFlagsRuntimeClient: self.start() @@ -329,14 +348,8 @@ def _fetch(self) -> Any: deadline = time.monotonic() + self._request_timeout attempt = 0 while True: - remaining = max(deadline - time.monotonic(), 0.001) try: - return self._client.request( - "get", - _policy.POLL_PATH, - params=dict(_policy.POLL_PARAMS), - request_options={"timeout": remaining, "max_retries": 0}, - ) + return self._transport.get(deadline) except Exception as error: delay = _policy.attempt_retry_delay( error, attempt, deadline - time.monotonic(), self._max_retries @@ -404,6 +417,22 @@ def _deliver(self, notifications: list[Notification]) -> None: 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.", + self._fork_blocked_message(), + ) + + def _raise_if_fork_blocked(self) -> None: + if self._fork_blocker is not None: + raise RuntimeError(self._fork_blocked_message()) + + def _fork_blocked_message(self) -> str: + return ( + f"Feature flag polling does not run in forked process {os.getpid()}: " + f"{self._fork_blocker}. Create the runtime client after forking instead." + ) def _restart_after_fork(self) -> None: with self._lifecycle_lock: @@ -419,8 +448,10 @@ def _restart_after_fork(self) -> None: def _reinitialize_after_fork(self) -> None: # The child has only the forking thread. Locks may have been held by - # threads that no longer exist, and the poller thread is gone, so - # rebuild them and restart polling on the next use of this client. + # 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._transport.reinitialize_after_fork() self._status_lock = threading.Lock() self._listeners_lock = threading.Lock() self._context_warnings.reinitialize_after_fork() @@ -428,9 +459,11 @@ def _reinitialize_after_fork(self) -> None: self._pending.clear() self._thread = None self._init_synchronization() - self._fork_restart_pending = ( + 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._fork_restart_pending = polling and self._fork_blocker is None def _discard_awaitable(awaitable: object) -> None: diff --git a/src/workos/feature_flags/runtime/_transport.py b/src/workos/feature_flags/runtime/_transport.py new file mode 100644 index 000000000..12567afa0 --- /dev/null +++ b/src/workos/feature_flags/runtime/_transport.py @@ -0,0 +1,200 @@ +# @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 sys +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 _policy + +if TYPE_CHECKING: + from ..._base_client import WorkOSClient + +_PHASES = ("connect", "read", "write", "pool") +_MINIMUM_WAIT = 0.001 + +# On macOS, a forked process that has not exec'd can crash inside system +# libraries (DNS resolution, proxy settings, the system trust store) as soon +# as it opens a connection, so polling never resumes there after a fork. +_FORK_SAFE_PLATFORM = sys.platform != "darwin" + + +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, and macOS cannot open connections safely from a forked + process, so in those cases polling does not resume in a forked child. + """ + + 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._lock = threading.Lock() + + def fork_blocker(self) -> Optional[str]: + """Why a forked child cannot poll safely, or ``None`` if it can.""" + if not self._uses_default_client: + return ( + "the WorkOSClient uses a caller-supplied HTTP client, whose " + "connections this process would share with its parent" + ) + if not _FORK_SAFE_PLATFORM: + return ( + "on macOS, a forked process that has not exec'd cannot safely open " + "network connections" + ) + 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: + 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") + 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: + 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/tests/feature_flags_runtime/helpers.py b/tests/feature_flags_runtime/helpers.py index 9c6c70c20..c106ff3f2 100644 --- a/tests/feature_flags_runtime/helpers.py +++ b/tests/feature_flags_runtime/helpers.py @@ -228,3 +228,82 @@ def eventually(predicate: Callable[[], bool], timeout: float = 5.0) -> None: 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_fork.py b/tests/feature_flags_runtime/test_fork.py index 68d369108..574fa7466 100644 --- a/tests/feature_flags_runtime/test_fork.py +++ b/tests/feature_flags_runtime/test_fork.py @@ -1,5 +1,8 @@ # @oagen-ignore-file -"""A forked worker process resumes polling with its own thread.""" +"""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 @@ -11,62 +14,239 @@ import pytest +from workos import WorkOSClient +from workos.feature_flags.runtime import _transport + +from tests.feature_flags_runtime.helpers import LocalHTTPServer, flag, payload + ROOT = Path(__file__).resolve().parents[2] -SCRIPT = textwrap.dedent( - """ - import os - import threading - import time - import warnings +PRELUDE = """ +import asyncio +import os +import time +import warnings - from workos import WorkOSClient - from workos.feature_flags.runtime import _policy - from tests.feature_flags_runtime.helpers import FlagServer, SyncBackend, flag, payload +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.01 - server = FlagServer() - server.respond(200, payload(f=flag("f", default_value=True))) +_policy.next_delay = lambda interval, failures, max_retry_delay: 0.05 +server = LocalHTTPServer(payload(f=flag("f", default_value=True))) - def reset_server_lock(): - # The parent's poller may hold the fake server's lock at fork time. - server._condition = threading.Condition() - os.register_at_fork(after_in_child=reset_server_lock) - workos = WorkOSClient(api_key="sk_test", base_url="https://api.workos.test", http_client=SyncBackend(server)) - runtime = workos.create_feature_flags_runtime_client() - runtime.start() - runtime.wait_until_ready(timeout=5) - server.wait_for_requests(2) +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) - pid = os.fork() - if pid == 0: - before = len(server.requests) - time.sleep(0.1) - idle = len(server.requests) == before - enabled = runtime.is_enabled("f") - deadline = time.monotonic() + 5 - while len(server.requests) < before + 3 and time.monotonic() < deadline: - time.sleep(0.01) - resumed = len(server.requests) >= before + 3 - runtime.close() - os._exit(0 if idle and enabled and resumed else 1) + return os.fork() + + +def finish(pid): _, status = os.waitpid(pid, 0) - runtime.close() 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 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 "created in the parent process" in str(error), error + 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" +""" -@pytest.mark.skipif(not hasattr(os, "fork"), reason="requires os.fork") -def test_a_forked_child_restarts_polling_on_first_use() -> None: - result = subprocess.run( - [sys.executable, "-c", SCRIPT], +# On macOS polling never resumes in a forked child, so to check the +# connection handling that runs elsewhere, this pretends the platform is +# fork-safe. NO_PROXY keeps httpx2 from reading macOS proxy settings, which is +# what crashes there. +FORCE_FORK_SAFE = """ +from workos.feature_flags.runtime import _transport + +_transport._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, ) - assert result.returncode == 0, result.stderr + + +pytestmark = pytest.mark.skipif(not hasattr(os, "fork"), reason="requires os.fork") + + +def test_a_child_polls_on_its_own_connections_once_used() -> None: + parts = ( + (FORCE_FORK_SAFE, DEFAULT_CLIENT) + if sys.platform == "darwin" + else (DEFAULT_CLIENT,) + ) + result = run(*parts) + 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(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(MAKE_ASYNC_RUNTIME, ASYNC_CLIENT) + assert result.returncode == 0, result.stdout + result.stderr + + +@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(_transport, "_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_transport.py b/tests/feature_flags_runtime/test_transport.py new file mode 100644 index 000000000..6fe7f305d --- /dev/null +++ b/tests/feature_flags_runtime/test_transport.py @@ -0,0 +1,91 @@ +# @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_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() From d1e2c1914ba9028a3b823cf2dd941fa90f6b7e67 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Tue, 3 Nov 2026 09:40:30 -0800 Subject: [PATCH 10/13] fix(feature-flags): Let clients that never polled start in a forked child A runtime client created before a fork but never started, as in an app gunicorn preloads that starts the client in each worker, could not start in the child: an async client always raised there, and so did a sync client with a caller-supplied HTTP client. A child now refuses to poll only when the client polled in the parent, through a caller-supplied HTTP client or, for an async client, on the parent's event loop and connections, and on macOS. A client that never polled no longer warns on first use in the child; start() and refresh() raise instead. On macOS, once a process has created an HTTP client, a child it forks crashes when it opens a new connection, and every WorkOSClient has one. The messages and README now say to create the WorkOSClient, not only the runtime client, after forking. close() waits up to request_timeout for an in-flight poll and abandons one that runs longer. Its docstring now says so, and the transport no longer opens a new HTTP client once closed, so an abandoned poll cannot leave one behind. The change listener docstring no longer says listeners run on the refreshing thread. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/workos/feature_flags/runtime/_async.py | 96 ++++++++++++------- src/workos/feature_flags/runtime/_core.py | 32 +++++++ src/workos/feature_flags/runtime/_sync.py | 55 ++++++----- .../feature_flags/runtime/_transport.py | 33 +++---- tests/feature_flags_runtime/test_fork.py | 96 +++++++++++++++---- tests/feature_flags_runtime/test_transport.py | 13 +++ 7 files changed, 230 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index d91cc44b9..7b8dc9f2b 100644 --- a/README.md +++ b/README.md @@ -279,7 +279,7 @@ flags = workos.create_feature_flags_runtime_client( **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. In a forked worker (for example gunicorn with `--preload`), polling resumes on new connections the first time the runtime client is used. It does not resume if the `WorkOSClient` uses an HTTP client you supplied, whose connections the worker would share with its parent, or on macOS, where a forked process cannot safely open network connections; the client then logs a warning and keeps serving its last configuration, so create it after forking instead. An async client never polls in a forked process. Background threads must be enabled, so run uWSGI with `--enable-threads`. +**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. A client that polled in the parent through an HTTP client you supplied, or any async client that polled in the parent, does not poll in the worker: it logs a warning, keeps serving its last configuration, and raises from `start()` and `refresh()`. 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 diff --git a/src/workos/feature_flags/runtime/_async.py b/src/workos/feature_flags/runtime/_async.py index 67492ca24..08cad60a6 100644 --- a/src/workos/feature_flags/runtime/_async.py +++ b/src/workos/feature_flags/runtime/_async.py @@ -14,10 +14,10 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Type, Union from ..._errors import WorkOSTimeoutError -from . import _policy +from . import _core, _policy from ._configuration import FeatureFlagsConfiguration from ._context import LoggerLike -from ._core import Notification, RuntimeClientCore, utc_now +from ._core import Notification, RuntimeClientCore, fork_blocked_message, utc_now from ._types import FlagsChangedEvent, RuntimeStatus if TYPE_CHECKING: @@ -46,6 +46,8 @@ class AsyncFeatureFlagsRuntimeClient(RuntimeClientCore): 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 it already + polled in the parent, or the platform is macOS. """ def __init__( @@ -78,19 +80,12 @@ def __init__( logger=logger, ) self._client = client - self._started = False self._task: Optional[asyncio.Task[None]] = None - # Neither primitive 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() self._draining = False - # Set in a forked child; see _mark_forked. - self._forked = False + # Why polling cannot run in this forked child, if it cannot. + self._fork_blocker: Optional[str] = None self._fork_warned = False - if self._status.ready: - self._ready.set() + self._init_synchronization() if on_change is not None: self.add_change_listener(on_change) if on_error is not None: @@ -99,6 +94,15 @@ def __init__( 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: @@ -110,13 +114,13 @@ async def start(self) -> None: Raises: RuntimeError: If the client is closed, no event loop is running, or - this is a forked copy of the client. + 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_forked() + self._raise_if_fork_blocked() if self._started: return loop = asyncio.get_running_loop() @@ -162,13 +166,14 @@ async def refresh(self) -> None: Raises: WorkOSError: If the request fails or the payload cannot be read. - RuntimeError: If the client is closed or is a forked copy. + 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_forked() + self._raise_if_fork_blocked() try: await self._refresh(background=False) except _Closed: @@ -284,7 +289,7 @@ async def _refresh(self, *, background: bool) -> None: async def _refresh_locked(self, *, background: bool) -> None: if ( self._closed - or self._forked + or self._fork_blocker is not None or (background and self._status.polling_stopped) ): raise _Closed() @@ -379,39 +384,56 @@ async def _deliver(self, notifications: list[Notification]) -> None: # --- Fork safety ---------------------------------------------------------- - def _mark_forked(self) -> None: - # An event loop, its tasks and the AsyncWorkOSClient's connections all - # belong to the parent. The copy in a forked child stops polling, even + def _reinitialize_after_fork(self) -> None: + # A client that polled in the parent used the parent's event loop and + # the AsyncWorkOSClient's connections. Its copy here stops polling, even # if the child keeps running a copy of the loop, and keeps serving its - # last configuration. - self._forked = True + # last configuration. A client that never polled starts over on the + # child's own event loop. + self._reinitialize_core_after_fork() + polling = ( + self._started and not self._closed and not self._status.polling_stopped + ) + 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(): + self._fork_blocker = ( + "it polled in the parent, on the parent's event loop and the " + "AsyncWorkOSClient's connections. Create the AsyncWorkOSClient and " + "the runtime client after forking instead" + ) + else: + self._fork_blocker = None + self._init_synchronization() + # 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._forked and not self._fork_warned: + 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.", - _FORKED_MESSAGE.format(pid=os.getpid()), + "%s. Evaluation keeps serving the last configuration.", + fork_blocked_message(self._fork_blocker), ) - def _raise_if_forked(self) -> None: - if self._forked: - raise RuntimeError(_FORKED_MESSAGE.format(pid=os.getpid())) - + def _raise_if_fork_blocked(self) -> None: + if self._fork_blocker is not None: + raise RuntimeError(fork_blocked_message(self._fork_blocker)) -_FORKED_MESSAGE = ( - "Feature flag polling does not run in forked process {pid}: this " - "AsyncFeatureFlagsRuntimeClient was created in the parent process, whose event " - "loop and HTTP connections it uses. Create a new one after forking instead." -) _clients: weakref.WeakSet[AsyncFeatureFlagsRuntimeClient] = weakref.WeakSet() -def _mark_clients_forked() -> None: +def _reinitialize_clients_after_fork() -> None: for client in list(_clients): - client._mark_forked() + client._reinitialize_after_fork() if hasattr(os, "register_at_fork"): - os.register_at_fork(after_in_child=_mark_clients_forked) + os.register_at_fork(after_in_child=_reinitialize_clients_after_fork) diff --git a/src/workos/feature_flags/runtime/_core.py b/src/workos/feature_flags/runtime/_core.py index ae6761653..ddd1ea6c5 100644 --- a/src/workos/feature_flags/runtime/_core.py +++ b/src/workos/feature_flags/runtime/_core.py @@ -7,6 +7,8 @@ import copy import logging +import os +import sys import threading from collections import deque from dataclasses import replace @@ -33,6 +35,23 @@ 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) @@ -82,6 +101,7 @@ def __init__( "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 @@ -303,6 +323,18 @@ def _readiness_failure(self) -> Exception: 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") diff --git a/src/workos/feature_flags/runtime/_sync.py b/src/workos/feature_flags/runtime/_sync.py index 140c25fcc..b2a4e1cc5 100644 --- a/src/workos/feature_flags/runtime/_sync.py +++ b/src/workos/feature_flags/runtime/_sync.py @@ -16,7 +16,7 @@ from . import _policy from ._configuration import FeatureFlagsConfiguration from ._context import LoggerLike -from ._core import Notification, RuntimeClientCore, utc_now +from ._core import Notification, RuntimeClientCore, fork_blocked_message, utc_now from ._transport import PollTransport from ._types import FlagsChangedEvent, RuntimeStatus @@ -43,9 +43,10 @@ class FeatureFlagsRuntimeClient(RuntimeClientCore): 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, and create it after - forking, since polling does not resume in a forked child. Polling does not - resume in a forked child on macOS either. + 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 it already polled through, + and on macOS. """ def __init__( @@ -97,7 +98,6 @@ def __init__( ) self._client = client self._transport = PollTransport(client) - self._started = False self._thread: Optional[threading.Thread] = None self._delivering: Optional[int] = None self._fork_restart_pending = False @@ -138,7 +138,8 @@ def start(self) -> None: polling permanently; the last good configuration stays usable. Raises: - RuntimeError: If the client is closed. + RuntimeError: If the client is closed or cannot poll in this forked + process. """ self._before_use() with self._lifecycle_lock: @@ -190,7 +191,8 @@ def refresh(self) -> None: Raises: WorkOSError: If the request fails or the payload cannot be read. - RuntimeError: If the client is closed. + RuntimeError: If the client is closed or cannot poll in this forked + process. """ self._before_use() if self._closed: @@ -212,9 +214,11 @@ def refresh(self) -> None: def close(self) -> None: """Stop polling and release the background thread. Idempotent. - Waits up to ``request_timeout`` for an in-flight request to finish, - then closes the HTTP client polling used, if the runtime created it. - Evaluation keeps serving the last configuration after closing. + 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: @@ -255,8 +259,9 @@ def add_change_listener( 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 on the refreshing thread, one at a time in refresh order; - their exceptions are logged and never affect polling. + 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) @@ -420,19 +425,13 @@ def _before_use(self) -> None: 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.", - self._fork_blocked_message(), + "%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(self._fork_blocked_message()) - - def _fork_blocked_message(self) -> str: - return ( - f"Feature flag polling does not run in forked process {os.getpid()}: " - f"{self._fork_blocker}. Create the runtime client after forking instead." - ) + raise RuntimeError(fork_blocked_message(self._fork_blocker)) def _restart_after_fork(self) -> None: with self._lifecycle_lock: @@ -451,19 +450,23 @@ def _reinitialize_after_fork(self) -> None: # 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._status_lock = threading.Lock() - self._listeners_lock = threading.Lock() - self._context_warnings.reinitialize_after_fork() self._delivering = None - self._pending.clear() 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._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: diff --git a/src/workos/feature_flags/runtime/_transport.py b/src/workos/feature_flags/runtime/_transport.py index 12567afa0..9dfa8f986 100644 --- a/src/workos/feature_flags/runtime/_transport.py +++ b/src/workos/feature_flags/runtime/_transport.py @@ -5,7 +5,6 @@ from __future__ import annotations -import sys import threading import time from typing import TYPE_CHECKING, Any, Dict, Optional @@ -22,7 +21,7 @@ TransportTimeout, _HttpxFamily, ) -from . import _policy +from . import _core, _policy if TYPE_CHECKING: from ..._base_client import WorkOSClient @@ -30,11 +29,6 @@ _PHASES = ("connect", "read", "write", "pool") _MINIMUM_WAIT = 0.001 -# On macOS, a forked process that has not exec'd can crash inside system -# libraries (DNS resolution, proxy settings, the system trust store) as soon -# as it opens a connection, so polling never resumes there after a fork. -_FORK_SAFE_PLATFORM = sys.platform != "darwin" - class _DeadlineTimeouts(Dict[str, Optional[float]]): """httpx timeouts that shrink to the time left before a deadline. @@ -74,8 +68,8 @@ class PollTransport: - 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, and macOS cannot open connections safely from a forked - process, so in those cases polling does not resume in a forked child. + rebuilt that way, so a child does not poll through one the parent polled + through. On macOS, a child does not poll at all. """ def __init__(self, workos: WorkOSClient) -> None: @@ -88,19 +82,21 @@ def __init__(self, workos: WorkOSClient) -> None: 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) -> Optional[str]: + def fork_blocker(self, used_before_fork: bool) -> Optional[str]: """Why a forked child cannot poll safely, or ``None`` if it can.""" - if not self._uses_default_client: + if not _core.FORK_SAFE_PLATFORM: return ( - "the WorkOSClient uses a caller-supplied HTTP client, whose " - "connections this process would share with its parent" + f"{_core.MACOS_FORK_HAZARD}. Create the WorkOSClient and the " + "runtime client after forking instead" ) - if not _FORK_SAFE_PLATFORM: + if used_before_fork and not self._uses_default_client: return ( - "on macOS, a forked process that has not exec'd cannot safely open " - "network connections" + "it polled in the parent through a 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 @@ -113,6 +109,7 @@ def reinitialize_after_fork(self) -> None: def close(self) -> None: with self._lock: + self._closed = True owned, self._owned = self._owned, None if owned is not None: owned.close() @@ -161,6 +158,10 @@ def _httpx_client(self) -> Any: 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( diff --git a/tests/feature_flags_runtime/test_fork.py b/tests/feature_flags_runtime/test_fork.py index 574fa7466..014fe59f8 100644 --- a/tests/feature_flags_runtime/test_fork.py +++ b/tests/feature_flags_runtime/test_fork.py @@ -6,6 +6,7 @@ from __future__ import annotations +import logging import os import subprocess import sys @@ -15,7 +16,7 @@ import pytest from workos import WorkOSClient -from workos.feature_flags.runtime import _transport +from workos.feature_flags.runtime import _core from tests.feature_flags_runtime.helpers import LocalHTTPServer, flag, payload @@ -135,7 +136,7 @@ def call(coroutine): asyncio.run(getattr(runtime, method)()) os._exit(2) except RuntimeError as error: - assert "created in the parent process" in str(error), error + assert "it polled in the parent" in str(error), error time.sleep(0.5) os._exit(0) _, status = os.waitpid(pid, 0) @@ -170,14 +171,53 @@ async def _make_runtime(workos): assert server.ports("Bearer sk_child") == set(), "the child polled" """ -# On macOS polling never resumes in a forked child, so to check the -# connection handling that runs elsewhere, this pretends the platform is -# fork-safe. NO_PROXY keeps httpx2 from reading macOS proxy settings, which is -# what crashes there. +# 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" +""" + +# 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 _transport +from workos.feature_flags.runtime import _core -_transport._FORK_SAFE_PLATFORM = True +_core.FORK_SAFE_PLATFORM = True """ @@ -197,12 +237,7 @@ def run(*scenario: str) -> subprocess.CompletedProcess: def test_a_child_polls_on_its_own_connections_once_used() -> None: - parts = ( - (FORCE_FORK_SAFE, DEFAULT_CLIENT) - if sys.platform == "darwin" - else (DEFAULT_CLIENT,) - ) - result = run(*parts) + result = run(FORCE_FORK_SAFE, DEFAULT_CLIENT) assert result.returncode == 0, result.stdout + result.stderr @@ -214,23 +249,50 @@ def test_on_macos_a_child_does_not_poll() -> None: def test_a_child_does_not_poll_through_a_caller_supplied_client() -> None: - result = run(CALLER_CLIENT) + 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(MAKE_ASYNC_RUNTIME, ASYNC_CLIENT) + 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 + + +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(_transport, "_FORK_SAFE_PLATFORM", True) + 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() diff --git a/tests/feature_flags_runtime/test_transport.py b/tests/feature_flags_runtime/test_transport.py index 6fe7f305d..af0555f44 100644 --- a/tests/feature_flags_runtime/test_transport.py +++ b/tests/feature_flags_runtime/test_transport.py @@ -47,6 +47,19 @@ def test_sends_the_sdk_url_and_headers_and_closes_its_client( 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( From d25aabc2b9c51fb76ac98c21ffccde03d7d24080 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Tue, 3 Nov 2026 10:41:35 -0800 Subject: [PATCH 11/13] docs(feature-flags): Correct the runtime client README on HTTP, retries and forks - With the SDK's default HTTP client, the sync runtime client polls through an HTTP client of its own, so the intro now says it uses the WorkOSClient's API key, base URL and retry settings rather than its HTTP client. - Only 429, 500, 502, 503 and 504 are retried, not every 5xx. - A client blocked in a forked worker warns only if it was polling in the parent; one that only called refresh() there just raises from start() and refresh(). Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7b8dc9f2b..6a2288b45 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,7 @@ result = client.organizations.list_organizations( ## 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) through the client's API key, base URL and HTTP client. Create one long-lived instance per process and share it across requests. It is for trusted server-side code only. +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 @@ -262,7 +262,7 @@ With `AsyncWorkOSClient`, polling runs in an asyncio task: `await flags.start()` **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, 5xx, 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()`. +**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. @@ -279,7 +279,7 @@ flags = workos.create_feature_flags_runtime_client( **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. A client that polled in the parent through an HTTP client you supplied, or any async client that polled in the parent, does not poll in the worker: it logs a warning, keeps serving its last configuration, and raises from `start()` and `refresh()`. 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`. +**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. A client that already sent requests in the parent through an HTTP client you supplied, or an async client that did, 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. 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 From 41c498c219d33a05729cc0e5b2ff810fcb4e0e73 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Tue, 3 Nov 2026 17:00:20 -0800 Subject: [PATCH 12/13] fix(feature-flags): Keep forked workers off the parent's connections and loop A forked async client kept the parent's polling task, so close() in the worker cancelled it and then waited on the parent's event loop, which does not run there, and never returned. The fork reset now drops that task and recreates the client's asyncio primitives. A runtime client that never polled in the parent started in the worker even when the WorkOSClient had already sent other requests through the HTTP client it would poll through: a caller-supplied client's connections, or the AsyncWorkOSClient's connections on the parent's loop. The base clients now record whether they have sent a request, and the worker refuses to poll in that case too. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/workos/_base_client.py | 8 ++ src/workos/feature_flags/runtime/_async.py | 29 ++++--- src/workos/feature_flags/runtime/_sync.py | 4 +- .../feature_flags/runtime/_transport.py | 18 +++-- tests/feature_flags_runtime/test_fork.py | 75 ++++++++++++++++++- 6 files changed, 114 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 6a2288b45..ed37d8a91 100644 --- a/README.md +++ b/README.md @@ -279,7 +279,7 @@ flags = workos.create_feature_flags_runtime_client( **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. A client that already sent requests in the parent through an HTTP client you supplied, or an async client that did, 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. 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`. +**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 diff --git a/src/workos/_base_client.py b/src/workos/_base_client.py index ec98fc43d..1f77c2e4d 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/feature_flags/runtime/_async.py b/src/workos/feature_flags/runtime/_async.py index 08cad60a6..86d5f7692 100644 --- a/src/workos/feature_flags/runtime/_async.py +++ b/src/workos/feature_flags/runtime/_async.py @@ -46,8 +46,9 @@ class AsyncFeatureFlagsRuntimeClient(RuntimeClientCore): 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 it already - polled in the parent, or the platform is macOS. + 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__( @@ -385,15 +386,20 @@ async def _deliver(self, notifications: list[Notification]) -> None: # --- Fork safety ---------------------------------------------------------- def _reinitialize_after_fork(self) -> None: - # A client that polled in the parent used the parent's event loop and - # the AsyncWorkOSClient's connections. Its copy here stops polling, even - # if the child keeps running a copy of the loop, and keeps serving its - # last configuration. A client that never polled starts over on the - # child's own event loop. + # 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: @@ -401,15 +407,14 @@ def _reinitialize_after_fork(self) -> None: f"{_core.MACOS_FORK_HAZARD}. Create the AsyncWorkOSClient and the " "runtime client after forking instead" ) - elif self._used_before_fork(): + elif self._used_before_fork() or self._client._sent_requests: self._fork_blocker = ( - "it polled in the parent, on the parent's event loop and the " - "AsyncWorkOSClient's connections. Create the AsyncWorkOSClient and " - "the runtime client after forking instead" + "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 - self._init_synchronization() # 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 diff --git a/src/workos/feature_flags/runtime/_sync.py b/src/workos/feature_flags/runtime/_sync.py index b2a4e1cc5..dd0c16ffe 100644 --- a/src/workos/feature_flags/runtime/_sync.py +++ b/src/workos/feature_flags/runtime/_sync.py @@ -45,8 +45,8 @@ class FeatureFlagsRuntimeClient(RuntimeClientCore): 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 it already polled through, - and on macOS. + except through an HTTP client you supplied that the parent already sent + requests through, and on macOS. """ def __init__( diff --git a/src/workos/feature_flags/runtime/_transport.py b/src/workos/feature_flags/runtime/_transport.py index 9dfa8f986..1004ba89b 100644 --- a/src/workos/feature_flags/runtime/_transport.py +++ b/src/workos/feature_flags/runtime/_transport.py @@ -68,8 +68,8 @@ class PollTransport: - 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 polled - through. On macOS, a child does not poll at all. + 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: @@ -92,11 +92,15 @@ def fork_blocker(self, used_before_fork: bool) -> Optional[str]: f"{_core.MACOS_FORK_HAZARD}. Create the WorkOSClient and the " "runtime client after forking instead" ) - if used_before_fork and not self._uses_default_client: + # 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 ( - "it polled in the parent through a caller-supplied HTTP client, " - "whose connections this process would share. Create that HTTP " - "client, the WorkOSClient and the runtime client after forking instead" + "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 @@ -134,6 +138,8 @@ def get(self, deadline: float) -> Any: 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( diff --git a/tests/feature_flags_runtime/test_fork.py b/tests/feature_flags_runtime/test_fork.py index 014fe59f8..f92e364ac 100644 --- a/tests/feature_flags_runtime/test_fork.py +++ b/tests/feature_flags_runtime/test_fork.py @@ -113,6 +113,7 @@ def finish(pid): # 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() @@ -136,7 +137,13 @@ def call(coroutine): asyncio.run(getattr(runtime, method)()) os._exit(2) except RuntimeError as error: - assert "it polled in the parent" in str(error), 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) @@ -210,6 +217,60 @@ async def serve(): 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 @@ -269,6 +330,18 @@ def test_a_client_that_never_polled_starts_in_a_child(scenario: str) -> None: 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: From 44258f4f71f4d765c072ce8fe3709d33e8c2565c Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Tue, 3 Nov 2026 17:00:20 -0800 Subject: [PATCH 13/13] fix(feature-flags): Notify change listeners of changes outside the flags A refresh that changed only fields outside the flags map installed a new snapshot without calling on_change, so a cache persisted from change events kept the old payload. Listeners now hear about every snapshot a refresh installs; changed_flags is empty when no flag changed. Co-Authored-By: Claude Opus 5.5 --- src/workos/feature_flags/runtime/_core.py | 7 +++++-- src/workos/feature_flags/runtime/_types.py | 5 ++++- tests/feature_flags_runtime/test_sync_client.py | 14 ++++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/workos/feature_flags/runtime/_core.py b/src/workos/feature_flags/runtime/_core.py index ddd1ea6c5..194971183 100644 --- a/src/workos/feature_flags/runtime/_core.py +++ b/src/workos/feature_flags/runtime/_core.py @@ -237,8 +237,11 @@ def _record_success( "Feature flag refresh succeeded (%d flags)", len(configuration) ) notifications = self._notifications("status", current) - changed = changed_flags(previous.configuration, configuration) - if previous.configuration is None or changed: + # 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 diff --git a/src/workos/feature_flags/runtime/_types.py b/src/workos/feature_flags/runtime/_types.py index 33d19d084..4b068a9da 100644 --- a/src/workos/feature_flags/runtime/_types.py +++ b/src/workos/feature_flags/runtime/_types.py @@ -24,7 +24,10 @@ class FlagsChangedEvent: 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.""" + """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) diff --git a/tests/feature_flags_runtime/test_sync_client.py b/tests/feature_flags_runtime/test_sync_client.py index 846e47a14..1a04d071a 100644 --- a/tests/feature_flags_runtime/test_sync_client.py +++ b/tests/feature_flags_runtime/test_sync_client.py @@ -316,6 +316,20 @@ def test_an_identical_bootstrap_reports_no_change( 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: