diff --git a/posthog/request.py b/posthog/request.py index 76df1fdc..1c2456a7 100644 --- a/posthog/request.py +++ b/posthog/request.py @@ -1,5 +1,6 @@ import json import logging +import math import re import socket import time @@ -479,9 +480,29 @@ class QuotaLimitError(APIError): RequestsConnectionError = requests.exceptions.ConnectionError +def _replace_non_finite_floats(value: Any) -> Any: + """Recursively replace inf/nan floats with None. + + json.dumps with the default allow_nan=True emits bare Infinity/NaN tokens, + which are not valid JSON and get rejected by strict-parsing ingestion (and + intermediaries). posthog-js avoids this because JSON.stringify converts + non-finite numbers to null; this mirrors that behavior. + """ + if isinstance(value, float): + return value if math.isfinite(value) else None + if isinstance(value, dict): + return {key: _replace_non_finite_floats(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_replace_non_finite_floats(item) for item in value] + return value + + class DatetimeSerializer(json.JSONEncoder): def default(self, obj: Any): if isinstance(obj, (date, datetime)): return obj.isoformat() return json.JSONEncoder.default(self, obj) + + def encode(self, o: Any) -> str: + return super().encode(_replace_non_finite_floats(o)) diff --git a/posthog/test/test_request.py b/posthog/test/test_request.py index d4ad7c9d..455f24d8 100644 --- a/posthog/test/test_request.py +++ b/posthog/test/test_request.py @@ -776,3 +776,43 @@ def test_flags_does_not_retry_other_http_status_errors(self, status_code): self.assertEqual(cm.exception.status, status_code) self.assertEqual(mock_session.post.call_count, 1) + + +def test_post_serializes_non_finite_floats_as_null(): + mock_response = requests.Response() + mock_response.status_code = 200 + mock_session = mock.MagicMock() + mock_session.post.return_value = mock_response + + request_module.post( + TEST_API_KEY, + host="https://test.posthog.com", + path="/batch/", + session=mock_session, + batch=[ + { + "event": "score_computed", + "distinct_id": "user-1", + "properties": { + "score": float("inf"), + "nested": [float("nan"), {"depth": float("-inf")}], + "ok": 1.5, + }, + } + ], + ) + + raw = mock_session.post.call_args.kwargs["data"] + # Bare Infinity/NaN tokens are not valid JSON; a strict parser must accept the body. + data = json.loads( + raw, parse_constant=lambda x: (_ for _ in ()).throw(ValueError(x)) + ) + props = data["batch"][0]["properties"] + assert props["score"] is None + assert props["nested"] == [None, {"depth": None}] + assert props["ok"] == 1.5 + + +def test_datetime_serializer_keeps_finite_values(): + body = {"n": 1.5, "s": "x", "b": True, "i": 3, "none": None} + assert json.loads(json.dumps(body, cls=DatetimeSerializer)) == body diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 29a1c477..645f6c42 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -1718,6 +1718,7 @@ method posthog.metrics_capture.PostHogMetrics.reset() -> None method posthog.poller.Poller.run() method posthog.poller.Poller.stop() method posthog.request.DatetimeSerializer.default(obj: Any) +method posthog.request.DatetimeSerializer.encode(o: Any) -> str method posthog.request.HTTPAdapterWithSocketOptions.init_poolmanager(*args, **kwargs) method posthog.tracing.span.Span.add_event(name: str, attributes: Optional[Mapping[str, Any]] = None, timestamp: Union[datetime, float, None] = None) -> Span method posthog.tracing.span.Span.end(end_time: Union[datetime, float, None] = None) -> None