feat(capture): v1 transport + partial-retry send loop (capture v1, 3/6)#703
feat(capture): v1 transport + partial-retry send loop (capture v1, 3/6)#703eli-r-ph wants to merge 4 commits into
Conversation
|
Reviews (1): Last reviewed commit: "feat(capture): add v1 transport and part..." | Re-trigger Greptile |
posthog-python Compliance ReportDate: 2026-07-03 19:14:43 UTC ✅ All Tests Passed!45/45 tests passed Capture Tests✅ 29/29 tests passed View Details
Feature_Flags Tests✅ 16/16 tests passed View Details
|
a901fdc to
7fd7dcd
Compare
419d8ac to
41c6948
Compare
7fd7dcd to
32d7b02
Compare
41c6948 to
d6d4aa2
Compare
32d7b02 to
3677400
Compare
Adds the HTTP transport for POST /i/v1/analytics/events alongside the pure transforms: a single Bearer-authed attempt (post_v1) with the required v1 headers, response classification (parse_v1_response), and the send loop (send_v1_batch) that resends only the events the server tags "retry", logs drops, honors Retry-After, and raises CaptureV1Error on terminal/transport failure or retry exhaustion so the consumer's existing on_error path fires unchanged. 429 is terminal in v1 (unlike v0). A 2xx with an unparseable body is terminal to avoid an infinite resend loop. A stable PostHog-Request-Id and created_at span attempts; PostHog-Attempt increments. Factors a shared gzip_compress helper out of request.post (no v0 behavior change). Still inert: nothing calls send_v1_batch until the consumer wiring PR. 74 capture_v1 tests (47 transform + 27 transport); ruff/mypy clean.
Address review of the v1 transport: - Hoist the batch created_at out of the retry loop so the envelope stays stable across attempts (only the events list and PostHog-Attempt change). - Isolate v1 request compression behind a CaptureCompression selector supporting gzip and zlib-wrapped deflate (RFC 1950), reverting the gzip_compress extraction from request.py so the v1 path owns its codecs. - Stop logging per-event drops at WARNING; a server-chosen drop on a 2xx is not a delivery failure and is already carried on CaptureV1Error for batch-level surfacing via on_error.
3677400 to
c698bd5
Compare
d6d4aa2 to
4ee420a
Compare
|
Reviews (2): Last reviewed commit: "fix(capture): stabilize v1 created_at, i..." | Re-trigger Greptile |
|
2 things here
posthog/capture_v1.py:510-526 Python collects drop results, but if there are no retry events it returns successfully and never surfaces the dropped Go does this differently: posthog-go/capture_v1_send.go:173-174 calls failure callback immediately for drop. Rust also surfaces 2xx drop/final retry outcomes through on_error: posthog-rs/src/client/transport.rs:835-850. This means Python users won’t be told that the backend explicitly rejected an event.
posthog/capture_v1.py:404-413 Python sleeps exactly Retry-After when present. Go/Rust treat Retry-After as a minimum and wait for max(configured_backoff, retry_after):
With a small Retry-After, Python retries earlier than the normal exponential backoff, which drifts from the canonical |
Address review of the v1 transport (#703): - Accumulate server `drop` verdicts across all attempts and raise CaptureV1Error even on a 2xx with no retry events (a success status is not full delivery) and when a later attempt clears the retries, so on_error sees every dropped uuid. Drops also ride along on the retry-exhaustion, malformed-2xx, and terminal non-2xx errors. - _backoff treats Retry-After as a minimum (max of configured backoff and Retry-After), hard-capped at 1 day, matching posthog-go/posthog-rs so a small header can't retry earlier than the schedule and a hostile one can't park the consumer thread. Adds drop-surfacing and backoff tests; regenerates public_api_snapshot.
|
👋 looked into it, PR parity issues are addressed, thanks for the callout! Also surfaced a couple of other small parity Issues across the 3 SDKs (some extending into prior defaults that effect v1, not v1-specific stuff) so opening a couple other tiny PRs to try to normalize so these things can be gated/smoke tested in a backend SDK-agnostic way as the porting continues 👍 |
Replace the 1-day RETRY_BACKOFF_CAP_SECONDS with a single MAX_BACKOFF_SECONDS (30s) that both caps the exponential backoff and clamps the server Retry-After, so the max retry wait is bounded and the default matches posthog-go/posthog-rs. Retry-After remains a minimum; there is no separate large cap.
|
👋 update on this:
Spot changes to those SDKs: |
💡 Motivation and Context
Third PR in the stacked Capture V1 series (stacked on #702). Adds the HTTP transport and partial-retry send loop for
POST /i/v1/analytics/events, on top of the pure transforms from #702. Still inert —send_v1_batchhas no caller until the consumer-wiring PR.New in
posthog/capture_v1.py:post_v1(...)— a single attempt. Bearer auth (noapi_keyin the body), the required v1 headers (PostHog-Sdk-Info,PostHog-Attempt,PostHog-Request-Id,PostHog-Request-Timestamp), and optional gzip. Returns the raw response; this is also the monkeypatch seam the test harness adapter will drive.parse_v1_response(...)— classifies one response without raising: 2xx parses the per-uuidresultsmap (an unparseable 2xx body is flaggedmalformed), non-2xx best-effort extracts an error message, andRetry-After(delta-seconds or HTTP-date) is parsed in both cases.send_v1_batch(...)— the v1 sibling ofConsumer._send. Loops up tomax_retries + 1attempts, but shrinks the batch to only the events the server taggedretryafter each 2xx.ok/warning/absent events succeed silently;dropevents are logged (a request the server accepted-but-dropped is not a delivery failure, so it is not raised). RaisesCaptureV1Error(anAPIErrorsubclass, so the consumer's existingon_error(exc, batch)keeps working) on a batch-level terminal/transport failure or once retries are exhausted.CaptureV1Error/V1ParsedResponse/V1EventResulttypes.Behavior choices, verified against the Rust contract and posthog-go's
capture_v1_send.go:Retry-After.PostHog-Request-Idandcreated_atspan all attempts;PostHog-Attemptincrements — so the backend can correlate/dedupe a retried batch.Retry-Afterwins, else capped exponential) so both wire protocols back off identically.Also factors a shared
gzip_compresshelper out ofrequest.post— pure refactor, no v0 behavior change (covered by the existingtest_request.py).💚 How did you test it?
posthog/test/test_capture_v1.pynow has 74 cases (47 transform + 27 transport). New transport coverage:post_v1header/url/no-key-in-body/gzip-magic;parse_v1_responsesuccess/malformed/missing-results/error-body-variants/text-fallback/Retry-After; andsend_v1_batchdriven by a stubbedpost_v1with mocked sleeps — all-ok, absent-uuid-accepted, partial-retry-shrinks-to-retry-uuids, stable-request-id + incrementing-attempt, drop-logged-not-raised, retry-exhausted-raises, malformed-2xx-terminal, 400/429-terminal-not-retried, 503-then-success (honorsRetry-After), 503-exhausted-raises, transport-error-then-success, transport-error-exhausted-reraises.ruff format/checkclean;mypyclean oncapture_v1.py+request.py;test_request.py(61) still green after the gzip refactor; regeneratedreferences/public_api_snapshot.txt.📝 Checklist
send_v1_batchhas no caller yet).🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Authored with Cursor (Claude Opus 4.8) per the agreed plan. posthog-go's
sendV1is woven into its client lifecycle (channels,notifyFailure/notifySuccess,maxAttempts); this port instead fits posthog-python'sConsumer._sendshape — a synchronous loop that raises on failure so the existingupload()->on_error(exc, batch)path fires unchanged — while preserving go's partial-retry algorithm, status matrix, stable-request-id semantics, and per-event drop/retry handling.