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/capture-retry-404-429.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: patch
---

Capture retries `404` and `429` responses like `408` and `500`, `502`, `503`, `504`, with the same backoff and `Retry-After` handling.
2 changes: 1 addition & 1 deletion docs/migration-7.x-to-8.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ When the whole request is over the billing limit, capture returns a `402` with n
- The 7.x lines `error uploading: ...`, `async capture upload failed (...)` and `Immediate async capture failed (...)` are gone. Update any log-based alerts that match them.
- In `sync_mode`, a failed `capture()` calls `on_error` and returns `None`. With `debug=True` it calls `on_error` and then re-raises the error.
- If a capture inside `on_error` fails, the SDK logs the failure and does not call `on_error` again.
- The SDK retries `408`, `500`, `502`, `503` and `504` responses and network errors. Other error statuses fail the batch at once, including `429` and the other 5xx codes. 7.x also retried `429` and every 5xx. Capture itself does not return `429`; a proxy in front of it can.
- The SDK retries `404`, `408`, `429`, `500`, `502`, `503` and `504` responses and network errors. Other error statuses fail the batch at once, including the other 5xx codes. 7.x retried `429` and every 5xx, but not `404`. Capture itself does not return `429`; a proxy in front of it can. A `404` that lasts through every attempt can mean the PostHog deployment has no capture v1 route, for example an older self-hosted install.
- Each attempt makes one connection try. 7.x also retried a failed connection up to twice inside each attempt, so a capture request could block for up to three times the `timeout` per attempt.
- Retries start at 100 ms and double each attempt, up to 30 seconds. Each wait varies randomly by up to 25%, so many clients do not retry at the same moment. A `Retry-After` header sets a minimum wait, up to 30 seconds. The `Consumer` default is 3 retries, down from 10.
- The SDK's own log lines no longer contain event content: the debug line for a queued event names the event and the lane, and the invalid timestamp warning no longer includes the value. When your `before_send` hook raises, the error line names the event and the exception type, not the exception message, which can hold the data the hook was removing. The traceback is logged at debug level. To inspect events, use `before_send`.
Expand Down
9 changes: 4 additions & 5 deletions posthog/capture_send.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,12 @@
# Drop reason for a later copy of a uuid that capture discarded in favor of the first.
_DUPLICATE_EVENT_UUID = "duplicate_event_uuid"

# HTTP status classification. 429 is terminal in v1 (unlike v0, where it is
# retried) — the backend signals overload via retryable 5xx + Retry-After.
_RETRYABLE_STATUSES = frozenset({408, 500, 502, 503, 504})
# HTTP status classification. 404 and 429 can come from a proxy in front of capture.
_RETRYABLE_STATUSES = frozenset({404, 408, 429, 500, 502, 503, 504})
# Only these keep the method and body, so a resent batch is unchanged.
_REDIRECT_STATUSES = frozenset({307, 308})
_MAX_REDIRECTS = 5
_TERMINAL_STATUSES = frozenset({400, 401, 402, 413, 415, 429})
_TERMINAL_STATUSES = frozenset({400, 401, 402, 413, 415})

# Single ceiling (seconds) for the retry backoff: caps the exponential schedule
# and clamps a server ``Retry-After`` to the same value. Keeps the max retry
Expand Down Expand Up @@ -655,7 +654,7 @@ def capture_error(
continue

# Non-2xx. Retryable transient statuses back off; everything else
# (400/401/402/413/415/429/...) is terminal. Any drops collected from a
# (400/401/402/413/415/...) is terminal. Any drops collected from a
# prior 2xx attempt ride along so on_error still sees them.
v1_error = capture_error(
parsed.status_code,
Expand Down
18 changes: 12 additions & 6 deletions posthog/test/test_capture_send.py
Original file line number Diff line number Diff line change
Expand Up @@ -678,7 +678,7 @@ def test_malformed_2xx_is_terminal(self) -> None:
@parameterized.expand(
[
("bad_request", 400),
("rate_limited", 429),
("billing_limit", 402),
("unfollowed_redirect", 307),
("unfollowed_permanent_redirect", 308),
]
Expand All @@ -692,23 +692,29 @@ def test_terminal_status_raises_immediately(self, _name, status) -> None:
self.assertEqual(len(stub.calls), 1) # not retried
self.assertEqual(exc.status, status)

def test_retryable_status_then_success(self) -> None:
@parameterized.expand(
[("not_found", 404), ("rate_limited", 429), ("unavailable", 503)]
)
def test_retryable_status_then_success(self, _name, status) -> None:
stub = self._run(
[_msg("u-1")],
[
_FakeResponse(503, headers={"Retry-After": "2"}),
_FakeResponse(status, headers={"Retry-After": "2"}),
_results_response({"u-1": "ok"}),
],
)
self.assertEqual(len(stub.calls), 2)
self.sleep.assert_called_once_with(2.0) # honored Retry-After

def test_retryable_status_exhausted_raises(self) -> None:
@parameterized.expand(
[("not_found", 404), ("rate_limited", 429), ("unavailable", 503)]
)
def test_retryable_status_exhausted_raises(self, _name, status) -> None:
stub, exc = self._run_expecting_error(
[_msg("u-1")], [_FakeResponse(503), _FakeResponse(503)], max_retries=1
[_msg("u-1")], [_FakeResponse(status), _FakeResponse(status)], max_retries=1
)
self.assertEqual(len(stub.calls), 2)
self.assertEqual(exc.status, 503)
self.assertEqual(exc.status, status)

def test_transport_error_then_success(self) -> None:
stub = self._run(
Expand Down
Loading