Skip to content
Merged
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/async-retry-after-date.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 2 additions & 12 deletions posthog/_async_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"]
Expand Down
53 changes: 53 additions & 0 deletions posthog/test/test_async_consumer.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
Expand Down
Loading