From 57f4b8ebe16789b9471b965aed96d674b2b4cc56 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Tue, 27 Oct 2026 09:41:51 -0700 Subject: [PATCH 1/2] fix: retry capture requests that get 404 or 429 Co-Authored-By: Claude Opus 5.5 --- .sampo/changesets/capture-retry-404-429.md | 5 +++++ docs/migration-7.x-to-8.0.md | 2 +- posthog/capture_send.py | 9 ++++----- posthog/test/test_capture_send.py | 18 ++++++++++++------ 4 files changed, 22 insertions(+), 12 deletions(-) create mode 100644 .sampo/changesets/capture-retry-404-429.md diff --git a/.sampo/changesets/capture-retry-404-429.md b/.sampo/changesets/capture-retry-404-429.md new file mode 100644 index 000000000..7f72747c1 --- /dev/null +++ b/.sampo/changesets/capture-retry-404-429.md @@ -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. diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index edb16f146..6a033785d 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -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. - 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`. diff --git a/posthog/capture_send.py b/posthog/capture_send.py index 4d0e01d91..0cdf533ca 100644 --- a/posthog/capture_send.py +++ b/posthog/capture_send.py @@ -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 @@ -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, diff --git a/posthog/test/test_capture_send.py b/posthog/test/test_capture_send.py index a50d62c7e..2d7eb44df 100644 --- a/posthog/test/test_capture_send.py +++ b/posthog/test/test_capture_send.py @@ -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), ] @@ -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( From 096206f82927b42bc9efca8db35cc9b365be326d Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Tue, 27 Oct 2026 13:53:31 -0700 Subject: [PATCH 2/2] docs: explain a lasting 404 in the 8.0 migration guide Co-Authored-By: Claude Opus 5.5 --- docs/migration-7.x-to-8.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index 6a033785d..29da4f0db 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -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 `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. +- 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`.