diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index 60ec1b526..d6b6ab1c1 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, 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 - **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/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index 2fde08f2b..318fdee63 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,20 @@ 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)) { + // 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( + "$safeUrl 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/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..1239204e9 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt @@ -416,6 +416,37 @@ 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 `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")) @@ -706,9 +737,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