[BUG] before hooks do not receive the evaluation context returned by earlier before hooks
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Newbie friendliness
- 78/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- python
- Domain
- backend-api-design
Research direction
Start at OpenFeatureClient._establish_hooks_and_provider and _hook_support._execute_hooks_unchecked, then trace _run_before_hooks_and_update_context and the before_hooks reduction. Ensure each subsequent before hook receives earlier contributions while preserving the provider's final merged context. Add a regression case using two hooks at different levels that checks both what the second hook observes and what the provider receives.
Written by the indexing model from the issue text.
Description
Observed behaviour
Requirement 4.3.4 says:
Any
evaluation contextreturned from abeforehook MUST be passed to subsequentbefore
hooks (viaHookContext).
Each hook receives a HookContext built before any hook ran, so a hook never sees what an earlier
hook contributed.
The final merge into the evaluation context is correct — the provider does receive every hook's
contribution, so 3.2.2
and 4.3.5 are satisfied.
Only the propagation half is missing.
Reproducer
Against main (bd3b1e6):
from openfeature import api
from openfeature.client import OpenFeatureClient
from openfeature.evaluation_context import EvaluationContext
from openfeature.flag_evaluation import FlagEvaluationOptions
from openfeature.hook import Hook, HookContext
from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider
observed = {}
class ContributingHook(Hook):
"""Adds one attribute, and records what it could see when it ran."""
def __init__(self, name, adds):
self.name = name
self.adds = adds
def before(self, hook_context: HookContext, hints):
observed[self.name] = dict(hook_context.evaluation_context.attributes)
return EvaluationContext(attributes=self.adds)
class RecordingProvider(InMemoryProvider):
def resolve_boolean_details(self, flag_key, default_value, evaluation_context=None):
observed["provider"] = dict(evaluation_context.attributes) if evaluation_context else {}
return super().resolve_boolean_details(flag_key, default_value, evaluation_context)
api.set_provider(RecordingProvider({"flag": InMemoryFlag("on", {"on": True, "off": False})}))
client: OpenFeatureClient = api.get_client()
client.add_hooks([ContributingHook("hook_a", {"from_hook_a": "a"})])
# hook_b is an invocation hook, so it runs after the client hook (API -> client -> invocation).
client.get_boolean_details(
"flag",
False,
evaluation_context=EvaluationContext(attributes={"from_invocation": "i"}),
flag_evaluation_options=FlagEvaluationOptions(
hooks=[ContributingHook("hook_b", {"from_hook_b": "b"})]
),
)
print("hook_a saw :", observed["hook_a"])
print("hook_b saw :", observed["hook_b"])
print("provider :", observed["provider"])
Output:
hook_a saw : {'from_invocation': 'i'}
hook_b saw : {'from_invocation': 'i'}
provider : {'from_invocation': 'i', 'from_hook_a': 'a', 'from_hook_b': 'b'}
hook_b should have seen from_hook_a: a. The provider line shows the final merge is fine.
Cause
OpenFeatureClient._establish_hooks_and_provider constructs one HookContext per hook up front,
all from the same merged_eval_context:
merged_hooks_and_context = [
(
hook,
HookContext(
flag_key=flag_key,
flag_type=flag_type,
default_value=default_value,
evaluation_context=merged_eval_context,
client_metadata=client_metadata,
provider_metadata=provider_metadata,
hook_data={},
),
)
for hook in chain(get_hooks(), self.hooks, evaluation_hooks, provider.get_provider_hooks())
]
_hook_support._execute_hooks_unchecked then iterates those pairs without ever updating one:
return [
getattr(hook, hook_method.value)(hook_context=hook_context, **kwargs)
for (hook, hook_context) in hooks_and_context
if hook.supports_flag_value_type(flag_type)
]
before_hooks reduces the results correctly (reduce(lambda a, b: a.merge(b), filtered_hooks)),
which is why the provider still gets everything — but that accumulation is never fed back into the
hook contexts.
The docstring on _run_before_hooks_and_update_context cites 4.3.4 while implementing only the
merge half.
Suggested fix
Accumulate as the hooks run and refresh the context handed to each subsequent hook, rather than
collecting results and merging once at the end. Roughly:
def before_hooks(flag_type, hooks_and_context, hints=None):
accumulated = EvaluationContext()
for hook, hook_context in hooks_and_context:
if not hook.supports_flag_value_type(flag_type):
continue
hook_context.evaluation_context = hook_context.evaluation_context.merge(accumulated)
result = hook.before(hook_context=hook_context, hints=hints)
if result is not None:
accumulated = accumulated.merge(result)
return accumulated
HookContext is a plain mutable class (__init__-assigned attributes, not frozen), so assigning
evaluation_context in place works; rebuilding the context per hook is equally fine. dotnet-sdk does
the latter (WithNewEvaluationContext applied to every pending hook context); ruby-sdk does the
former.
Cross-language note
Surveyed 2026-09-14 — js-sdk, java-sdk, dotnet-sdk and ruby-sdk all propagate correctly.
go-sdk had a related but distinct bug (results replaced rather than merged), reported as
go-sdk#549 and fixed by
go-sdk#569. php-sdk has the same propagation gap
as this one.
Given four SDKs have now been found wanting on one half or the other, this seems like a good
candidate for a shared e2e/TCK case: two before hooks at different levels, each returning a distinct
key; assert both reach the provider and that the second hook observed the first's key. Asserting
only the merged result would miss this bug entirely.
- Dominant language
- Python
- Stars
- 111
- Forks
- 44
- Avg merge
- 3h 9m
- Merged PRs (30d)
- 19
Getting set up
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from open-feature/python-sdk
-
question
Difficulty 4/5 3-5 days Newbie friendliness 38/100
open-feature/python-sdk#627 ·
Maintainers usually reply within 1 day
-
v0.9.0
Difficulty 5/5 Over a week Newbie friendliness 25/100
open-feature/python-sdk#618 ·
Maintainers usually reply within 1 day
-
Difficulty 4/5 3-5 days Newbie friendliness 55/100
open-feature/python-sdk#615 ·
Maintainers usually reply within 1 day
-
Support isolated API instancesMay be free again @marcozabel claimed this 158 days ago, and no pull request is open. Open
open-feature/python-sdk#584 · 1 assignee ·
Maintainers usually reply within 1 day
-
multi-provider
Difficulty 5/5 Over a week Newbie friendliness 25/100
open-feature/python-sdk#568 ·
Maintainers usually reply within 1 day
All issues in open-feature/python-sdk
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
PedestrianDynamics/pyFDS-Evac#199 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
521xueweihan/HelloGitHub#3790 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
sandialabs/atlas-ui-3#978 ·
Maintainers usually reply within 1 day
-
area: tests perceived difficulty: 2
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Nitjsefnie-Harness-Commons/daedalus#1255 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
EleutherAI/lm-evaluation-harness#4256 ·
Maintainers usually reply within 1 day