Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .changeset/android-v4-major.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/migration-3.x-to-4.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions posthog/src/main/java/com/posthog/internal/EndpointSpec.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions posthog/src/main/java/com/posthog/internal/PostHogApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
Expand Down
8 changes: 3 additions & 5 deletions posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
37 changes: 34 additions & 3 deletions posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading