Repository navigation
feat: user-scoped GrowthBookClient instances via create_scoped_instance - #141
Open
vazarkevych wants to merge 1 commit into
Open
vazarkevych wants to merge 1 commit into
vazarkevych wants to merge 1 commit into
Conversation
|
Every evaluation on GrowthBookClient took an explicit UserContext argument (is_on(key, ctx), get_feature_value(key, fallback, ctx), run(experiment, ctx), and four more). In request-scoped backends that means threading the same context through every layer, and passing a mismatched one is a silent correctness bug. Adds GrowthBookClient.create_scoped_instance(user_context) returning a lightweight per-user/per-request UserScopedGrowthBook handle whose evaluation methods take no context: eval_feature, is_on, is_off, get_feature_value, run, log_event and preload_remote_eval. Every call delegates back to the shared client, so feature refresh, experiment subscriptions, the feature-usage callback and the remote-eval cache are reused rather than forked. The handle also owns per-user state, mutating its bound context: set_attributes, update_attributes (shallow merge), set_url, set_forced_variations, set_forced_features, and a per-user set_tracking_callback that overrides the client-level Options.on_experiment_viewed for that user, falling back to it when unset. To support the per-user callback, UserContext gains an optional tracking_callback and GrowthBookClient._track prefers it over the client-level one. core.py needs no change — _track already receives the user context. The context is bound by reference rather than copied, so the setters write back to the caller's object and external mutations are visible to the handle. All changes are additive and default to existing behavior. Documents in the README that set_forced_features is only honored in remote-eval mode — local evaluation never reads UserContext.forced_features. Pre-existing SDK-wide behavior, called out so the setter isn't mistaken for a local override.
vazarkevych
force-pushed
the
feat/user-scoped-instances
branch
from
October 5, 2026 18:10
6765784 to
576bf29
Compare
andywhite37
self-requested a review
October 8, 2026 21:28
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.
Every evaluation on
GrowthBookClienttakes an explicitUserContextargument —
is_on(key, ctx),get_feature_value(key, fallback, ctx),run(experiment, ctx),eval_feature,is_off,preload_remote_eval, andoptionally
log_event. In request-scoped backends that means threading thesame context through every layer, and passing a mismatched one is a silent
correctness bug — the wrong user gets the wrong variation and nothing errors.
What this adds
GrowthBookClient.create_scoped_instance(user_context)returns a lightweightper-user / per-request
UserScopedGrowthBookhandle whose evaluation methodstake no context argument:
Available on the handle: eval_feature, is_on, is_off,
get_feature_value, run, log_event, preload_remote_eval.
Every call delegates back to the shared client, so feature refresh, experiment
subscriptions, the feature-usage callback and the remote-eval cache are reused
rather than forked. The handle holds no evaluation state — creating and
discarding one per request is cheap.
Per-user state
The handle owns the state that belongs to one user, mutating its bound context:
user only, falling back to it when unset
a lower layer that still needs it
To support the per-user callback, UserContext gains an optional
tracking_callback and GrowthBookClient._track prefers it over the
client-level one. core.py needs no change — _track already receives the
user context.
All changes are additive and default to existing behavior.
Two decisions worth reviewing
The context is bound by reference, not copied. The setters write back to
the object you passed in, and external mutations of it are visible to the
handle. That is what makes user_context usable as a pass-through to plugins.
There is a test pinning this explicitly so it isn't later "optimized" into a
copy.
One handle per user. The bound context is mutable and also accumulates
sticky bucket assignments via create_evaluation_context, so sharing one
handle across concurrent requests for different users reintroduces exactly the
bug this class prevents. Documented on the class and in the README, matching
the existing README guidance about not sharing a UserContext across requests.
Pre-existing behavior called out
set_forced_features() is only honored in remote-eval mode, where the values
ship to the proxy in the eval payload. Local (CDN-mode) evaluation never reads
UserContext.forced_features — core.py doesn't reference it at all. That is
SDK-wide behavior this PR doesn't change, but an unqualified setter would read
as a local override, so both the docstring and the README say so and point at
set_forced_variations() for steering local evaluation.
Testing
Seven tests covering: evaluation without a context (including asserting the
handle and the client-with-explicit-context agree), independence of two handles
off one client, every state setter, reference-binding semantics, the per-user
tracking callback overriding the client-level one and falling back when
cleared, log_event passing the bound context, and preload_remote_eval
delegation without remote-eval configured.
792 tests pass, mypy clean, no new flake8 findings. README snippets were run
as written.