diff --git a/.sampo/changesets/capture-retry-404-429.md b/.sampo/changesets/capture-retry-404-429.md new file mode 100644 index 00000000..7f72747c --- /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 edb16f14..29da4f0d 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. 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`. diff --git a/posthog/capture_send.py b/posthog/capture_send.py index 4d0e01d9..0cdf533c 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 a50d62c7..2d7eb44d 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(