Skip to content

Commit ffd6bf1

Browse files
committed
fix(capture): retry capture v1 requests that get 404 or 429
With capture_mode="v1", 404 and 429 now retry like 408 and the 5xx statuses, with the same backoff, Retry-After handling and retry budget. They raise CaptureV1Error with their status after the last attempt. This matches the v1 branch (#1132) and the harness capture v1 retry_behavior suite.
1 parent 06e5611 commit ffd6bf1

4 files changed

Lines changed: 22 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: patch
3+
---
4+
5+
With `capture_mode="v1"`, capture now retries `404` and `429` responses like `408` and `500`, `502`, `503`, `504`, with the same backoff and `Retry-After` handling. Before, both failed the batch at once.

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Follow [Public API changes](./CONTRIBUTING.md#public-api-changes). As an agent,
2424

2525
Before changing capture configuration, serialization, routing, or retries, read the relevant implementation and tests.
2626

27-
Preserve v0 defaults/compatibility; strictly typed v1 options and `$set`/`$set_once` relocation; v1-only compression (zlib-wrapped deflate, optional zstd); partial-only per-event retries with stable identity; accumulated drop reporting even on 2xx; terminal v1 `429`; `Retry-After` as a minimum bounded by the shared 30s ceiling; and inline blocking retries with `sync_mode=True`.
27+
Preserve v0 defaults/compatibility; strictly typed v1 options and `$set`/`$set_once` relocation; v1-only compression (zlib-wrapped deflate, optional zstd); partial-only per-event retries with stable identity; accumulated drop reporting even on 2xx; v1 retries on `404` and `429` like `408` and 5xx; `Retry-After` as a minimum bounded by the shared 30s ceiling; and inline blocking retries with `sync_mode=True`.
2828

2929
## Mirror and build safety
3030

‎posthog/capture_v1.py‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,9 @@
8181
_RESULT_DROP = "drop"
8282
_RESULT_RETRY = "retry"
8383

84-
# HTTP status classification. 429 is terminal in v1 (unlike v0, where it is
85-
# retried) — the backend signals overload via retryable 5xx + Retry-After.
86-
_RETRYABLE_STATUSES = frozenset({408, 500, 502, 503, 504})
87-
_TERMINAL_STATUSES = frozenset({400, 401, 402, 413, 415, 429})
84+
# HTTP status classification. 404 and 429 can come from a proxy in front of capture.
85+
_RETRYABLE_STATUSES = frozenset({404, 408, 429, 500, 502, 503, 504})
86+
_TERMINAL_STATUSES = frozenset({400, 401, 402, 413, 415})
8887

8988
# Single ceiling (seconds) for the retry backoff: caps the exponential schedule
9089
# and clamps a server ``Retry-After`` to the same value. Keeps the max retry
@@ -591,7 +590,7 @@ def _send_v1_batch(
591590
continue
592591

593592
# Non-2xx. Retryable transient statuses back off; everything else
594-
# (400/401/402/413/415/429/...) is terminal. Any drops collected from a
593+
# (400/401/402/413/415/...) is terminal. Any drops collected from a
595594
# prior 2xx attempt ride along so on_error still sees them.
596595
v1_error = CaptureV1Error(
597596
parsed.status_code,

‎posthog/test/test_capture_v1.py‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -664,7 +664,7 @@ def test_malformed_2xx_is_terminal(self) -> None:
664664
self.assertEqual(len(stub.calls), 1)
665665
self.assertEqual(exc.status, 200)
666666

667-
@parameterized.expand([("bad_request", 400), ("rate_limited", 429)])
667+
@parameterized.expand([("bad_request", 400), ("billing_limit", 402)])
668668
def test_terminal_status_raises_immediately(self, _name, status) -> None:
669669
stub, exc = self._run_expecting_error(
670670
[_msg("u-1")],
@@ -674,23 +674,29 @@ def test_terminal_status_raises_immediately(self, _name, status) -> None:
674674
self.assertEqual(len(stub.calls), 1) # not retried
675675
self.assertEqual(exc.status, status)
676676

677-
def test_retryable_status_then_success(self) -> None:
677+
@parameterized.expand(
678+
[("not_found", 404), ("rate_limited", 429), ("unavailable", 503)]
679+
)
680+
def test_retryable_status_then_success(self, _name, status) -> None:
678681
stub = self._run(
679682
[_msg("u-1")],
680683
[
681-
_FakeResponse(503, headers={"Retry-After": "2"}),
684+
_FakeResponse(status, headers={"Retry-After": "2"}),
682685
_results_response({"u-1": "ok"}),
683686
],
684687
)
685688
self.assertEqual(len(stub.calls), 2)
686689
self.sleep.assert_called_once_with(2.0) # honored Retry-After
687690

688-
def test_retryable_status_exhausted_raises(self) -> None:
691+
@parameterized.expand(
692+
[("not_found", 404), ("rate_limited", 429), ("unavailable", 503)]
693+
)
694+
def test_retryable_status_exhausted_raises(self, _name, status) -> None:
689695
stub, exc = self._run_expecting_error(
690-
[_msg("u-1")], [_FakeResponse(503), _FakeResponse(503)], max_retries=1
696+
[_msg("u-1")], [_FakeResponse(status), _FakeResponse(status)], max_retries=1
691697
)
692698
self.assertEqual(len(stub.calls), 2)
693-
self.assertEqual(exc.status, 503)
699+
self.assertEqual(exc.status, status)
694700

695701
def test_transport_error_then_success(self) -> None:
696702
stub = self._run(

0 commit comments

Comments
 (0)