From f693c1e020b91506e1d561477e44187e8a062094 Mon Sep 17 00:00:00 2001 From: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:37:34 -0400 Subject: [PATCH] fix: honor HTTP-date Retry-After in async requests --- .sampo/changesets/async-retry-after-date.md | 5 ++ posthog/_async_request.py | 14 +----- posthog/test/test_async_consumer.py | 53 +++++++++++++++++++++ 3 files changed, 60 insertions(+), 12 deletions(-) create mode 100644 .sampo/changesets/async-retry-after-date.md diff --git a/.sampo/changesets/async-retry-after-date.md b/.sampo/changesets/async-retry-after-date.md new file mode 100644 index 000000000..e300a67bc --- /dev/null +++ b/.sampo/changesets/async-retry-after-date.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Honor HTTP-date Retry-After headers in asynchronous requests while preserving the existing retry backoff and delay cap. diff --git a/posthog/_async_request.py b/posthog/_async_request.py index 52194d9b5..d50f17ead 100644 --- a/posthog/_async_request.py +++ b/posthog/_async_request.py @@ -11,7 +11,7 @@ from urllib.parse import quote, urljoin, urlsplit from .capture_compression import CaptureCompression -from .capture_v1 import _send_v1_batch +from .capture_v1 import _parse_retry_after, _send_v1_batch from .request import ( APIError, DatetimeSerializer, @@ -100,21 +100,11 @@ def _serialize_flags_body( } -def _parse_retry_after(response: Any) -> Optional[float]: - value = response.headers.get("Retry-After") - if value is None: - return None - try: - return float(value) - except (TypeError, ValueError): - return None - - def _process_response(response: Any) -> None: if response.status_code == 200: return - retry_after = _parse_retry_after(response) + retry_after = _parse_retry_after(response.headers.get("Retry-After")) try: payload = response.json() detail = payload["detail"] diff --git a/posthog/test/test_async_consumer.py b/posthog/test/test_async_consumer.py index 4fb5c613b..290ce687d 100644 --- a/posthog/test/test_async_consumer.py +++ b/posthog/test/test_async_consumer.py @@ -1,9 +1,12 @@ from __future__ import annotations import asyncio +import json from unittest import mock +import httpx import pytest +from freezegun import freeze_time from posthog._async_consumer import _AsyncConsumer from posthog.capture_compression import CaptureCompression @@ -60,6 +63,56 @@ async def test_request_retries_transient_failures_until_success( assert [call.args[0] for call in sleep.await_args_list] == expected_delays +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("retry_after", "expected_delay"), + [ + ("Tue, 08 Sep 2026 00:00:10 GMT", 10), + ("Tue, 08 Sep 2026 00:01:00 GMT", 30), + ("Mon, 07 Sep 2026 23:59:59 GMT", 1), + ("5", 5), + ("0", 1), + ("-1", 1), + ("invalid", 1), + (None, 1), + ], +) +async def test_request_honors_retry_after_from_http_response( + retry_after, expected_delay +): + headers = {"Retry-After": retry_after} if retry_after is not None else {} + responses = [ + httpx.Response(503, headers=headers, json={"detail": "temporary"}), + httpx.Response(200, json={"ok": True}), + ] + requests = [] + + def handle_request(request): + requests.append(request) + return responses.pop(0) + + consumer = make_consumer(retries=1) + batch = [{"event": "test", "distinct_id": "test-user"}] + async with httpx.AsyncClient( + base_url="https://example.com", transport=httpx.MockTransport(handle_request) + ) as client: + consumer.http_client = client + with ( + freeze_time("2026-09-08 00:00:00", real_asyncio=True), + mock.patch( + "posthog._async_consumer.asyncio.sleep", new=mock.AsyncMock() + ) as sleep, + ): + await consumer.request(batch) + + sleep.assert_awaited_once_with(expected_delay) + assert len(requests) == 2 + assert [json.loads(request.content)["batch"] for request in requests] == [ + batch, + batch, + ] + + @pytest.mark.asyncio @pytest.mark.parametrize("status", [400, 401, 413]) async def test_request_does_not_retry_terminal_client_errors(status):