Conform to the OpenFeature provider contract; require growthbook 3.x - #19
Open
madhuchavva wants to merge 6 commits into
Open
madhuchavva wants to merge 6 commits into
madhuchavva wants to merge 6 commits into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:initialize()had the wrong signature and was asyncinitialize(evaluation_context)raisedTypeError, was swallowed by its blanketexcept, and dispatchedPROVIDER_ERRORon everyset_provider()openfeature/provider/_registry.pyclose()defined instead ofshutdown()AbstractProvider.shutdownno-opinitializedassigned the first fetch resultPROVIDER_NOT_READYpermanently, even after a successful background refreshreasonkeyed offruleIdbeforeexperimentResultTARGETING_MATCHand the experiment branch was unreachablegrowthbook/core.py:686bool("false")→True,str(123)→"123",list({"a":1})→["a"]TARGETING_KEY_MISSINGbranch unreachableprovider.py:219(old)on_experiment_viewedtyped as 1-positional-argexperiment/result/user_context; following the annotation raisedTypeErrorgrowthbook_client.py:736-770Behavioral change
targeting_keystill maps toid, but an explicitidattribute now wins.Missing bucketing id returns the default plusReverted on review: that gate also broke plain flags (which need no subject) and experiments hashing on custom attributes such asTARGETING_KEY_MISSINGdeviceId. A missing id no longer blocks evaluation — GrowthBook itself skips only the rules whose hash attribute is absent.TYPE_MISMATCH. One exception: an integer flag read viaget_float_valuewidens losslessly, since GrowthBook (like JSON) has a single number type while OpenFeature splits the accessors.FLAG_NOT_FOUND(and a cyclic prerequisite returnsPARSE_ERROR) instead of a plainDEFAULTwith no error. The resolved value is still your default, soget_*_valueis unaffected; onlyget_*_detailsgains the error. This matches the JavaScript provider and lets a typo be told apart from a flag that is deliberately off.SPLITwith the variation id; forced/override/prerequisite rules reportTARGETING_MATCHwith the rule id.Dependencies
Both floors were unbounded, so fresh installs already crossed two major boundaries:
growthbook>=1.2.1>=3.0.0,<4.0.0openfeature-sdk>=0.8.1>=0.10.0,<0.11.0set_provider_and_waitandtrack()land here; requires Python 3.10+pytest-asyncio>=0.21.0>=1.0.0,<1.1.0run_async_legacydoes not survivePython 3.9 is dropped (the openfeature floor requires 3.10+). CI matrix moves to 3.10–3.13.
Follow-ups
PROVIDER_CONFIGURATION_CHANGEDwhen the payload refreshes; the Python provider has no equivalent, so callers cannot react to flag changes. Deliberately left out of this PR to keep it reviewable.run_async_legacyrelies onasyncio.get_event_loop()and is whypytest-asynciois capped. Reworking it would lift the cap.targetingKeyandreasonfixes are open for the JS providers ([growthbook] targetingKey is not mapped to GrowthBook's id attribute, silently disabling rollouts and experiments open-feature/js-sdk-contrib#1613, #1614) and Go (growthbook-openfeature-provider-go#4).Test plan
pytest tests/ -q— 44 passed, 1 skippedGB_INTEGRATION_TESTS=1 pytest tests/ -q— 45 passedmypy src tests— clean (main had 4 pre-existing Liskov errors, also fixed)pip install -e ".[dev]"on Python 3.13 — 43 passed, 89% coveragetests/test_provider_contract.py— 17 of its 21 tests fail against the pre-change provider, confirming they are real regression testsTARGETING_MATCH, int flag widens to1.0, boolean-as-string →TYPE_MISMATCHtest_provider_with_real_apiis now opt-in behindGB_INTEGRATION_TESTS=1: it hits a shared demo project, and its previous assertions were wrong (it expectedff1to beFalsewhile passingcloud: True, which aforce: truerule matches).Review pass
A second read of the diff surfaced and fixed:
DEFAULT(above)close()discarded tracking events that were already scheduledtrack()docstring claimed the call never blocks; true inside a running loop, false on the synchronous pathprovider.resolve_*_details_asyncdirectly, bypassing the OpenFeature client's hooks and telemetry — the SDK dispatches to those resolvers itself, soclient.get_*_asyncis the correct entry pointChecked 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
idno longer required to evaluate. TheTARGETING_KEY_MISSINGgate rejected every context without an id, breaking plain flags and custom-hash experiments (deviceId). Removed; regression tests cover both.shutdown()used to driveclose()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, whichclose()drains — it awaited itself. It lives in a separate lifecycle set.self.clientwas assigned; the completing initialize then resurrected an unclosed provider._initializenow honours a_closedflag and closes the freshly built client.override→STATIC, blockedprerequisite→DEFAULT; theforcemapping is documented as lossy.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).