-
Notifications
You must be signed in to change notification settings - Fork 83
fix: Return None for malformed feature flag payloads #947
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
3781203
fix: Return None for malformed feature flag payloads
marandaneto eae7347
fix: align bulk flag payload parsing and address review feedback
marandaneto 00feca5
fix: isolate flag payload decoding failures across public getters
marandaneto 8ce5c2d
fix: merge main and restore JSON import for flag definition hashing
marandaneto 89fab0f
fix: preserve serialized payloads in legacy bulk getters
marandaneto 84728a9
fix: log flag payload parse failures at debug level
marandaneto 310d051
fix: annotate nullable serialized feature payloads
marandaneto File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| pypi/posthog: patch | ||
| --- | ||
|
|
||
| Return None for malformed, empty, or whitespace-only serialized JSON feature flag payloads instead of returning the raw string or raising a JSONDecodeError. Legacy bulk getters (`get_all_flags_and_payloads`, `get_feature_flags_and_payloads`, and `get_feature_payloads`) validate payloads but preserve valid serialized JSON for compatibility with callers using `json.loads()`. Single-flag and `evaluate_flags()` snapshot payload getters continue returning decoded values, including JSON strings (such as `""`), false, and zero. Reject non-JSON constants such as NaN and Infinity and isolate decoder-limit failures so flag values and healthy sibling payloads remain available. |
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
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
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,221 @@ | ||
| import json | ||
| import sys | ||
| from unittest.mock import patch | ||
|
|
||
| import pytest | ||
|
|
||
| from posthog.client import Client | ||
| from posthog.types import _parse_flag_payload | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "api, local", | ||
| [ | ||
| ("payload", True), | ||
| ("payload", False), | ||
| ("snapshot", True), | ||
| ("snapshot", False), | ||
| ("bulk", True), | ||
| ("bulk", False), | ||
| ("remote_bulk", False), | ||
| ("remote_payloads", False), | ||
| ], | ||
| ) | ||
| @pytest.mark.parametrize( | ||
| "raw, expected", | ||
| [ | ||
| ('{"broken":', None), | ||
| ("not json", None), | ||
| (" ", None), | ||
| ("", None), | ||
| ("NaN", None), | ||
| ("Infinity", None), | ||
| ("-Infinity", None), | ||
| ('{"nested": [NaN]}', None), | ||
| ("[Infinity, -Infinity]", None), | ||
| pytest.param("[" * 20000, None, id="decoder-recursion-limit"), | ||
| pytest.param( | ||
| "9" * (getattr(sys, "get_int_max_str_digits", lambda: 0)() + 1), | ||
| None, | ||
| id="decoder-integer-limit", | ||
| marks=pytest.mark.skipif( | ||
| not getattr(sys, "get_int_max_str_digits", lambda: 0)(), | ||
| reason="Integer conversion limit is unavailable or disabled", | ||
| ), | ||
| ), | ||
| ("[1, 2]", [1, 2]), | ||
| ('{"ok": true}', {"ok": True}), | ||
| (' {"ok": true}\n', {"ok": True}), | ||
| ('"text"', "text"), | ||
| ('"123"', "123"), | ||
| ('"true"', "true"), | ||
| ('"NaN"', "NaN"), | ||
| ('"Infinity"', "Infinity"), | ||
| ('""', ""), | ||
| ("false", False), | ||
| ("0", 0), | ||
| ("null", None), | ||
| ({"decoded": True}, {"decoded": True}), | ||
| (None, None), | ||
| ], | ||
| ) | ||
| def test_payload_parsing(local, api, raw, expected): | ||
| client = Client("test-key", send=False) | ||
| if local: | ||
| client.feature_flags = [ | ||
| { | ||
| "id": 1, | ||
| "key": "test-flag", | ||
| "active": True, | ||
| "filters": { | ||
| "groups": [{"properties": [], "rollout_percentage": 100}], | ||
| "payloads": {"true": raw}, | ||
| }, | ||
| }, | ||
| { | ||
| "id": 2, | ||
| "key": "healthy", | ||
| "active": True, | ||
| "filters": { | ||
| "groups": [{"properties": [], "rollout_percentage": 100}], | ||
| "payloads": {"true": '{"ok": true}'}, | ||
| }, | ||
| }, | ||
| ] | ||
| response = { | ||
| "flags": { | ||
| "test-flag": { | ||
| "key": "test-flag", | ||
| "enabled": True, | ||
| "variant": None, | ||
| "reason": {"code": "condition_match", "description": "Matched"}, | ||
| "metadata": {"id": 1, "version": 1, "payload": raw}, | ||
| }, | ||
| "healthy": { | ||
| "enabled": True, | ||
| "metadata": {"payload": '{"ok": true}'}, | ||
| }, | ||
| } | ||
| } | ||
| try: | ||
| with ( | ||
| patch.object(client, "load_feature_flags"), | ||
| patch("posthog.client.flags", return_value=response) as request, | ||
| ): | ||
| if api in ("bulk", "remote_bulk"): | ||
| bulk = ( | ||
| client.get_all_flags_and_payloads( | ||
| "user", only_evaluate_locally=local | ||
| ) | ||
| if api == "bulk" | ||
| else client.get_feature_flags_and_payloads("user") | ||
| ) | ||
| assert bulk["featureFlags"] == {"test-flag": True, "healthy": True} | ||
| assert bulk["featureFlagPayloads"]["healthy"] == '{"ok": true}' | ||
| result = bulk["featureFlagPayloads"].get("test-flag") | ||
| elif api == "remote_payloads": | ||
| payloads = client.get_feature_payloads("user") | ||
| assert payloads["healthy"] == '{"ok": true}' | ||
| result = payloads.get("test-flag") | ||
| elif api == "payload": | ||
| with pytest.warns(DeprecationWarning): | ||
| result = client.get_feature_flag_payload( | ||
| "test-flag", "user", only_evaluate_locally=local | ||
| ) | ||
| else: | ||
| result = client.evaluate_flags("user", only_evaluate_locally=local) | ||
| assert result.get_flag_payload("healthy") == {"ok": True} | ||
| assert result.get_flag("test-flag") is True | ||
| result = result.get_flag_payload("test-flag") | ||
| if api in ("bulk", "remote_bulk", "remote_payloads") and isinstance( | ||
| raw, str | ||
| ): | ||
| if expected is not None or raw == "null": | ||
| assert result == raw | ||
| assert json.loads(result) == expected | ||
| else: | ||
| assert result is None | ||
| else: | ||
| assert result == expected | ||
| assert type(result) is type(expected) | ||
| assert request.call_count == (0 if local else 1) | ||
| finally: | ||
| client.shutdown() | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "raw", ['{"private":', "", " ", "NaN", "Infinity", "-Infinity"] | ||
| ) | ||
| @pytest.mark.parametrize("decode", [True, False]) | ||
| def test_parse_failure_logs_without_payload(raw, decode, caplog): | ||
| with caplog.at_level("WARNING", logger="posthog"): | ||
| for _ in range(10): | ||
| assert _parse_flag_payload(raw, decode=decode) is None | ||
| assert not caplog.records | ||
|
|
||
| with caplog.at_level("DEBUG", logger="posthog"): | ||
| assert _parse_flag_payload(raw, decode=decode) is None | ||
| assert len(caplog.records) == 1 | ||
| assert caplog.records[0].levelname == "DEBUG" | ||
| assert caplog.records[0].getMessage().removeprefix("[PostHog] ") == ( | ||
| "[FEATURE FLAGS] Unable to parse flag payload as JSON" | ||
| ) | ||
| assert caplog.records[0].exc_info is None | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("local", [True, False]) | ||
| @pytest.mark.parametrize("value", [True, False, "blue"]) | ||
| @pytest.mark.parametrize("raw", ['{"broken":', "", " "]) | ||
| def test_invalid_payload_preserves_flag_getters(local, value, raw): | ||
| client = Client("test-key", send=False) | ||
| filters = { | ||
| "groups": [{"properties": [], "rollout_percentage": 100 if value else 0}], | ||
| "payloads": {str(value).lower(): raw}, | ||
| } | ||
| if isinstance(value, str): | ||
| filters["multivariate"] = { | ||
| "variants": [{"key": value, "rollout_percentage": 100}] | ||
| } | ||
| if local: | ||
| client.feature_flags = [ | ||
| {"id": 1, "key": "test-flag", "active": True, "filters": filters} | ||
| ] | ||
| response = { | ||
| "flags": { | ||
| "test-flag": { | ||
| "enabled": value is not False, | ||
| "variant": value if isinstance(value, str) else None, | ||
| "reason": {"code": "condition_match", "description": "Matched"}, | ||
| "metadata": {"id": 1, "version": 1, "payload": raw}, | ||
| } | ||
| } | ||
| } | ||
| try: | ||
| with ( | ||
| patch.object(client, "load_feature_flags"), | ||
| patch("posthog.client.flags", return_value=response) as request, | ||
| ): | ||
| result = client.get_feature_flag_result( | ||
| "test-flag", "user", only_evaluate_locally=local | ||
| ) | ||
| assert result is not None | ||
| assert result.key == "test-flag" | ||
| assert result.get_value() == value | ||
| assert result.enabled is (value is not False) | ||
| assert result.variant == (value if isinstance(value, str) else None) | ||
| assert result.payload is None | ||
| assert result.reason == (None if local else "Matched") | ||
| with pytest.warns(DeprecationWarning): | ||
| assert ( | ||
| client.get_feature_flag( | ||
| "test-flag", "user", only_evaluate_locally=local | ||
| ) | ||
| == value | ||
| ) | ||
| with pytest.warns(DeprecationWarning): | ||
| assert client.feature_enabled( | ||
| "test-flag", "user", only_evaluate_locally=local | ||
| ) is (value is not False) | ||
| assert request.call_count == (0 if local else 3) | ||
| finally: | ||
| client.shutdown() |
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.