open-feature / open-feature/python-sdk

[BUG] before hooks do not receive the evaluation context returned by earlier before hooks

オープン
#628 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

bug
主要言語
Python
スター
111
フォーク
44
平均マージ
2時間 37分
マージ済み PR(30日)
14

説明

Observed behaviour

Requirement 4.3.4 says:

Any evaluation context returned from a before hook MUST be passed to subsequent before
hooks (via HookContext).

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.

コントリビューションガイド

コントリビューションガイドを開く

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

調査の方向性

OpenFeatureClient._establish_hooks_and_provider と _hook_support._execute_hooks_unchecked から始め、次に _run_before_hooks_and_update_context と before_hooks の reduction を追跡します。各 subsequent before hook がそれ以前の hook の寄与を受け取ることを確認しつつ、provider の最終的にマージされた context を保持します。異なるレベルの 2 つの hook を使い、2 番目の hook が観測する内容と provider が受け取る内容の両方を確認する regression case を追加します。

索引モデルが issue の本文から書いたものです。

評価

技術スタック
python
領域
backend-api-design
issue の種類
バグ
難易度
3/5
見積もり時間
1〜2日
活発さ
活発
明瞭さ
明確に書かれている
初心者へのやさしさ
78/100

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。