Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sampo/changesets/steadfast-baroness-vellamo.md
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. Single and bulk getters (including `get_feature_payloads` and `get_feature_flags_and_payloads`) consistently decode valid JSON payloads, preserving JSON strings (including `""`), 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.
41 changes: 21 additions & 20 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@
remote_config,
reset_sessions,
)
from posthog.types import (
from .types import (
_parse_flag_payload,
FeatureFlag,
FeatureFlagError,
FeatureFlagResult,
Expand Down Expand Up @@ -342,20 +343,6 @@ def _parse_has_experiment(value: Any) -> Optional[bool]:
return value if isinstance(value, bool) else None


def _parse_flag_payload(raw_payload: Any) -> Optional[Any]:
"""Flag payloads are stored as JSON strings, both in the ``/flags`` response
metadata and in the local-evaluation flag definitions, so decode them before
handing them to callers. A string that isn't valid JSON is passed through as-is."""
if isinstance(raw_payload, str):
if not raw_payload:
return None
try:
return json.loads(raw_payload)
except (json.JSONDecodeError, TypeError):
return raw_payload
return raw_payload


def _metadata_has_experiment(metadata: Any) -> Optional[bool]:
"""Server-reported experiment linkage from flag metadata; ``None`` when absent
(e.g. ``LegacyFlagMetadata``, which doesn't carry the field)."""
Expand Down Expand Up @@ -1390,9 +1377,9 @@ def get_feature_payloads(
disable_geoip: Optional[bool] = None,
flag_keys_to_evaluate: Optional[list[str]] = None,
device_id: Optional[str] = None,
) -> dict[str, str]:
) -> dict[str, Any]:
"""
Get feature flag payloads for a user.
Get decoded feature flag payloads for a user.

Args:
distinct_id: The distinct ID of the user.
Expand Down Expand Up @@ -1421,7 +1408,10 @@ def get_feature_payloads(
flag_keys_to_evaluate,
device_id=device_id,
)
return to_payloads(resp_data) or {}
return {
key: _parse_flag_payload(payload)
for key, payload in (to_payloads(resp_data) or {}).items()
}

def get_feature_flags_and_payloads(
self,
Expand Down Expand Up @@ -1463,7 +1453,13 @@ def get_feature_flags_and_payloads(
flag_keys_to_evaluate,
device_id=device_id,
)
return to_flags_and_payloads(resp)
response = to_flags_and_payloads(resp)
payloads = response.get("featureFlagPayloads")
if payloads is not None:
response["featureFlagPayloads"] = {
key: _parse_flag_payload(payload) for key, payload in payloads.items()
}
return response

def get_flags_decision(
self,
Expand Down Expand Up @@ -4410,12 +4406,17 @@ def get_all_flags_and_payloads(
flag_keys_to_evaluate=flag_keys_to_evaluate,
device_id=device_id,
)
return to_flags_and_payloads(decide_response)
response = to_flags_and_payloads(decide_response)
except Exception as e:
self.log.exception(
f"[FEATURE FLAGS] Unable to get feature flags and payloads: {e}"
)

payloads = response.get("featureFlagPayloads")
if payloads is not None:
response["featureFlagPayloads"] = {
key: _parse_flag_payload(payload) for key, payload in payloads.items()
}
return response

def evaluate_flags(
Expand Down
7 changes: 3 additions & 4 deletions posthog/test/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4547,7 +4547,7 @@ def test_get_feature_flag_result_with_empty_string_payload(self, patch_batch_pos
self.assertIsNotNone(result)
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.get_value(), "empty-variant")
self.assertEqual(result.payload, "") # Should be empty string, not None
self.assertIsNone(result.payload)

@mock.patch("posthog.client.batch_post")
def test_get_all_flags_and_payloads_with_empty_string(self, patch_batch_post):
Expand Down Expand Up @@ -4585,7 +4585,7 @@ def test_get_all_flags_and_payloads_with_empty_string(self, patch_batch_post):
"multivariate": {
"variants": [{"key": "variant2", "rollout_percentage": 100}]
},
"payloads": {"variant2": "normal payload"},
"payloads": {"variant2": '"normal payload"'},
},
},
]
Expand All @@ -4598,9 +4598,8 @@ def test_get_all_flags_and_payloads_with_empty_string(self, patch_batch_post):
self.assertEqual(result["featureFlags"]["empty-payload-flag"], "variant1")
self.assertEqual(result["featureFlags"]["normal-payload-flag"], "variant2")

# Check that empty string payload is included (not filtered out)
self.assertIn("empty-payload-flag", result["featureFlagPayloads"])
self.assertEqual(result["featureFlagPayloads"]["empty-payload-flag"], "")
self.assertIsNone(result["featureFlagPayloads"]["empty-payload-flag"])
self.assertEqual(
result["featureFlagPayloads"]["normal-payload-flag"], "normal payload"
)
Expand Down
4 changes: 2 additions & 2 deletions posthog/test/test_evaluate_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,10 +404,10 @@ def test_local_payloads_are_parsed(self, patch_flags):
self.assertEqual(patch_flags.call_count, 0)

@mock.patch("posthog.client.flags")
def test_non_json_local_payload_is_passed_through(self, patch_flags):
def test_non_json_local_payload_returns_none(self, patch_flags):
flags = self.client.evaluate_flags("user-1")

self.assertEqual(flags.get_flag_payload("plain-payload"), "not json")
self.assertIsNone(flags.get_flag_payload("plain-payload"))
self.assertEqual(patch_flags.call_count, 0)

@mock.patch("posthog.client.flags")
Expand Down
4 changes: 2 additions & 2 deletions posthog/test/test_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -1263,7 +1263,7 @@ def test_get_all_flags_and_payloads_with_no_fallback(
]
self.assertEqual(
client.get_all_flags_and_payloads("distinct_id")["featureFlagPayloads"],
{"beta-feature": "new"},
{"beta-feature": None},
)
# /flags is not called because this can be evaluated locally
self.assertEqual(patch_flags.call_count, 0)
Expand Down Expand Up @@ -1403,7 +1403,7 @@ def test_get_all_flags_and_payloads_with_fallback_but_only_local_evaluation_set(
client.get_all_flags_and_payloads(
"distinct_id", only_evaluate_locally=True
)["featureFlagPayloads"],
{"beta-feature": "some-payload"},
{"beta-feature": None},
)
self.assertEqual(patch_flags.call_count, 0)
self.assertEqual(patch_capture.call_count, 0)
Expand Down
203 changes: 203 additions & 0 deletions posthog/test/test_flag_payload_parsing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
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}),
('"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")
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"]
)
def test_parse_failure_logs_without_payload(raw, caplog):
with caplog.at_level("WARNING", logger="posthog"):
assert _parse_flag_payload(raw) is None
assert len(caplog.records) == 1
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()
4 changes: 2 additions & 2 deletions posthog/test/test_property_matching_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,8 +333,8 @@ def refresh_after_matching(*args, **kwargs):
assert result == {
"featureFlags": {"person": True, "second": True},
"featureFlagPayloads": {
"person": '"original-true"',
"second": '"original-true"',
"person": "original-true",
"second": "original-true",
},
}
else:
Expand Down
Loading
Loading