Skip to content

Conform to the OpenFeature provider contract; require growthbook 3.x - #19

Open
madhuchavva wants to merge 6 commits into
mainfrom
fix/openfeature-lifecycle-and-parity
Open

madhuchavva wants to merge 6 commits into
mainfrom
fix/openfeature-lifecycle-and-parity

Conversation

@madhuchavva

@madhuchavva madhuchavva commented Aug 31, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Brings the Python provider in line with the OpenFeature provider contract, fixes four evaluation bugs, and pins the dependency floors that fresh installs were already resolving past. Adds track() support. Releases as 0.1.0 (breaking).

Root cause

Verified by introspection against openfeature-sdk:

provider.initialize   : (self)                      | async: True
abstract.initialize   : (self, evaluation_context) -> None
provider defines shutdown? : False  -> inherits the no-op
Bug Effect Evidence
initialize() had the wrong signature and was async The registry's initialize(evaluation_context) raised TypeError, was swallowed by its blanket except, and dispatched PROVIDER_ERROR on every set_provider() openfeature/provider/_registry.py
close() defined instead of shutdown() OpenFeature never closed the client; background refresh outlived the provider inherited AbstractProvider.shutdown no-op
initialized assigned the first fetch result A transient startup blip pinned the provider to PROVIDER_NOT_READY permanently, even after a successful background refresh —
reason keyed off ruleId before experimentResult GrowthBook sets both on experiment results, so every experiment reported TARGETING_MATCH and the experiment branch was unreachable growthbook/core.py:686
Values coerced, not validated bool("false") → True, str(123) → "123", list({"a":1}) → ["a"] —
TARGETING_KEY_MISSING branch unreachable A context with no bucketing id looked like a disabled flag; the README documented the error as real behaviour provider.py:219 (old)
on_experiment_viewed typed as 1-positional-arg growthbook 3.0.0 invokes it with keyword args experiment/result/user_context; following the annotation raised TypeError growthbook_client.py:736-770

Behavioral change

  • targeting_key still maps to id, but an explicit id attribute now wins.
  • Missing bucketing id returns the default plus TARGETING_KEY_MISSING Reverted on review: that gate also broke plain flags (which need no subject) and experiments hashing on custom attributes such as deviceId. A missing id no longer blocks evaluation — GrowthBook itself skips only the rules whose hash attribute is absent.
  • Type mismatches return the default plus TYPE_MISMATCH. One exception: an integer flag read via get_float_value widens losslessly, since GrowthBook (like JSON) has a single number type while OpenFeature splits the accessors.
  • Unknown flags now return FLAG_NOT_FOUND (and a cyclic prerequisite returns PARSE_ERROR) instead of a plain DEFAULT with no error. The resolved value is still your default, so get_*_value is unaffected; only get_*_details gains the error. This matches the JavaScript provider and lets a typo be told apart from a flag that is deliberately off.
  • Experiments now report SPLIT with the variation id; forced/override/prerequisite rules report TARGETING_MATCH with the rule id.

Dependencies

Both floors were unbounded, so fresh installs already crossed two major boundaries:

Package Was Now Why
growthbook >=1.2.1 >=3.0.0,<4.0.0 3.0.0 changed the tracking-callback contract
openfeature-sdk >=0.8.1 >=0.10.0,<0.11.0 set_provider_and_wait and track() land here; requires Python 3.10+
pytest-asyncio >=0.21.0 >=1.0.0,<1.1.0 1.1+ changes loop teardown in a way run_async_legacy does not survive

Python 3.9 is dropped (the openfeature floor requires 3.10+). CI matrix moves to 3.10–3.13.

Follow-ups

Test plan

  • Full suite: pytest tests/ -q — 44 passed, 1 skipped
  • With live CDN: GB_INTEGRATION_TESTS=1 pytest tests/ -q — 45 passed
  • mypy src tests — clean (main had 4 pre-existing Liskov errors, also fixed)
  • Clean install as CI does it: pip install -e ".[dev]" on Python 3.13 — 43 passed, 89% coverage
  • New tests/test_provider_contract.py — 17 of its 21 tests fail against the pre-change provider, confirming they are real regression tests
  • End-to-end against a real SDK connection: forced rule → TARGETING_MATCH, int flag widens to 1.0, boolean-as-string → TYPE_MISMATCH

test_provider_with_real_api is now opt-in behind GB_INTEGRATION_TESTS=1: it hits a shared demo project, and its previous assertions were wrong (it expected ff1 to be False while passing cloud: True, which a force: true rule matches).

Review pass

A second read of the diff surfaced and fixed:

  • unknown flags silently reported as DEFAULT (above)
  • close() discarded tracking events that were already scheduled
  • the track() docstring claimed the call never blocks; true inside a running loop, false on the synchronous path
  • the async doc examples called provider.resolve_*_details_async directly, bypassing the OpenFeature client's hooks and telemetry — the SDK dispatches to those resolvers itself, so client.get_*_async is the correct entry point

Checked and found fine, recorded so they are not re-litigated: decryption_key="" is falsy in GrowthBook so it is safely treated as "no decryption"; and constructing the client through the sync bridge from inside a running loop does not break evaluation or background refresh (verified against the live CDN).

Second review round

  • id no longer required to evaluate. The TARGETING_KEY_MISSING gate rejected every context without an id, breaking plain flags and custom-hash experiments (deviceId). Removed; regression tests cover both.
  • Shutdown is async-safe. shutdown() used to drive close() through a helper thread's fresh event loop, closing resources owned by the caller's loop. Inside a running loop it now schedules the close on that loop. Fixing this surfaced a second bug: the close task was initially parked in _tracking_tasks, which close() drains — it awaited itself. It lives in a separate lifecycle set.
  • Shutdown/initialize race closed. A shutdown landing mid-boot returned before self.client was assigned; the completing initialize then resurrected an unclosed provider. _initialize now honours a _closed flag and closes the freshly built client.
  • Reason map aligned with the JS and Go providers: override → STATIC, blocked prerequisite → DEFAULT; the force mapping is documented as lossy.
  • Docs: the async example now evaluates through get_*_async (the sync methods block the loop per call); the failed-fetch example correctly shows READY + FLAG_NOT_FOUND; the tracking section no longer claims tracking never blocks (true in a running loop; plain sync callers drive the event inline).

…n 3.9

Both floors were unbounded (`growthbook>=1.2.1`, `openfeature-sdk>=0.8.1`), so
fresh installs already resolved to growthbook 3.0.0 and openfeature-sdk 0.10.0
across two major boundaries. openfeature-sdk 0.10.0 requires Python 3.10+,
which the declared `requires-python = ">=3.9"` contradicted.

BREAKING CHANGE: Python 3.9 is no longer supported.
…ntract

Lifecycle: initialize() took no evaluation_context and was async, so the SDK
registry's `provider.initialize(evaluation_context)` call raised TypeError, was
swallowed by its blanket except, and dispatched PROVIDER_ERROR instead of
PROVIDER_READY on every set_provider(). The provider also defined close()
rather than shutdown(), so OpenFeature never closed the GrowthBook client and
its background refresh outlived the provider. A transient startup fetch failure
also used to pin the provider to PROVIDER_NOT_READY permanently; `initialized`
now records that the provider booted, not whether the first load succeeded.

Reason: keyed off ruleId before experimentResult, but GrowthBook sets both on
experiment results, so every experiment reported TARGETING_MATCH and the
experiment branch was unreachable. Now derived from FeatureResult.source.

Types: flag values were coerced through bool/str/int/float, turning a mistyped
flag into a plausible wrong value. They are now validated, returning the
default plus TYPE_MISMATCH. An integer still widens to satisfy a float flag,
which is lossless and matches GrowthBook's single number type.

Targeting: the TARGETING_KEY_MISSING branch was unreachable, so a context with
no bucketing id looked like a disabled flag. An explicit `id` attribute now
also takes precedence over targeting_key.

on_experiment_viewed was typed as a one-positional-argument callable;
GrowthBook invokes it with keyword arguments.

BREAKING CHANGE: `await provider.initialize()` no longer works. Use
`api.set_provider_and_wait(provider)`, or `await provider.initialize_async()`
in async applications.
Implements the provider track() hook against GrowthBookClient.log_event.
OpenFeature's track() is synchronous while log_event is a coroutine, so the
event is scheduled fire-and-forget: tracking must never block or fail an
evaluation path. TrackingEventDetails carries a distinguished numeric `value`
alongside free-form attributes, which is folded into GrowthBook's single
properties dict.

Requires an event_logger or the GrowthBook tracking plugin, both now
exposed on GrowthBookProviderOptions.
Replaces the `await provider.initialize()` pattern with
set_provider_and_wait(), documents the keyword-argument contract for
on_experiment_viewed, the targeting-key and type-validation behaviour, and the
new track() support. The async example's on_experiment_viewed lambda took a
single positional argument, which GrowthBook never calls it with.
Review pass over the provider found three gaps:

An unknown flag returned reason=DEFAULT with no error code, because the
null-value shortcut ran before the source was inspected. GrowthBook signals
this as source="unknownFeature", which the JavaScript provider maps to
FLAG_NOT_FOUND; Python now matches, along with cyclicPrerequisite ->
PARSE_ERROR. The resolved value is still the caller's default, so get_*_value
is unaffected and only the details carry the error.

close() tore the client down without waiting for tracking events already
scheduled, silently dropping them.

The track() docstring claimed the call never blocks. That holds inside a
running loop, but the synchronous path drives the coroutine to completion
inline; the docstring now says so.

Docs: the async examples called provider.resolve_*_details_async directly,
bypassing the OpenFeature client's hooks and telemetry. The SDK dispatches to
those resolvers itself, so the examples now use client.get_*_async. Adds the
GrowthBook source -> OpenFeature reason mapping table.
…ontexts

Review findings, each reproduced before fixing:

- Every context without an id was rejected with TARGETING_KEY_MISSING before
  GrowthBook saw it, which broke plain flags (no subject needed) and
  experiments hashing on custom attributes such as deviceId. The gate is
  removed: GrowthBook itself skips only the rules whose hash attribute is
  absent, which is the correct scoping.
- shutdown() drove close() through a helper thread's fresh event loop, closing
  resources owned by the caller's loop; async applications could get
  "RuntimeError: This event loop is already running" and keep an open client.
  Inside a running loop the close is now scheduled on that loop (parked in a
  lifecycle set, not _tracking_tasks -- close() drains that set and would have
  awaited itself).
- A shutdown landing while initialize was still awaiting the client boot
  returned before self.client was assigned, so the completing initialize
  resurrected an unclosed provider. _initialize now honours a _closed flag and
  closes the freshly built client instead of assigning it.
- Reason map aligned with the JS and Go providers: override (a programmatic
  force) reports STATIC and a prerequisite-blocked feature reports DEFAULT;
  the force mapping is documented as lossy.

Docs: the async example evaluated through the sync client methods, blocking
the loop per call -- now uses get_*_async. The failed-fetch example claimed
initialization fails; it reaches READY (deliberately, so a transient blip
cannot brick the provider) and unknown flags then report FLAG_NOT_FOUND. The
README tracking section no longer claims tracking never blocks: that holds
inside a running loop, while plain sync callers drive the event inline.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant