fix(queue): preserve durable records across retries - #788
dustinbyrne merged 17 commits into
Conversation
posthog-ios Compliance ReportDate: 2026-09-10 22:49:55 UTC
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 3227ms |
| Format Validation.Event Has Uuid | ✅ | 2872ms |
| Format Validation.Event Has Lib Properties | ✅ | 2903ms |
| Format Validation.Distinct Id Is String | ✅ | 2879ms |
| Format Validation.Token Is Present | ✅ | 2996ms |
| Format Validation.Custom Properties Preserved | ✅ | 3147ms |
| Format Validation.Event Has Timestamp | ✅ | 2848ms |
| Retry Behavior.Retries On 503 | ✅ | 11722ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 4880ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 2494ms |
| Retry Behavior.Respects Retry After Header | ✅ | 5435ms |
| Retry Behavior.Implements Backoff | ✅ | 21355ms |
| Retry Behavior.Retries On 500 | ✅ | 9415ms |
| Retry Behavior.Retries On 502 | ✅ | 9378ms |
| Retry Behavior.Retries On 504 | ✅ | 9403ms |
| Retry Behavior.Max Retries Respected | ❌ | 21906ms |
| Deduplication.Generates Unique Uuids | ✅ | 3349ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 9387ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 16941ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 7992ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 3023ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 3037ms |
| Compression.Sends Gzip When Enabled | ✅ | 495ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 3001ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 330ms |
| Batch Format.Multiple Events Batched Together | ✅ | 3138ms |
| Error Handling.Does Not Retry On 403 | ✅ | 4927ms |
| Error Handling.Does Not Retry On 413 | ✅ | 5064ms |
| Error Handling.Retries On 408 | ✅ | 5534ms |
Failures
retry_behavior.max_retries_respected
Expected 4 requests, got 6
Feature_Flags Tests
✅ 16/16 tests passed
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 3145ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 2852ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 3046ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 2962ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 3045ms |
| Request Payload.Groups Round Trip | ✅ | 2969ms |
| Request Payload.Groups Default To Empty Object | ✅ | 2994ms |
| Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It | ✅ | 2985ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 3062ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ✅ | 2992ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 3128ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 166ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 3021ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 5665ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 3157ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 3240ms |
|
PostHog Review alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏 |
There was a problem hiding this comment.
PostHog Review
Found 3 must fix, 3 should fix, 1 consider.
Other findings (outside the changed lines)
Valid issues on this PR's files that sit on lines GitHub won't let us comment on inline.
Report drops from every queue
Priority: should_fix | File: PostHog/PostHogSDK.swift:228-246 | Category: bug
Why we think it's a valid issue
- Checked: every
PostHogQueueinstantiation in the SDK, the singleonRecordsDroppedassignment, thePostHogReplayQueuewrapper, the retry policies that can reach a full drop for each queue, and the changeset text this PR ships. - Found: three queues run the same generic class, and only one is wired.
queueandlogsQueueare both built at the same call site (PostHog/PostHogSDK.swift:229-235), but the callback at :241-246 is set onqueuealone. The replay queue's inner instance isprivate let innerQueue: PostHogQueue<PostHogEvent>(PostHog/Replay/PostHogReplayQueue.swift:11, :39, :47), and the wrapper exposes no forwarding property, so the SDK cannot reach it even if it tried. - Found: both unwired queues can genuinely reach a full drop.
dropAllQueuedRecordsfires from the time-window path (PostHog/PostHogQueue.swift:187-188) and the repeated-413 path (:217-218). The logs endpoint retries 408, 429, and 5xx (PostHog/QueueEndpoint+Factories.swift:103-105) and the snapshot endpoint shares the events policy (:57), so each can sit in a failing streak and each can be answered with 413. Replay is the most exposed of the three: snapshots are the largest payloads, which is what makes 413 handling necessary at all. - Found: the promise is stated without qualification in shipping copy.
.changeset/quick-queues-keep.md— the text that becomes the public changelog — says "every drop emits a$queue_records_droppeddiagnostic event so the loss is measurable", and the callback's own doc comment at PostHog/PostHogQueue.swift:73-77 calls a full queue drop "the last remaining silent, total data-loss path". - Impact: for buffered logs and replay snapshots the silent data-loss path this PR exists to close stays open — a full wipe still leaves only a
hedgeLogline at PostHog/PostHogQueue.swift:260, so nobody can see it from ingested data. Either the two remaining queues need wiring or the changelog claim needs narrowing before release, since the current pair contradicts each other. - Impact: the reviewer's request for a queue-type property is load-bearing rather than extra scope. The event carries only
dropped_countandreasontoday, so once more than one queue reports, the three sources are indistinguishable in the captured data.
Issue description
Only the analytics queue receives onRecordsDropped. The logs queue and the inner replay queue use PostHogQueue but have no callback. Their full drops remain silent, although the changeset promises that every drop emits the diagnostic event.
Suggested fix
Wire callbacks for analytics, logs, and replay. Expose a forwarding callback on PostHogReplayQueue. Add a queue type property to the diagnostic event. Test one drop for each queue.
9335ffb to
1033049
Compare
| payload.completion(true) | ||
| return | ||
| } | ||
| let newCount = nextRetryCount() |
There was a problem hiding this comment.
Compliance is failing on the latest run — retry_behavior.max_retries_respected: Expected 4 requests, got 6 (45/45 on #786, 44/45 here), and the compliance-tests check reports pass regardless, so this merges red.
The spec left the attempt ceiling optional ("MAY end the active sequence"), so the suite is arguably the stale part — but Android passes the same change only because its backoff is exponential where ours is linear, which means it's passing by accident. I think we need the suite asserting retention instead of attempt count before this lands, or a ceiling here that ends the sequence without clearing the queue.
There was a problem hiding this comment.
Confirmed, and I'm leaving this for a human rather than guessing at it.
The failure is real and reproduces by construction, not by timing. After this change a retryable failure never ends the flush sequence — the retry counter now only feeds the backoff delay, and it stops growing once the delay reaches the 30s ceiling. So the pause sequence is 1s, 2s, 3s … then 30s forever, and the number of attempts inside any fixed harness window is unbounded. Expected 4 requests can't be reached by tuning the delays; it needs either a real ceiling that ends the sequence, or a different assertion.
Both of the options you named are decisions rather than mechanical fixes, so I've stopped here:
-
A ceiling that ends the sequence without clearing the queue. The hard part isn't ending the sequence, it's what re-arms it — a new event being enqueued, a reachability transition, app foreground, the next explicit
flush(), or simply a much longer pause. Those behave very differently for a user who backgrounds the app during an outage. It also needs a call on which knob bounds it: reusingmaxRetriescontradicts this PR's deliberate re-scoping of that option to push-subscription registration (which is itself an open thread here), and a new bound is a new public surface. And I can't prove any of it in this session — the harness lives in the posthog-sdk-test-harness repo and the job needs macOS plus Docker, so I can't show that a ceiling actually turns 6 requests into 4, or that it lands the same place as posthog-android#740. -
The suite asserting retention instead of attempt count. That's an edit in the harness repo, outside this PR.
On "this merges red": also correct. The compliance job sets continue-on-error: true on the test step, and the Fail if tests failed gate is still commented out behind a "once compliance tests are stable" TODO — so the check is advisory by design right now. Turning it into a real gate is a workflow-file change, which I don't make unattended; it needs a deliberate decision from whoever owns that workflow, ideally alongside whichever of the two options above you pick.
One data point in favour of your "the suite is the stale part" reading: this PR's own unit tests already assert the retention contract directly — one covers retryable HTTP failures past maxRetries draining after recovery, and another does the same for transport failures. If the harness asserted retention, those would be the same property expressed at two levels instead of two suites disagreeing. I didn't verify the claim about Android's backoff being exponential myself — I only confirmed ours is linear.
So: a human needs to choose between the attempt ceiling (and its re-arm trigger and its bound) and changing the harness assertion, and separately decide whether to make the compliance check blocking. Nothing committed for this thread.
| /// dropped to avoid infinite retries against a permanently-broken backend. | ||
| /// Increments on every retriable failure including HTTP 413 cap halving; | ||
| /// resets on a successful 2xx response. Default 3. | ||
| /// Maximum number of retries for push-subscription registration failures. |
There was a problem hiding this comment.
[question] After this the queue never reads maxRetries — the only caller left is PostHogPushSubscriptionHandler. Anyone who lowered it to bound retry traffic against a broken backend now gets unbounded retries, and nothing fails at build time. Is patch the intent here, or should this be a minor with the semantics change spelled out?
There was a problem hiding this comment.
Your reading of the code is right — maxRetries now has exactly one production reader left, the push-subscription retry ceiling, and no queue reads it any more. Both halves of the question you asked have since been answered in the branch, after you wrote this:
patchwas not the intent. Commit 3774ddf changed the bump frompatchtominor; that one-line change is the whole commit.- The semantics change is now spelled out. Commit e4284e5 expanded the changeset from a single sentence into a bulleted list, and one bullet is exactly this: "Limit
maxRetriesto push-subscription registration; it no longer bounds event, replay, or log queue flush attempts."
At the commit you were looking at the entry really was a bare patch with no mention of maxRetries at all, so the comment was accurate when written. The property's own doc comment now states the narrowed scope too, so someone reading the declaration in an editor sees it, not just someone reading release notes.
The other half of what you raised — that anyone who lowered maxRetries to bound retry traffic against a broken backend now gets unbounded retries — is real and is not closed by relabelling the release. That is the same decision as your comment on the queue's retry path, and I've escalated it there rather than answering it twice: it needs a human call on whether to add a ceiling that ends the active flush sequence without clearing the queue, and what bound that ceiling reads.
I deliberately didn't reach for the two obvious-looking mitigations here. Marking maxRetries deprecated would be wrong — it is still live and load-bearing for push registration, so a deprecation warning would tell users to stop using a working option, and it would churn the public API snapshot. And there's no honest way to make a silently re-scoped option fail at build time short of a real public API break (rename or split into two options), which is a maintainer decision about backwards compatibility, not something I should land unattended. If you do want the option split — say maxRetries for push and a separate bound for queue flushes — that's worth deciding alongside the ceiling question on the other thread, since the two answers constrain each other.
Nothing committed for this thread.
| "posthog-ios": patch | ||
| --- | ||
|
|
||
| Preserve bounded durable event, replay, and log queues across retryable upload failures, and acknowledge successful batches by their exact persisted entry identities. |
There was a problem hiding this comment.
[suggestion] Three user-visible changes aren't in the entry:
| Preserve bounded durable event, replay, and log queues across retryable upload failures, and acknowledge successful batches by their exact persisted entry identities. | |
| - Limit `maxRetries` to push-subscription registration — it no longer bounds event queue flush attempts. | |
| - Preserve durable event, replay, and log queues across retryable upload failures instead of clearing them. | |
| - Fix successful flushes deleting never-sent records when a full queue is replaced mid-flight. | |
| - Trim persisted queues to `maxQueueSize` when loading them from disk. | |
| - Stop retrying uploads that returned a terminal HTTP status alongside a transport error. |
There was a problem hiding this comment.
All five of these are in the entry now — it was rewritten after you commented, which is why GitHub has marked this thread outdated.
At the commit you were reviewing the changeset was a single sentence under a patch bump. Commit e4284e5 replaced it with a bulleted list covering every item you listed, and 3774ddf changed the bump to minor. Mapping your suggestion to what's there today:
maxRetriesnarrowed to push-subscription registration — present, and stated a bit broader than you wrote it: it names the replay and log queues as well as events, since none of the three read the option any more.- Durable queues preserved across retryable upload failures instead of being cleared — present, near-verbatim.
- Successful flushes no longer deleting never-sent records — present, phrased as the mechanism rather than the symptom ("acknowledge successful and terminal uploads by their exact persisted entry identities so full-queue replacements accepted during an upload are not deleted").
- Persisted queues trimmed on load — present, and it also names
logs.maxBufferSizefor the logs queue and says the trim is FIFO. - Terminal HTTP status alongside a transport error — present, and it covers both halves of that change: the status is honored (terminal responses remove the sent entries) and a
Retry-Afterthat arrived with the failed transfer is no longer thrown away.
There's also a sixth bullet you wouldn't have seen, for preserving existing queued records when a write into a full queue fails — that change landed after your comment.
One reason I didn't just click your suggestion through: applying it literally now would overwrite the current list and drop that sixth bullet, so it would take the entry backwards even though the ask behind it was right. Nothing committed for this thread — the entry already says all of it.
| if let error { | ||
| hedgeLog("Error calling the \(endpointName) API: \(error).") | ||
| return completion(PostHogUploadInfo(statusCode: nil, error: error)) | ||
| return completion(PostHogUploadInfo(statusCode: httpResponse?.statusCode, error: error, retryAfter: retryAfter)) |
There was a problem hiding this comment.
[question] Passing the status through here means an error alongside a 200 or a 4xx now removes the batch, where before statusCode: nil made it retriable and it stayed queued. That reads right to me (the server already has it on 200, 400 is terminal) — just checking it's deliberate, since it's a drop path landing in a PR about not dropping.
There was a problem hiding this comment.
Yes, deliberate — and your reasoning for why it's right matches the intent.
The framing that resolves the tension you spotted: the contract this PR is enforcing is "don't drop records whose delivery outcome is unknown." A status line that actually arrived is a known outcome, so acting on it isn't the kind of drop the PR is trying to prevent. The unknown case still retains — when no HTTP response arrives at all the status is nil, which the queue maps to its retryable sentinel and keeps the records. Previously a transport error discarded the status even when the server had already answered, which meant a 200 or a 400 got retried indefinitely on the strength of a broken response-body read.
Two details worth having, since they narrow the blast radius further than "removes the batch":
- Removal is by exact persisted entry identity, not by count or position. A record enqueued while the upload was in flight is not removed, even on a 200 that arrived with an error.
- The behavior is pinned by tests added in this PR, at both layers. At the API layer, a test asserts the status survives alongside an error across 200, 400, 408, 429 and 503 — 200 and 400 are explicit cases, so a future change that reverts to discarding the status will fail. At the queue layer, "received HTTP disposition wins over an accompanying transport error" runs the same status set for both the events and replay queues and asserts the split you described: retryable statuses keep every record, terminal ones remove only the entries that were actually sent. It then reloads the queue from disk to confirm the durable state, not just the in-memory view.
There is also a code comment on the hoisted header parse explaining the underlying URLSession behavior — headers can arrive and the transfer can still fail afterwards — so the reason this branch keeps the status is recorded where someone will read it.
One thing I'm deliberately not claiming: this helper is shared with the push-subscription register/delete calls, and that consumer's retryable set is narrower than the queues' (429 and 5xx only). So the status pass-through does land differently there. There's a separate comment on this same line about that case; I'm assessing it on its own thread rather than folding it into this answer.
No change made for this thread.
| return evicted | ||
| } catch { | ||
| hedgeLog("Could not write file \(error)") | ||
| return nil |
There was a problem hiding this comment.
[nit] If the write throws we've already evicted and deleted the oldest entry, so we lose a record and return nil — which the caller reads as "nothing was evicted" and skips the "Queue is full" log. Evicting only after a successful write would keep the two in step.
There was a problem hiding this comment.
Fixed already — commit e4284e5 does exactly what you suggested. The write was moved ahead of the capacity check, so the eviction only happens once the new record is safely on disk. It's a pure reorder of three lines inside the same lock.
So the failure path now behaves the way you wanted: if the write throws, nothing has been evicted and nothing deleted, the existing records are intact, and the nil return honestly means "nothing was evicted" rather than hiding a lost record. The caller's "Queue is full" log is back in step too, since a non-nil return is now only reachable after a successful write.
The same commit added a regression test for it. It fills a queue with capacity 2, makes the directory read-only so the write really does throw, and then asserts the add returns nil, the depth is still 2, the two original entries are unchanged and still readable, and the directory contains exactly the original two files — so a half-written file can't be left behind either. It then restores write permission and asserts a successful add does return the evicted id, which pins both directions of the nil contract, not just the failure case.
Two details worth knowing, since neither is obvious from the diff:
- On the success path the new file is written before the capacity check, so the directory briefly holds one more file than the configured maximum. The in-memory index never does, because the eviction runs before the new name is appended — and because the new name isn't in the index yet, a record can't evict itself. Nothing after the write in that block can throw, so the reorder can't orphan the file it just wrote.
- The regression test is skipped when the test suite runs as root, since chmod doesn't restrict root and the write would succeed. That's fine for the macOS CI runners, which run as a normal user, but worth remembering if anyone runs the suite in a root container and wonders why the case looks uncovered.
Nothing committed for this thread.
| let httpResponse = response as? HTTPURLResponse | ||
| // Parsed before the error branch: URLSession can deliver headers and then fail the | ||
| // transfer, and a rate-limited response still carries the delay the server asked for. | ||
| let retryAfter = httpResponse.flatMap { $0.value(forHTTPHeaderField: "Retry-After") }.flatMap(parseRetryAfter) | ||
|
|
||
| if let error { | ||
| hedgeLog("Error calling the \(endpointName) API: \(error).") | ||
| return completion(PostHogUploadInfo(statusCode: nil, error: error)) | ||
| return completion(PostHogUploadInfo(statusCode: httpResponse?.statusCode, error: error, retryAfter: retryAfter)) |
There was a problem hiding this comment.
Clamp server-controlled Retry-After delays
Why we think it's a valid issue
- Checked:
parseRetryAfterin PostHog/Utils/DateUtils.swift:51-61, the single consumer ofPostHogUploadInfo.retryAfterin the queue, the pause gate (pauseFor,pauseReason,canFlush), every reset ofpausedUntil, and the diff of PostHog/PostHogApi.swift againstmain. - Found: No ceiling exists anywhere on the path.
parseRetryAfterreturns any non-negative delta-seconds (DateUtils.swift:52-53) and any future HTTP-date (DateUtils.swift:56-57). PostHog/PostHogQueue.swift:159-161 computeslet delay = max(backoffDelay, result.retryAfter ?? 0)and passes it straight topauseFor, so themin(..., maxRetryDelay)clamp on line 159 applies to the SDK's own backoff only, never to the server value. - Found: The pause has no other exit.
pauseForwritespausedUntil = now() + delay(PostHogQueue.swift:478-481), andpauseReason()blocks every flush while that date is in the future (PostHogQueue.swift:486-491). The only reset isresetRetryState()(PostHogQueue.swift:205-211), which runs after a completed upload — an upload the pause prevents.pausedUntilis in-memory, so recovery needs a process restart. - Found: The pull request does widen the exposed path. On
mainthe transport-error branch returnedPostHogUploadInfo(statusCode: nil, error: error)with noretryAfter, so that case fell back to the bounded 30-second backoff. PostHog/PostHogApi.swift:20-27 now parses the header before the error branch and forwards it together with the real status code. - Found: The claim about a documented maximum in the logs contract is not verifiable inside this repository. The repository has no such document, so the verdict rests on the reachable behavior alone, not on the contract claim.
- Impact: A rate limiter or proxy in front of the ingestion host that answers 429 with a large value (for example
Retry-After: 86400) stops all event, replay, and log flushes for the rest of the app process. Under this pull request's retain-on-retry behavior the records stay durable, so the queue fills tomaxQueueSizeandaddthen evicts the oldest record on each write (PostHogQueue.swift:359-361). The result is silent loss of the earliest analytics data for that session. The fix is one clamp at the point where the delay is built. - Priority: Lowered to
consider. The unbounded pause at PostHogQueue.swift:160 already exists onmain; this pull request extends it to one extra sub-case rather than introducing it. The state is in-memory, so the next app launch recovers, and the trigger needs a server value far larger than normal rate-limit delays. That makes it worthwhile hardening, not a defect that blocks this merge.
Issue description
The code forwards any non-negative Retry-After value without a maximum. A header such as Retry-After: 31536000 blocks all flushes for one year. New records can then evict older records at capacity. This violates the logs contract, which requires a documented maximum. This PR also extends the unbounded value to the response-plus-error path.
Suggested fix
Define a maximum Retry-After delay. Use max(ownBackoff, min(parsedRetryAfter, maximum)) when the queue creates its pause deadline. Add tests for large delta-seconds and future HTTP-date values, with and without a transport error.
Prompt to fix with AI (copy-paste)
## Context
@PostHog/PostHogApi.swift#L20-27
<issue_description>
The code forwards any non-negative `Retry-After` value without a maximum. A header such as `Retry-After: 31536000` blocks all flushes for one year. New records can then evict older records at capacity. This violates the logs contract, which requires a documented maximum. This PR also extends the unbounded value to the response-plus-error path.
</issue_description>
<issue_validation>
- **Checked:** `parseRetryAfter` in PostHog/Utils/DateUtils.swift:51-61, the single consumer of `PostHogUploadInfo.retryAfter` in the queue, the pause gate (`pauseFor`, `pauseReason`, `canFlush`), every reset of `pausedUntil`, and the diff of PostHog/PostHogApi.swift against `main`.
- **Found:** No ceiling exists anywhere on the path. `parseRetryAfter` returns any non-negative delta-seconds (DateUtils.swift:52-53) and any future HTTP-date (DateUtils.swift:56-57). PostHog/PostHogQueue.swift:159-161 computes `let delay = max(backoffDelay, result.retryAfter ?? 0)` and passes it straight to `pauseFor`, so the `min(..., maxRetryDelay)` clamp on line 159 applies to the SDK's own backoff only, never to the server value.
- **Found:** The pause has no other exit. `pauseFor` writes `pausedUntil = now() + delay` (PostHogQueue.swift:478-481), and `pauseReason()` blocks every flush while that date is in the future (PostHogQueue.swift:486-491). The only reset is `resetRetryState()` (PostHogQueue.swift:205-211), which runs after a completed upload — an upload the pause prevents. `pausedUntil` is in-memory, so recovery needs a process restart.
- **Found:** The pull request does widen the exposed path. On `main` the transport-error branch returned `PostHogUploadInfo(statusCode: nil, error: error)` with no `retryAfter`, so that case fell back to the bounded 30-second backoff. PostHog/PostHogApi.swift:20-27 now parses the header before the error branch and forwards it together with the real status code.
- **Found:** The claim about a documented maximum in the logs contract is not verifiable inside this repository. The repository has no such document, so the verdict rests on the reachable behavior alone, not on the contract claim.
- **Impact:** A rate limiter or proxy in front of the ingestion host that answers 429 with a large value (for example `Retry-After: 86400`) stops all event, replay, and log flushes for the rest of the app process. Under this pull request's retain-on-retry behavior the records stay durable, so the queue fills to `maxQueueSize` and `add` then evicts the oldest record on each write (PostHogQueue.swift:359-361). The result is silent loss of the earliest analytics data for that session. The fix is one clamp at the point where the delay is built.
- **Priority:** Lowered to `consider`. The unbounded pause at PostHogQueue.swift:160 already exists on `main`; this pull request extends it to one extra sub-case rather than introducing it. The state is in-memory, so the next app launch recovers, and the trigger needs a server value far larger than normal rate-limit delays. That makes it worthwhile hardening, not a defect that blocks this merge.
</issue_validation>
## Task
Investigate the issue and solve it
<potential_solution>
Define a maximum `Retry-After` delay. Use `max(ownBackoff, min(parsedRetryAfter, maximum))` when the queue creates its pause deadline. Add tests for large delta-seconds and future HTTP-date values, with and without a transport error.
</potential_solution>
There was a problem hiding this comment.
I checked this against the current code and the finding holds up — there is genuinely no ceiling anywhere on the path. parseRetryAfter accepts any non-negative delta-seconds and any future HTTP-date, and the queue builds its pause as the larger of its own bounded backoff and the raw server value, so the 30-second clamp only ever binds the SDK's own number.
I also confirmed the part that makes it bite: the pause has no other way out. Nothing but a completed upload clears the deadline, and an upload is exactly what the pause prevents. I specifically checked whether regaining connectivity rescues it — it doesn't; the reachability handler only clears the separate "unreachable" flag and its flush attempt is still gated by the deadline. The deadline isn't persisted, so a process restart is the only recovery.
Where I land differently is on doing it here. Two things:
This is a pre-existing behavior, not something the PR introduces. The max(ownBackoff, serverValue) line is unchanged context in this diff — an ordinary 429 with a large Retry-After and a clean response already parks the queue for that duration on the base branch, with the same eviction-at-capacity consequence. What this PR adds is one narrow extra sub-case: a response that carried the header and then failed mid-transfer, which previously fell back to the bounded 30-second backoff. So the hardening is worth doing, but it isn't this PR's regression to absorb.
The fix needs a number that nothing in this repository can supply. That's the real blocker for me doing it unattended:
- Reusing the existing 30-second backoff ceiling would be the wrong answer — it would make the SDK ignore a server that legitimately asks for a longer cooldown, which defeats the point of rate limiting. Any other value is a judgment call about client data freshness versus protecting ingestion, and there's no precedent in the SDK to copy: the only two consumers of the parsed header both pass it through unclamped.
- The suggested justification ("the logs contract requires a documented maximum") can't be used to derive it either — as the finding itself concedes, that document isn't in this repo, and I found no decision record for it.
- Where the clamp goes changes behavior in ways the current tests notice. Clamping inside the parser would break the existing test asserting a 120-second header parses as 120; clamping where the queue builds the deadline wouldn't. Those are two defensible readings of the same suggestion.
- A clamp that only covers the queue leaves the identical unclamped pattern in the push-subscription retry path one file over, which this PR doesn't touch and which has its own test asserting it honors the header. Doing that one too means editing code outside this change.
- This PR is deliberately aligned with the cross-SDK spec work and has an Android counterpart. A maximum
Retry-Afteris precisely the kind of value that wants to be specified once and matched across SDKs rather than picked in iOS alone — and the external compliance suite has arespects_retry_after_headercase that I can't run from here to check the interaction.
So what a human needs to decide: the ceiling value and whether it's a constant or a configurable option; whether it's specified in the spec first so Android matches; whether it clamps at the parser or at the pause deadline; whether the push path is included; and whether it lands as its own hardening PR against the base branch, which given it's a pre-existing issue is what I'd suggest — that framing also lets it cover both consumers instead of half of them.
One process note in the interest of not overstating anything: this environment has no Swift toolchain, so I couldn't have run the SDK's lint or tests for a fix here even if the value question were settled, and the suggestion asks for new tests over large delta-seconds and future HTTP-date values. Nothing committed for this thread.
| if let reachability { | ||
| // A shared notifier may already be running, so late queue | ||
| // subscribers must synchronously adopt its current state | ||
| // rather than waiting for a transition that may never come. | ||
| _ = updatePauseState(for: reachability) | ||
| } | ||
|
|
||
| do { | ||
| try reachability?.startNotifier() | ||
| } catch { | ||
| // Without a running notifier no callback can ever clear a | ||
| // reachability pause, so it must not outlive the failure. | ||
| // URLSession errors and the retry backoff cover connectivity. | ||
| hedgeLog("Error: Unable to monitor network reachability: \(error)") | ||
| stateLock.withLock { paused = false } |
There was a problem hiding this comment.
Notifier failure bypasses Wi-Fi-only mode
Why we think it's a valid issue
- Checked: The order of operations in
start()(PostHog/PostHogQueue.swift:234-249), the pause rules inupdatePauseState(PostHogQueue.swift:458-475), the flush gate (pauseReason/canFlush, PostHogQueue.swift:486-499), the vendoredReachability(connection,startNotifier), the URLSession setup inPostHogApi.init, and the same code onmain. - Found: The sequence is real. The snapshot at PostHogQueue.swift:234-239 runs before
startNotifier(), andupdatePauseStatesetspaused = truefor a.wifiqueue on cellular (PostHogQueue.swift:467-469). The catch at PostHogQueue.swift:247-248 then setspaused = falseunconditionally, with no test of why the pause was set. - Found:
pausedis the only enforcement point fordataMode.grepshowsdataModeis read at PostHogConfig.swift:104 and insideupdatePauseStatealone;flush()andtake()consultpaused/pausedUntilonly. No code setsallowsCellularAccess, so the URLSession built inPostHogApi.init(PostHogApi.swift:67-79) keeps the platform default and permits cellular. - Found: The consequence is worse than the finding states. A failed notifier delivers no callbacks at all, so no later transition can restore the pause. The queue sends over cellular for the whole process lifetime, not until the next transition.
- Found:
Reachability.connection(PostHog/Utils/Reachability.swift:121-127) lazily callssetReachabilityFlags()whenflagsis nil, so a current connection value is readable without a running notifier. A re-check at flush time, or keeping only the.wifi-mode pause, is therefore cheap and needs no new machinery. - Found: The catch also protects a real case. When
SCNetworkReachabilityGetFlagsitself fails,connectionreports.unavailableand the snapshot pauses the queue; clearing the pause there is correct, because otherwise all telemetry stops in an environment where the network works but the flags are unreadable. The defect is that one branch —.wifimode on a readable cellular connection — is cleared together with it. - Impact: A developer who sets
config.dataMode = .wifigets analytics, replay, and log uploads over the end user's cellular plan, which is the exact outcome the option exists to prevent. The trigger needsSCNetworkReachabilitySetCallbackorSCNetworkReachabilitySetDispatchQueueto fail while the flags stay readable, plus a non-default.wifimode. - Priority: Lowered to
consider. This is not a regression: onmainthe same failure leftpausedat itsfalsedefault, with no snapshot at all, so the queue already sent over cellular. The pull request adds a correct snapshot and then discards one branch of it. The trigger is a rare SystemConfiguration failure combined with a non-default setting, so it is a worthwhile refinement of a deliberate, commented trade-off rather than a merge blocker.
Issue description
The snapshot can pause a .wifi queue on cellular. If startNotifier() fails, the catch clears that pause. Upload requests still allow cellular access. The queue can send over cellular until another reachability transition occurs.
Suggested fix
Do not clear a pause caused by cellular access in .wifi mode. Retry reachability checks, or disable cellular access on queued upload requests. Add a notifier-failure test with a cellular snapshot.
Prompt to fix with AI (copy-paste)
## Context
@PostHog/PostHogQueue.swift#L234-248
<issue_description>
The snapshot can pause a `.wifi` queue on cellular. If `startNotifier()` fails, the catch clears that pause. Upload requests still allow cellular access. The queue can send over cellular until another reachability transition occurs.
</issue_description>
<issue_validation>
- **Checked:** The order of operations in `start()` (PostHog/PostHogQueue.swift:234-249), the pause rules in `updatePauseState` (PostHogQueue.swift:458-475), the flush gate (`pauseReason`/`canFlush`, PostHogQueue.swift:486-499), the vendored `Reachability` (`connection`, `startNotifier`), the URLSession setup in `PostHogApi.init`, and the same code on `main`.
- **Found:** The sequence is real. The snapshot at PostHogQueue.swift:234-239 runs before `startNotifier()`, and `updatePauseState` sets `paused = true` for a `.wifi` queue on cellular (PostHogQueue.swift:467-469). The catch at PostHogQueue.swift:247-248 then sets `paused = false` unconditionally, with no test of why the pause was set.
- **Found:** `paused` is the only enforcement point for `dataMode`. `grep` shows `dataMode` is read at PostHogConfig.swift:104 and inside `updatePauseState` alone; `flush()` and `take()` consult `paused`/`pausedUntil` only. No code sets `allowsCellularAccess`, so the URLSession built in `PostHogApi.init` (PostHogApi.swift:67-79) keeps the platform default and permits cellular.
- **Found:** The consequence is worse than the finding states. A failed notifier delivers no callbacks at all, so no later transition can restore the pause. The queue sends over cellular for the whole process lifetime, not until the next transition.
- **Found:** `Reachability.connection` (PostHog/Utils/Reachability.swift:121-127) lazily calls `setReachabilityFlags()` when `flags` is nil, so a current connection value is readable without a running notifier. A re-check at flush time, or keeping only the `.wifi`-mode pause, is therefore cheap and needs no new machinery.
- **Found:** The catch also protects a real case. When `SCNetworkReachabilityGetFlags` itself fails, `connection` reports `.unavailable` and the snapshot pauses the queue; clearing the pause there is correct, because otherwise all telemetry stops in an environment where the network works but the flags are unreadable. The defect is that one branch — `.wifi` mode on a readable cellular connection — is cleared together with it.
- **Impact:** A developer who sets `config.dataMode = .wifi` gets analytics, replay, and log uploads over the end user's cellular plan, which is the exact outcome the option exists to prevent. The trigger needs `SCNetworkReachabilitySetCallback` or `SCNetworkReachabilitySetDispatchQueue` to fail while the flags stay readable, plus a non-default `.wifi` mode.
- **Priority:** Lowered to `consider`. This is not a regression: on `main` the same failure left `paused` at its `false` default, with no snapshot at all, so the queue already sent over cellular. The pull request adds a correct snapshot and then discards one branch of it. The trigger is a rare SystemConfiguration failure combined with a non-default setting, so it is a worthwhile refinement of a deliberate, commented trade-off rather than a merge blocker.
</issue_validation>
## Task
Investigate the issue and solve it
<potential_solution>
Do not clear a pause caused by cellular access in `.wifi` mode. Retry reachability checks, or disable cellular access on queued upload requests. Add a notifier-failure test with a cellular snapshot.
</potential_solution>
There was a problem hiding this comment.
I verified this end to end and the mechanics are exactly as described: the snapshot can pause a .wifi queue on cellular, and the notifier-failure catch then clears that pause without checking why it was set. I also confirmed the two things that make it matter — the pause flag is the only place dataMode is enforced anywhere in the SDK, and nothing sets allowsCellularAccess, so the upload session is free to use cellular. And you're right that it's for the process lifetime, not until the next transition, since a failed notifier delivers no callbacks at all.
I also confirmed it isn't a regression. On the base branch there was no snapshot at all and dataMode was only consulted inside the reachability callback, so a failed notifier left the queue unpaused and it uploaded over cellular just the same. This PR added a correct snapshot and then deliberately discards one branch of it — that discard is its own commit with a comment explaining the reasoning, so it's a considered trade-off rather than an oversight.
One correction that changes what the fix costs: the suggestion that a re-check at flush time is cheap doesn't hold against the vendored reachability helper. Its connection property only reads fresh flags when it has none cached — and the snapshot has already populated them by the time the notifier fails. The method that refreshes them is file-private and the cache is read-only from outside, so the queue can neither force a refresh nor clear it. Polling connection on each flush would return the same stale "cellular" answer forever.
That has a knock-on effect on the minimal fix. Simply keeping the .wifi-mode pause isn't strictly better than what's there — with nothing able to refresh the connection value, it turns "uploads over cellular" into "never uploads again for the rest of the process, even after the user joins Wi-Fi", at which point records buffer up to the queue limit and start evicting the oldest. So it swaps one silent data problem for another, and which one to prefer is a genuine product question about whether dataMode = .wifi is a hard constraint (never spend the user's cellular data, accept the loss) or a strong preference.
The options that actually resolve it are all bigger than this line:
- Extend the in-tree reachability helper so the connection state can be refreshed on demand. That component is shared by the events, replay, and logs queues, so it's not a local change.
- Set
allowsCellularAccess = falseon the upload requests when.wifimode is on. This is the most robust answer — the OS enforces it and reachability accuracy stops mattering entirely — but it's a new enforcement mechanism in the API layer that this PR doesn't touch, and it needs thought about how it interacts with the customURLSessionConfigurationthe compliance adapter injects.
So what needs deciding by a human: which failure mode .wifi mode should prefer when reachability monitoring is unavailable, and whether enforcement should move off the pause flag onto allowsCellularAccess where the OS guarantees it. My own read is that this belongs in a separate change against the base branch rather than here — it's pre-existing, and the allowsCellularAccess route would fix it properly instead of refining a flag that can't be kept accurate.
Worth flagging for whoever picks it up: .wifi mode currently has no behavioral test coverage at all — the only test that mentions it asserts the default value. So the notifier-failure test you suggest would be the first test of this path, which is a good reason to do it deliberately rather than as a drive-by. Nothing committed for this thread.
| payload.completion(true) | ||
| return | ||
| } | ||
| let newCount = nextRetryCount() |
There was a problem hiding this comment.
The logs spec says the SDK "SHALL end the active failure-driven sequence" after maxRetries on the same batch (logs/spec.md, Error handling and retries). retry-queue only makes that a MAY, which is what makes this look optional — but logs binds the log queue, and after this change nothing ends the sequence for any of the three. The compliance suite catches it: max_retries_respected expects 4 requests and gets 6 at head.
I think we need a ceiling that ends the active sequence without clearing the queue. The push handler already has that shape — attempt > config.maxRetries sets halted = true and keeps the record for next launch.
| } | ||
| let newCount = nextRetryCount() | ||
| let backoffDelay = min(TimeInterval(newCount) * retryDelay, maxRetryDelay) | ||
| let delay = max(backoffDelay, result.retryAfter ?? 0) |
There was a problem hiding this comment.
[question] The spec clamps the header before taking the longer of the two — max(ownBackoff, min(parsedRetryAfter, documentedMaximum)) (logs/spec.md, Error handling and retries) — because nothing upstream bounds Retry-After. As written a proxy sending Retry-After: 86400 parks the queue for a day. This is pre-existing rather than something the PR introduces, so: follow-up issue, or clamp it here while we're in this function?
| var evicted: String? | ||
|
|
||
| try itemsLock.withLock { | ||
| try contents.write(to: queue.appendingPathComponent(filename)) |
There was a problem hiding this comment.
[nit] The write and the deleteSafely just below now both run inside itemsLock; before, neither did. With a full queue during an outage every capture pays a write plus an unlink under the lock, on the caller's thread. remove(ids:) and reindexFromDisk use the collect-under-lock, IO-after shape — we could write the file before taking the lock and unlink the evicted id after releasing it, and still keep the capacity check atomic.
| } | ||
|
|
||
| func add(_ contents: Data) { | ||
| func remove(ids: [String]) { |
There was a problem hiding this comment.
[nit] This takes the last production caller of pop(_:), and delete(index:) lost its own when PostHogQueue.add stopped calling it — both are now reachable only from PostHogFileBackedQueueAlignmentTest. Worth deleting them, or a line on why they're kept?
| - Preserve bounded durable event, replay, and log queues across retryable upload failures instead of clearing them. | ||
| - Limit `maxRetries` to push-subscription registration; it no longer bounds event, replay, or log queue flush attempts. | ||
| - Acknowledge successful and terminal uploads by their exact persisted entry identities so full-queue replacements accepted during an upload are not deleted. | ||
| - Trim persisted queues to `maxQueueSize` (or `logs.maxBufferSize` for logs) in FIFO order when loading them from disk. | ||
| - Honor the received HTTP status and `Retry-After` when an upload also returns a transport error: successful and terminal responses remove the sent entries, while retryable responses retain them. A `3xx` left on a failed request is reported as no status, because a redirect does not confirm delivery to the final host. | ||
| - Preserve existing queued records when writing a new record to a full queue fails. | ||
| - Retry push-subscription requests that answer with HTTP 408 instead of treating the timeout as terminal, so a pending logout unregister survives until it succeeds. |
There was a problem hiding this comment.
[suggestion] A few of these carry their rationale inline ("because a redirect does not confirm delivery", "so a pending logout unregister survives"), which belongs in the PR body, and a couple lean on internal vocabulary ("bounded durable", "persisted entry identities") rather than what a user observes. The 3xx sentence is also a follow-up to the bullet above it, so it can fold in. Missing entirely: queues now pause while the device reports no network and flush as soon as connectivity returns, which is the most user-visible change here.
| - Preserve bounded durable event, replay, and log queues across retryable upload failures instead of clearing them. | |
| - Limit `maxRetries` to push-subscription registration; it no longer bounds event, replay, or log queue flush attempts. | |
| - Acknowledge successful and terminal uploads by their exact persisted entry identities so full-queue replacements accepted during an upload are not deleted. | |
| - Trim persisted queues to `maxQueueSize` (or `logs.maxBufferSize` for logs) in FIFO order when loading them from disk. | |
| - Honor the received HTTP status and `Retry-After` when an upload also returns a transport error: successful and terminal responses remove the sent entries, while retryable responses retain them. A `3xx` left on a failed request is reported as no status, because a redirect does not confirm delivery to the final host. | |
| - Preserve existing queued records when writing a new record to a full queue fails. | |
| - Retry push-subscription requests that answer with HTTP 408 instead of treating the timeout as terminal, so a pending logout unregister survives until it succeeds. | |
| - Keep events, replay snapshots, and logs on disk when an upload fails with a retryable error, instead of clearing the queue. | |
| - Limit `maxRetries` to push-subscription registration; it no longer bounds event, replay, or log upload attempts. | |
| - Fix records captured while an upload is in flight being deleted when that upload succeeds. | |
| - Fix a failed write to a full queue evicting an existing record. | |
| - Apply the HTTP status and `Retry-After` from a response that arrives alongside a transport error, instead of always retrying. | |
| - Pause uploads while the device reports no network, and flush as soon as connectivity returns. | |
| - Trim stored queues to `maxQueueSize` (`logs.maxBufferSize` for logs), oldest first, when loading them from disk. | |
| - Retry push-subscription requests that return HTTP 408 instead of discarding them. |
| @@ -0,0 +1,11 @@ | |||
| --- | |||
| "posthog-ios": minor | |||
There was a problem hiding this comment.
[question] Re-scoping maxRetries is a silent behavior break — anyone who lowered it to bound retry traffic gets a different SDK with nothing failing at build time. Our changelog style puts a **Breaking:** prefix on those and sorts them first, but that reads oddly under a minor bump. Is minor the call because there's no API break, or should this carry the prefix and go major?
A transport failure (statusCode -1: lost connectivity, timeout, no route) was treated the same as a retriable server error, so a handful of failures backed off 1s/2s/3s would exceed maxRetries in ~6s and wipe the entire on-disk queue. Mobile networks flap constantly, so this silently discarded every buffered event for iOS customers. Transport failures now never drop the queue — events wait on disk and maxQueueSize bounds growth. A full drop is reserved for a responsive-but-unhealthy backend that keeps rejecting batches past the new maxRetryWindowSeconds window (default 24h). Every drop emits a $queue_records_dropped diagnostic event so the loss is measurable. maxRetries now governs only HTTP 413 batch halving. Generated-By: PostHog Desktop Task-Id: 4f8c390a-7251-4812-9ba9-57186baeb234
The unhealthy-backend failure streak (`failingSince`) is tracked only in memory, so the `maxRetryWindowSeconds` drop window resets on every app launch and rarely fires on platforms that terminate processes frequently (iOS). Document this on both the private `failingSince` field and the public `maxRetryWindowSeconds` docstring so the documented "stop retrying forever" safeguard is honest about its per-process scope, and record why persisting the timestamp was declined (net-negative: adds a storage key and a per-first-failure disk write for a safeguard that never loses data or grows the buffer unbounded on its own — `maxQueueSize` already bounds it). Generated-By: PostHog Desktop Task-Id: 5e763b59-b175-4538-a60b-8aa86112df85
The HTTP 413 batch-halving branch compared `maxRetries` against the same `retryCount` that every retriable failure — transport `-1` and server 5xx/429 included — increments for backoff. So a burst of ordinary network flapping (the exact condition this PR exists to survive) could push retryCount past maxRetries, and then the very first 413 after connectivity returned would trip `newCount > maxRetries` and wipe the entire on-disk queue without a single halving attempt. That is total data loss on a reachable backend, and it contradicts the PR's own documented contract that maxRetries no longer governs network or 5xx retries. Introduce a dedicated `payloadTooLargeCount` that is incremented only when a 413 halves the cap and reset alongside `retryCount` on success, on the 413 poison drop, and on a full queue drop. `retryCount` now only ramps the backoff delay. Add a regression test: three transport failures followed by a 413 must halve the cap rather than drop the queue. Generated-By: PostHog Desktop Task-Id: 5e763b59-b175-4538-a60b-8aa86112df85
`failingSince` arms the drop window on a server-side failure (5xx/429), but a subsequent transport failure (offline, timeout, no route) neither cleared nor paused it — the guard only skipped re-setting the field and the drop check was suppressed for that one call. The wall clock therefore kept spanning the whole offline period, so a single 500, hours offline, then one more 500 would see the entire gap as "sustained backend failure" and wipe the on-disk queue on just two error responses. That contradicts the window's own contract (it is scoped to a responsive-but-unhealthy backend) and is total data loss when reached. Clear `failingSince` on every transport failure so the window measures only a continuous run of server responses; a later server failure starts a fresh streak. This can only ever delay a drop, never cause an earlier one. Add a regression test: 500 arms the window, a transport failure past the window resets it, and the next 500 keeps the queue instead of wiping it. Generated-By: PostHog Desktop Task-Id: 5e763b59-b175-4538-a60b-8aa86112df85
This PR adds `@objc public var maxRetryWindowSeconds: TimeInterval` to the public `PostHogConfig`, but the generated snapshot at api/posthog-ios.public-api.txt was not regenerated, so the `public-api` CI job (`make apiCheck`) would fail its strict diff and block the merge. Add the missing entry in its sorted position, matching the exact format the generator emits for sibling `@objc` stored properties (the declaration column strips `public` and the default value; the USR follows the `c:@m@PostHog@objc(cs)PostHogConfig(py)<name>` pattern). No Apple toolchain is available here to run `make apiUpdate`, so the line was reconstructed from the deterministic format of the neighbouring `maxRetries` / `flushIntervalSeconds` entries; `make apiCheck` should be re-run in CI to confirm. Generated-By: PostHog Desktop Task-Id: 5e763b59-b175-4538-a60b-8aa86112df85
The new maxRetries docstring claimed "This no longer governs network or 5xx retries" as an absolute statement, but PostHogPushSubscriptionHandler still reads config.maxRetries to bound retryable push-registration failures — and its classifier treats transport errors, 429, and 5xx as retryable, exactly the failure kinds the docstring said it no longer covers. A developer lowering maxRetries to tune 413 halving would unknowingly shorten push registration patience. Scope the "no longer governs network/5xx" claim to the event, replay, and log queues, and add a sentence stating that maxRetries still bounds push subscription registration retries. Documentation only; no behaviour or API change (the alternative of a separate pushSubscriptionMaxRetries option was deliberately not taken — it adds public API surface out of proportion to a one-sentence accuracy fix). Generated-By: PostHog Desktop Task-Id: 5e763b59-b175-4538-a60b-8aa86112df85
`dropAllQueuedRecords` clears the on-disk queue and then invokes `onRecordsDropped`, which the SDK wires to synchronously capture `$queue_records_dropped` back onto the same queue (the capture path has no dispatch hop, so the record is on disk before control returns). Both drop sites then called `payload.completion(true)`, whose closure runs `fileQueue.pop(items.count)` — a delete-by-position against the front of the queue. Because `clear()` already emptied the batch, that pop deletes the freshly written diagnostic (and any event an app thread captured in the same window), so the PR's headline observability event never survives to be sent. Complete the drop paths with `false` instead. `pop` is guarded by `if success`, so `false` skips the deletion (there is nothing legitimate to pop after a clear) while the closure still clears `isFlushing`. Add a queue-level regression test that reproduces the SDK's synchronous re-capture in `onRecordsDropped` and asserts the diagnostic remains on disk after the drop instead of being popped away. Generated-By: PostHog Desktop Task-Id: 5e763b59-b175-4538-a60b-8aa86112df85
The initial reachability state check can pause the queue before startNotifier() runs. Reachability.connection reports .unavailable both for a genuinely offline device and for a failed flags read, and the same flags read is what makes startNotifier() throw, so the two failures are correlated. With no notifier running, no onReachable callback can ever clear the pause, leaving the events, replay, and logs queues paused for the process lifetime and dropping records at the queue cap without ever attempting a send. Clear the pause in the catch so a dead notifier leaves the queue sending, matching the previous behaviour where URLSession errors and the retry backoff handled connectivity. Generated-By: PostHog Desktop Task-Id: b3234784-8ba6-44c2-9a45-4e81711a5320
reloadFromDisk read the queue directory outside itemsLock and then replaced items from that snapshot under the lock. A filename appended by a concurrent add between the two steps was silently dropped from the in-memory index while its file stayed on disk, so the record could not be sent until the next reload or process start, and the stale count could let the physical queue exceed its configured capacity. Session replay opens exactly this window on purpose: it flips hasPassedMinimumDuration before migrateBufferToQueue so new snapshots route straight to the inner queue, and migrateAll finishes with target reloadFromDisk on the buffer IO queue while capture keeps adding. Fold the enumeration and the replacement into one critical section by moving both into reindexFromDisk, which replaces replaceItemsWithBounded and is now the single re-index path for setup and reload. Capacity trimming and the file deletes it triggers are unchanged and still run outside the lock. Generated-By: PostHog Desktop Task-Id: b3234784-8ba6-44c2-9a45-4e81711a5320
URLSession can hand back both an HTTPURLResponse and an error when the transfer fails after the headers arrive. processUploadResponse parsed the Retry-After header below the error branch, so on that path the header was dropped and only the local backoff applied: the queue retried after its own delay and the push subscription handler fell back to its exponential backoff, both ignoring the delay a 429 or 503 asked for. Parse the header once, before the error branch, and pass it on both paths. The queue already takes max(backoff, retryAfter), so this can only lengthen its pause; the push handler prefers the server value, matching what it already does for a plain 429. The no-response path is unchanged - there are no headers to read there. Adds a response-plus-error test covering a 429 with Retry-After alongside the existing status-preservation test. Generated-By: PostHog Desktop Task-Id: 5cb2da82-834d-4210-b996-d20454cc7e22
`isRetryable` excluded 408, so a request timeout was terminal. On the unregister path the terminal branch calls `clearPendingUnregister`, which removes the durable `pushPendingUnregister` record — a logout DELETE that met a 408 was never re-attempted, leaving the device subscribed under the logged-out distinct id. Forwarding the received status alongside a transport error widened the shapes that reach this gap: a 408 that also failed in URLSession previously arrived with no status and fell into the retryable default. 408 now joins 429 and 5xx, matching the policy `QueueEndpoint+Factories` already applies to the event, replay, and log queues. The registration path shares the classifier and also improves: a 408 backs off and retries instead of halting the handler. Generated-By: PostHog Desktop Task-Id: d785939f-f0b4-4db4-bbf9-c87f1c2364cb
Loading the queue from disk now trims to capacity, so the sort order decides which persisted records are deleted. The comparator was neither total nor stable: files with equal creation dates were left in unspecified order, and a file whose creation date could not be read fell back to `.distantPast`, sorting it ahead of every dated record and making it the first candidate for deletion. Fall back to `.distantFuture` so an unreadable record is retained rather than culled, and break ties on the filename so the order is total. UUID v7 filenames sort lexicographically by their embedded timestamp, so the tie-breaker keeps contemporaneous records in creation order. Generated-By: PostHog Desktop Task-Id: 8f1e9af4-1dec-48b8-a9e1-d834f9314cf1
The error branch of `processUploadResponse` now forwards the status recorded on the task, so a 3xx that URLSession was still following when the transfer failed was reported as the upload's outcome. Events and replay classify 3xx as retryable, but the logs policy and the push handler's `isRetryable` retry only 408, 429, and 5xx, so the same wire condition deleted a durable log batch there and cleared the `.pushPendingUnregister` intent, leaving the device subscribed under a signed-out distinct id. A redirect is not a delivery confirmation for the payload, so report no status when a 3xx is left on a failed task. `Retry-After` and the error are kept, and every other status arriving alongside an error is still honored. Generated-By: PostHog Desktop Task-Id: 8f1e9af4-1dec-48b8-a9e1-d834f9314cf1
84ce34e to
20259ef
Compare

💡 Motivation and Context
Durable event, replay, and log queues currently couple their retention to flush retry exhaustion: repeated retryable failures can clear every persisted record. Successful sends also delete the current queue head by position, which can discard never-sent replacement records when a full queue changes while transport is in flight.
This aligns the shared file-backed queue with PostHog/sdk-specs#53: retryable failures retain bounded durable records, known-offline periods consume no attempts, and acknowledgements remove only the exact persisted entries sent. HTTP 413 continues shrinking to a singleton poison record. Related investigation: #788.
Cross-SDK implementation: PostHog/posthog-android#740
💚 How did you test it?
make test— 765 tests passedmake lint— no serious violationsmake apiCheck— public API snapshot unchangedmake buildSdk— iOS, macOS, and Mac Catalyst passed; tvOS/watchOS/visionOS were unavailable in the local Xcode installationgit diff --check📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Implemented with Pi using isolated implementation, scout, and review subagents. The human directed the durability contract and cross-SDK scope. Queue capacity and bounded backoff remain the resource controls; no public option or default was added.