From 76ecbfa0862686e9416b8fe29beed650aaa3acf1 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 30 Jul 2026 16:40:11 +0200 Subject: [PATCH 1/2] fix(capture): cap v0 retry delays --- .../majestic-iceseeker-vainamoinen.md | 5 +++ posthog/consumer.py | 14 ++++--- posthog/test/test_consumer.py | 40 +++++++++++++++++++ 3 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 .sampo/changesets/majestic-iceseeker-vainamoinen.md diff --git a/.sampo/changesets/majestic-iceseeker-vainamoinen.md b/.sampo/changesets/majestic-iceseeker-vainamoinen.md new file mode 100644 index 00000000..92a6d39e --- /dev/null +++ b/.sampo/changesets/majestic-iceseeker-vainamoinen.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Cap capture v0 Retry-After delays diff --git a/posthog/consumer.py b/posthog/consumer.py index b60e156f..fb39064d 100644 --- a/posthog/consumer.py +++ b/posthog/consumer.py @@ -7,7 +7,7 @@ from posthog._logging import _configure_posthog_logging from posthog.capture_compression import CaptureCompression from posthog.capture_mode import CaptureMode -from posthog.capture_v1 import _send_v1_batch +from posthog.capture_v1 import _MAX_BACKOFF_SECONDS, _send_v1_batch from posthog.request import ( EVENTS_ENDPOINT, APIError, @@ -209,12 +209,14 @@ def is_retryable(exc): if not is_retryable(e): raise if attempt < self.retries: - # Respect Retry-After header if present, otherwise use exponential backoff + configured_backoff = min(2**attempt, _MAX_BACKOFF_SECONDS) retry_after = getattr(e, "retry_after", None) - if retry_after and retry_after > 0: - time.sleep(retry_after) - else: - time.sleep(min(2**attempt, 30)) + clamped_retry_after = ( + min(retry_after, _MAX_BACKOFF_SECONDS) + if retry_after and retry_after > 0 + else 0 + ) + time.sleep(max(configured_backoff, clamped_retry_after)) if last_exc: raise last_exc diff --git a/posthog/test/test_consumer.py b/posthog/test/test_consumer.py index ab582193..d9c96e54 100644 --- a/posthog/test/test_consumer.py +++ b/posthog/test/test_consumer.py @@ -1,6 +1,8 @@ import json import time import unittest +from datetime import datetime, timedelta, timezone +from email.utils import format_datetime from typing import Any from unittest import mock @@ -231,6 +233,44 @@ def mock_post(*args: Any, **kwargs: Any) -> None: ], ) + @parameterized.expand( + [ + ("huge_numeric", "1000000000", [30, 30]), + ("small_numeric", "0.25", [1, 2]), + ("huge_date", "Fri, 01 Jan 2100 00:00:00 GMT", [30, 30]), + ("small_date", None, [1, 2]), + ] + ) + def test_request_bounds_retry_after_without_reducing_attempts( + self, _name: str, retry_after_header: str | None, expected_sleeps: list[int] + ) -> None: + if retry_after_header is None: + retry_after_header = format_datetime( + datetime.now(timezone.utc) + timedelta(seconds=1), usegmt=True + ) + + retry_response = mock.Mock( + status_code=503, + headers={"Retry-After": retry_after_header}, + text="Service Unavailable", + ) + retry_response.json.return_value = {"detail": "Service Unavailable"} + success_response = mock.Mock(status_code=200) + session = mock.Mock() + session.post.side_effect = [retry_response, retry_response, success_response] + + consumer = Consumer(None, TEST_API_KEY, retries=2) + with ( + mock.patch("posthog.request._get_session", return_value=session), + mock.patch("posthog.consumer.time.sleep") as mock_sleep, + ): + consumer.request([_track_event()]) + + self.assertEqual(session.post.call_count, 3) + self.assertEqual( + [call.args[0] for call in mock_sleep.call_args_list], expected_sleeps + ) + def test_request_retries_on_408(self) -> None: call_count = [0] From 8928bb7d4f4876db04c83c99169e3f035bd1a312 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 31 Jul 2026 11:43:47 +0200 Subject: [PATCH 2/2] refactor(capture): share retry backoff logic --- posthog/consumer.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/posthog/consumer.py b/posthog/consumer.py index fb39064d..8976db9c 100644 --- a/posthog/consumer.py +++ b/posthog/consumer.py @@ -7,7 +7,7 @@ from posthog._logging import _configure_posthog_logging from posthog.capture_compression import CaptureCompression from posthog.capture_mode import CaptureMode -from posthog.capture_v1 import _MAX_BACKOFF_SECONDS, _send_v1_batch +from posthog.capture_v1 import _backoff, _send_v1_batch from posthog.request import ( EVENTS_ENDPOINT, APIError, @@ -209,14 +209,7 @@ def is_retryable(exc): if not is_retryable(e): raise if attempt < self.retries: - configured_backoff = min(2**attempt, _MAX_BACKOFF_SECONDS) - retry_after = getattr(e, "retry_after", None) - clamped_retry_after = ( - min(retry_after, _MAX_BACKOFF_SECONDS) - if retry_after and retry_after > 0 - else 0 - ) - time.sleep(max(configured_backoff, clamped_retry_after)) + _backoff(attempt, getattr(e, "retry_after", None)) if last_exc: raise last_exc