From 6d9c2c05871a76e62ac404103c6d407ba8b637bb Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 14:23:47 -0400 Subject: [PATCH 1/3] fix: retry capture v1 requests that get 404 or 429 A 404 usually means a misconfigured proxy and a 429 now only comes from an overloaded ingress, so keep the events queued and retry them with the usual backoff and Retry-After handling instead of dropping them. Matches PostHog/posthog-go#395. --- .changeset/android-v4-major.md | 4 ++-- docs/migration-3.x-to-4.0.md | 6 +++--- .../src/main/java/com/posthog/internal/EndpointSpec.kt | 7 ++++--- posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt | 8 +++----- .../test/java/com/posthog/internal/EndpointSpecTest.kt | 4 ---- .../java/com/posthog/internal/PostHogCaptureV1Test.kt | 6 +++--- .../test/java/com/posthog/internal/PostHogQueueTest.kt | 8 +++++--- 7 files changed, 20 insertions(+), 23 deletions(-) diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index 60ec1b526..3fa78f21e 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -11,8 +11,8 @@ - **Breaking:** return `null` instead of the raw string for a malformed, empty or whitespace-only feature flag payload from `getFeatureFlagResult(key)?.payload`; payloads must be valid JSON (a JSON `null` payload now also reads as `null` instead of the string `"null"`) - **Breaking:** make SDK-internal helpers in the `com.posthog.internal` and `com.posthog.android.replay.internal` packages non-public - **Breaking:** remove the deprecated `remoteConfig` config option; the SDK now always loads remote config at setup -- **Breaking:** send events to capture v1 (`/i/v1/analytics/events`) with the project token in an `Authorization: Bearer` header; if you use a reverse proxy or self-host PostHog, make sure the path is served, because a 404 or other non-retried response drops the events, and authenticate your proxy with a header other than `Authorization`, which capture requests replace -- **Breaking:** stop retrying 429 and 3xx responses for events, and cap a `Retry-After` pause at 30 seconds for events, session replay and logs +- **Breaking:** send events to capture v1 (`/i/v1/analytics/events`) with the project token in an `Authorization: Bearer` header; if you use a reverse proxy or self-host PostHog, make sure the path is served, because events stay queued and are retried on a 404, and authenticate your proxy with a header other than `Authorization`, which capture requests replace +- **Breaking:** stop retrying 3xx responses for events, retry 404 responses, and cap a `Retry-After` pause at 30 seconds for events, session replay and logs - **Breaking:** add up to 25% random jitter to the wait after a failed send, which still doubles from 1 second to 30 seconds, and treat a `Retry-After` as a minimum wait instead of replacing the backoff, for events, session replay and logs; queued records are still kept until they're delivered - **Breaking:** follow only a 307 or 308 redirect of a capture request to the same origin as `host`, at most 5 times; any other redirect drops the events, so point `host` at the final host - **Breaking:** send the `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties as capture options (`PostHogEvent.options`, a new trailing constructor parameter that Java callers of the full constructor and `copy` must pass), where PostHog drops an event whose option value it can't read diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 267ef2a93..daf5b3bee 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -24,7 +24,7 @@ You need to change something if your app does any of these: - registers a super property, or passes an event property, with the same key as one the SDK sets, such as `$os_name` or `$is_identified`, and expects the SDK's value to win - calls `capture`, `screen`, `identify`, `setPersonProperties`, `alias`, `group` or `captureException` from Java, implements `PostHogInterface`, or calls `identify` through a `PostHogCoreInterface` reference - sets the event `uuid` in `beforeSend` -- relies on 429 responses being retried, or on a `Retry-After` longer than 30 seconds +- relies on a `Retry-After` longer than 30 seconds - sets `maxRetries` on `PostHogConfig` or `PostHogAndroidConfig` ## Endpoints @@ -57,7 +57,7 @@ If you send events through your own reverse proxy, update it before you upgrade: PostHog's managed reverse proxy already does this. See the [proxy guides](https://posthog.com/docs/advanced/proxy). -The SDK doesn't fall back to `/batch`. A 404, or any other error status that isn't retried, such as 403 or 405, drops the events. +The SDK doesn't fall back to `/batch`. A 404 is retried, so the events stay queued until the path is served. Any other error status that isn't retried, such as 403 or 405, drops the events. ### Redirects @@ -172,7 +172,7 @@ PostHog.captureAi("$ai_generation", properties = mapOf("$ai_model" to "gpt-5", " ## Retries -- The SDK retries network errors and 408, 500, 502, 503 and 504 responses. Other statuses drop the events, including 429. 3.x also retried 429 and 3xx responses. Capture itself doesn't return 429, but a proxy in front of it can. +- The SDK retries network errors and 404, 408, 429, 500, 502, 503 and 504 responses. Other statuses drop the events, including 3xx. 3.x also retried 3xx responses, and didn't retry 404. - When the response asks to retry only some events, the SDK keeps those events queued and removes the rest. - A failed request is resent with the same events, request ID and `created_at`, and the next attempt number, before newer events are sent. OkHttp doesn't retry capture requests on its own. - A `Retry-After` header pauses sending for at most 30 seconds. This also applies to session replay and logs. In 3.x there was no cap. diff --git a/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt b/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt index 81fe9cf9e..41abd466f 100644 --- a/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt +++ b/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt @@ -194,10 +194,11 @@ internal class SendResult( ) /** - * Capture v1 retry policy: 408 and the listed 5xx. Everything else, including 429 and 3xx, is - * terminal; 413 is handled by the queue's batch halving. + * Capture v1 retry policy: 404, 408, 429 and the listed 5xx. A 404 usually means a misconfigured + * proxy and a 429 an overloaded ingress, so neither drops events. Everything else, including 3xx, + * is terminal; 413 is handled by the queue's batch halving. */ -internal fun isCaptureV1RetriableStatusCode(code: Int): Boolean = code == 408 || code in setOf(500, 502, 503, 504) +internal fun isCaptureV1RetriableStatusCode(code: Int): Boolean = code in setOf(404, 408, 429, 500, 502, 503, 504) /** Soft request size for the capture v1 analytics and AI endpoints, well under their body limits. */ internal const val CAPTURE_V1_MAX_BATCH_BYTES: Long = 5L * 1024 * 1024 diff --git a/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt b/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt index b5e4bbd79..0e0e04567 100644 --- a/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt @@ -168,18 +168,16 @@ internal class PostHogCaptureAiTest { } @Test - fun `a 404 from the AI endpoint is terminal`() { - val http = httpServers.mockHttp(total = 2, response = MockResponse().setResponseCode(404)) + fun `a 404 from the AI endpoint keeps the event queued`() { + val http = httpServers.mockHttp(response = MockResponse().setResponseCode(404)) val sut = getSut(http) sut.captureAi("\$ai_generation") aiExecutor.awaitExecution() - sut.flush() - aiExecutor.awaitExecution() assertEquals(1, http.requestCount) assertEquals("/i/v1/ai/events", takeRequest(http).path) - assertTrue(aiQueueFiles().isEmpty()) + assertEquals(1, aiQueueFiles().size) } @Test diff --git a/posthog/src/test/java/com/posthog/internal/EndpointSpecTest.kt b/posthog/src/test/java/com/posthog/internal/EndpointSpecTest.kt index 73a68f956..12946becc 100644 --- a/posthog/src/test/java/com/posthog/internal/EndpointSpecTest.kt +++ b/posthog/src/test/java/com/posthog/internal/EndpointSpecTest.kt @@ -274,13 +274,9 @@ internal class EndpointSpecTest { @Test fun `events spec does not retry non-retriable status codes`() { - // Events keeps a curated retry set (408, 500, 502, 503, 504); it must not - // retry 429, 3xx, arbitrary 4xx, nor the extra 5xx codes the broad logs - // policy (500..599) covers. Guards against widening the set by mistake. assertEquals(false, isCaptureV1RetriableStatusCode(307)) assertEquals(false, isCaptureV1RetriableStatusCode(400)) assertEquals(false, isCaptureV1RetriableStatusCode(413)) - assertEquals(false, isCaptureV1RetriableStatusCode(429)) assertEquals(false, isCaptureV1RetriableStatusCode(501)) assertEquals(false, isCaptureV1RetriableStatusCode(505)) assertEquals(false, isCaptureV1RetriableStatusCode(599)) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt index f2b4ea503..3fa763e68 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt @@ -706,9 +706,9 @@ internal class PostHogCaptureV1Test { } @Test - fun `queue drops the batch on 429 and keeps it on 503`() { - val http = httpServers.mockHttp(response = MockResponse().setResponseCode(429)) - http.enqueue(MockResponse().setResponseCode(503)) + fun `queue drops the batch on 400 and keeps it on 429`() { + val http = httpServers.mockHttp(response = MockResponse().setResponseCode(400)) + http.enqueue(MockResponse().setResponseCode(429)) val sut = queue(http) sut.add(event()) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt index 9ad84b2e7..8e007ccc4 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt @@ -990,12 +990,14 @@ internal class PostHogQueueTest { } @Test - fun `does not retry on 429`() { - val e = PostHogApiError(429, "", null) + fun `retries on 404 and 429`() { val config = PostHogConfig(PROJECT_TOKEN) val limits = initialBatchLimits(config) - assertTrue(deleteFilesIfAPIError(e, limits, actualBatchSize = limits.cap, logger = config.logger)) + for (status in listOf(404, 429)) { + val e = PostHogApiError(status, "", null) + assertFalse(deleteFilesIfAPIError(e, limits, actualBatchSize = limits.cap, logger = config.logger)) + } } @Test From c112fff7fccb85417c258130bbe70c423663a10e Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 14:49:54 -0400 Subject: [PATCH 2/3] fix: warn once when capture v1 returns 404 A 404 now keeps events queued, so nothing tells the app that the route is missing. Print one warning per client, even without debug, naming the URL and the path a reverse proxy or self-hosted instance must serve. --- .changeset/android-v4-major.md | 2 +- .../java/com/posthog/internal/PostHogApi.kt | 10 ++++++++++ .../posthog/internal/PostHogCaptureV1Test.kt | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index 3fa78f21e..d6b6ab1c1 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -11,7 +11,7 @@ - **Breaking:** return `null` instead of the raw string for a malformed, empty or whitespace-only feature flag payload from `getFeatureFlagResult(key)?.payload`; payloads must be valid JSON (a JSON `null` payload now also reads as `null` instead of the string `"null"`) - **Breaking:** make SDK-internal helpers in the `com.posthog.internal` and `com.posthog.android.replay.internal` packages non-public - **Breaking:** remove the deprecated `remoteConfig` config option; the SDK now always loads remote config at setup -- **Breaking:** send events to capture v1 (`/i/v1/analytics/events`) with the project token in an `Authorization: Bearer` header; if you use a reverse proxy or self-host PostHog, make sure the path is served, because events stay queued and are retried on a 404, and authenticate your proxy with a header other than `Authorization`, which capture requests replace +- **Breaking:** send events to capture v1 (`/i/v1/analytics/events`) with the project token in an `Authorization: Bearer` header; if you use a reverse proxy or self-host PostHog, make sure the path is served, because events stay queued and are retried on a 404, with a warning printed once even when `debug` is off, and authenticate your proxy with a header other than `Authorization`, which capture requests replace - **Breaking:** stop retrying 3xx responses for events, retry 404 responses, and cap a `Retry-After` pause at 30 seconds for events, session replay and logs - **Breaking:** add up to 25% random jitter to the wait after a failed send, which still doubles from 1 second to 30 seconds, and treat a `Retry-After` as a minimum wait instead of replacing the backoff, for events, session replay and logs; queued records are still kept until they're delivered - **Breaking:** follow only a 307 or 308 redirect of a capture request to the same origin as `host`, at most 5 times; any other redirect drops the events, so point `host` at the final host diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index 2fde08f2b..a2d42b2a1 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -31,6 +31,7 @@ import java.net.SocketTimeoutException import java.net.UnknownHostException import java.util.UUID import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import javax.net.ssl.SSLException /** @@ -62,6 +63,9 @@ public class PostHogApi( } } + // A 404 keeps the events queued, so the warning is printed once instead of on every retry. + private val warnedCaptureNotFound = AtomicBoolean(false) + private val captureMediaType by lazy { try { APP_JSON.toMediaType() @@ -221,6 +225,12 @@ public class PostHogApi( // The status only: the results name event uuids. config.logger.log("Response ${response.request.url}: ${response.code}") + if (response.code == 404 && warnedCaptureNotFound.compareAndSet(false, true)) { + config.logger.logWarning( + "$url returned 404. Events stay queued and are retried. " + + "If you use a reverse proxy or self-host PostHog, make sure it serves $path.", + ) + } if (response.code != 307 && response.code != 308) { return captureV1Result(response, path, events, identity) } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt index 3fa763e68..5a9fa2f6c 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt @@ -416,6 +416,23 @@ internal class PostHogCaptureV1Test { assertTrue(result.retryIds.isEmpty()) } + @Test + fun `a 404 warns once with the capture URL`() { + val http = httpServers.mockHttp(total = 2, response = MockResponse().setResponseCode(404)) + val host = http.url("/").toString().trimEnd('/') + val sut = api(host) + + repeat(2) { assertThrows(PostHogApiError::class.java) { sut.captureV1(listOf(event())) } } + + assertEquals( + listOf( + "$host/i/v1/analytics/events returned 404. Events stay queued and are retried. " + + "If you use a reverse proxy or self-host PostHog, make sure it serves /i/v1/analytics/events.", + ), + logger.warnings, + ) + } + @Test fun `a 503 with Retry-After 0 is not resent by the HTTP client`() { val http = httpServers.mockHttp(response = MockResponse().setResponseCode(503).setHeader("Retry-After", "0")) From 5346aa9394b36b075ae383e75d4144cc0076bbf6 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 10:31:07 -0400 Subject: [PATCH 3/3] fix: leave host credentials and query out of the capture v1 404 warning Co-Authored-By: Claude Opus 5.5 --- .../main/java/com/posthog/internal/PostHogApi.kt | 10 +++++++++- .../com/posthog/internal/PostHogCaptureV1Test.kt | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index a2d42b2a1..318fdee63 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -226,8 +226,16 @@ public class PostHogApi( config.logger.log("Response ${response.request.url}: ${response.code}") if (response.code == 404 && warnedCaptureNotFound.compareAndSet(false, true)) { + // Warnings log even with debug off, so drop any credentials or query the host carries. + val safeUrl = + response.request.url.newBuilder() + .username("") + .password("") + .query(null) + .fragment(null) + .build() config.logger.logWarning( - "$url returned 404. Events stay queued and are retried. " + + "$safeUrl returned 404. Events stay queued and are retried. " + "If you use a reverse proxy or self-host PostHog, make sure it serves $path.", ) } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt index 5a9fa2f6c..1239204e9 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt @@ -433,6 +433,20 @@ internal class PostHogCaptureV1Test { ) } + @Test + fun `the 404 warning leaves out credentials and the query in the host`() { + val http = httpServers.mockHttp(response = MockResponse().setResponseCode(404)) + val origin = http.url("/").toString().trimEnd('/') + val host = origin.replace("://", "://proxy-user:proxy-password@") + "?secret=s3cr3t" + val sut = api(host) + + assertThrows(PostHogApiError::class.java) { sut.captureV1(listOf(event())) } + + val warning = logger.warnings.single() + assertTrue(warning.startsWith("$origin/"), warning) + listOf("proxy-user", "proxy-password", "s3cr3t").forEach { assertFalse(warning.contains(it), warning) } + } + @Test fun `a 503 with Retry-After 0 is not resent by the HTTP client`() { val http = httpServers.mockHttp(response = MockResponse().setResponseCode(503).setHeader("Retry-After", "0"))