-
Notifications
You must be signed in to change notification settings - Fork 342
fix(core): honor Retry-After on the OTLP export queues #4726
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
turnipdabeets
merged 47 commits into
feat/traces-node-mvp
from
fix/otlp-honor-retry-after
Sep 10, 2026
Merged
Changes from all commits
Commits
Show all changes
47 commits
Select commit
Hold shift + click to select a range
3db04a2
fix(core): honor Retry-After and skip oversized bodies on the OTLP qu…
turnipdabeets 39f5d4d
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into fix/o…
turnipdabeets 098b597
fix(core): honor Retry-After on every send path, not just the retry t…
turnipdabeets f766c21
chore: regenerate the terser mangled-names list
turnipdabeets 9a2df41
fix(core): drain on explicit flush, and clamp the Retry-After deadline
turnipdabeets cc72d10
fix(core): stop an in-window refusal from extending the window
turnipdabeets 95ce73e
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into tmp/4…
turnipdabeets af65292
Merge feat/traces-node-mvp into fix/otlp-honor-retry-after
turnipdabeets aea2b15
fix(core): keep the Retry-After window off the public API and out of …
turnipdabeets 74ac214
fix(core): stop a closed Retry-After window from stranding the metric…
turnipdabeets b91c94c
fix(core): release a queue once the Retry-After window that held it c…
turnipdabeets 055e0b8
fix(traces): charge the retry budget once per backoff window
turnipdabeets 829f41f
chore(core): declare posthog-js and trim the retry-after comments
turnipdabeets de06acd
fix(core): measure OTLP bodies against the configured limit, not the …
turnipdabeets ea4e9a2
chore(core): correct the Retry-After parser and window comments
turnipdabeets 59bc392
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into fix/o…
turnipdabeets 5885f79
chore(react-native): regenerate references for the body-cap doc wording
turnipdabeets 3562b39
Merge feat/traces-node-mvp into fix/otlp-honor-retry-after
turnipdabeets 5fa7aca
refactor(core): hold the flush timer in one place for all three queues
turnipdabeets dd5e9be
chore(browser): regenerate mangled property names after the FlushTime…
turnipdabeets 659f4e9
fix(traces): reset the head batch budget when consent withdrawal clea…
turnipdabeets 0e59eb8
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into work/…
turnipdabeets 124b6e9
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into work/…
turnipdabeets 366c141
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into work/…
turnipdabeets d06a6b2
docs: one-line the OTLP changesets and split the browser-visible half
turnipdabeets fa42a17
fix(core): report an unserializable OTLP batch as too-large
turnipdabeets d1007d0
docs(core): correct the unserializable-batch test comment, drop a dup…
turnipdabeets 6df30f4
docs: scope the OTLP entries to logs and metrics
turnipdabeets 030c294
docs(core): correct where the ingestion body limit is applied
turnipdabeets 7713aef
docs(core): the ingestion limit sees the decompressed body
turnipdabeets 4fb5bf1
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into tmp/4…
turnipdabeets 7ef472b
Merge branch 'feat/traces-node-mvp' into fix/otlp-honor-retry-after
turnipdabeets 4db1e85
chore: restore generated reference files to generator output
turnipdabeets 6ee9467
fix(traces): hold spans back during Retry-After and split locally
turnipdabeets 5ee37fb
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into sync/…
turnipdabeets d5c64e2
fix(otlp): extend an open Retry-After window and jitter our own backoff
turnipdabeets b21fce3
docs(changeset): name the retry-window and backoff changes
turnipdabeets 3279b97
fix(otlp): cap the logs and metrics backoff at 30s, as the contracts …
turnipdabeets d1b93bc
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into fix/4…
turnipdabeets 3a749d0
chore(browser): record _flushJitter in the mangled property names
turnipdabeets 69d0475
chore(references): regenerate for the measuredLocally too-large outcome
turnipdabeets 8e95248
docs(changeset): disclose the 30s cap on the logs retry delay
turnipdabeets 4d02232
chore: restore generated reference files to generator output
turnipdabeets 9b763ec
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into fix/o…
turnipdabeets c7e1802
fix(core): leave an exempt flush uncharged when a later refusal exten…
turnipdabeets 7406bbc
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into fix/o…
turnipdabeets 842dcb9
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into fix/o…
turnipdabeets File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| 'posthog-js': patch | ||
| 'posthog-react-native': patch | ||
| '@posthog/core': patch | ||
| --- | ||
|
|
||
| Cap the retry delay for log exports at 30 seconds, the ceiling the logs contract states. It previously doubled to 64 times the flush interval — 192s on web, 640s on React Native — so a log export now resumes within 30 seconds of a failing endpoint recovering, at the cost of more retry requests while that endpoint is down. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| 'posthog-js': patch | ||
| 'posthog-react-native': patch | ||
| '@posthog/core': patch | ||
| --- | ||
|
|
||
| Keep backing off a failing log flush while new records arrive, instead of the next record resetting the retry to the flush interval. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| 'posthog-node': patch | ||
| 'posthog-react-native': patch | ||
| '@posthog/core': patch | ||
| --- | ||
|
|
||
| Honor `Retry-After` when the ingestion endpoint refuses a logs or metrics batch, instead of retrying on the SDK's own schedule alone. A refusal naming a longer wait extends the one being served, up to five minutes from when it started. Retry delays now carry jitter so clients refused together do not return together, and metrics backs off exponentially across consecutive failures rather than retrying on a fixed interval. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| 'posthog-node': patch | ||
| 'posthog-react-native': patch | ||
| '@posthog/core': patch | ||
| --- | ||
|
|
||
| Stop sending logs and metrics batches over 10 MiB, or too large to serialize at all, instead of spending a request to discover the endpoint refuses them. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| import { FlushTimer } from '../utils/flush-timer' | ||
|
|
||
| describe('FlushTimer', () => { | ||
| it('fires once, after the delay', async () => { | ||
| const onFire = vi.fn() | ||
| const timer = new FlushTimer(onFire) | ||
| timer.arm(1000) | ||
|
|
||
| await vi.advanceTimersByTimeAsync(999) | ||
| expect(onFire).not.toHaveBeenCalled() | ||
| await vi.advanceTimersByTimeAsync(1) | ||
| expect(onFire).toHaveBeenCalledTimes(1) | ||
|
|
||
| await vi.advanceTimersByTimeAsync(10_000) | ||
| expect(onFire).toHaveBeenCalledTimes(1) | ||
| }) | ||
|
|
||
| it('releases the handle before firing, so the callback can arm again', async () => { | ||
| const timer: FlushTimer = new FlushTimer(() => { | ||
| pendingInsideCallback = timer.pending | ||
| }) | ||
| let pendingInsideCallback: boolean | undefined | ||
| timer.arm(1000) | ||
| expect(timer.pending).toBe(true) | ||
|
|
||
| await vi.advanceTimersByTimeAsync(1000) | ||
| expect(pendingInsideCallback).toBe(false) | ||
| expect(timer.pending).toBe(false) | ||
| }) | ||
|
|
||
| it('arm replaces a pending timer outright, in either direction', async () => { | ||
| const onFire = vi.fn() | ||
| const timer = new FlushTimer(onFire) | ||
| timer.arm(10_000) | ||
| timer.arm(1000) | ||
|
|
||
| await vi.advanceTimersByTimeAsync(1000) | ||
| expect(onFire).toHaveBeenCalledTimes(1) | ||
| }) | ||
|
|
||
| // The reason the deadline lives next to the handle: every capture reaches the | ||
| // arming path, and none of them may pull a flush in front of a longer wait. | ||
| it('armNoEarlierThan does not shorten a pending timer', async () => { | ||
| const onFire = vi.fn() | ||
| const timer = new FlushTimer(onFire) | ||
| timer.arm(10_000) | ||
| timer.armNoEarlierThan(1000) | ||
|
|
||
| await vi.advanceTimersByTimeAsync(9999) | ||
| expect(onFire).not.toHaveBeenCalled() | ||
| await vi.advanceTimersByTimeAsync(1) | ||
| expect(onFire).toHaveBeenCalledTimes(1) | ||
| }) | ||
|
|
||
| it('armNoEarlierThan lengthens a pending timer', async () => { | ||
| const onFire = vi.fn() | ||
| const timer = new FlushTimer(onFire) | ||
| timer.arm(1000) | ||
| timer.armNoEarlierThan(10_000) | ||
|
|
||
| await vi.advanceTimersByTimeAsync(1000) | ||
| expect(onFire).not.toHaveBeenCalled() | ||
| await vi.advanceTimersByTimeAsync(9000) | ||
| expect(onFire).toHaveBeenCalledTimes(1) | ||
| }) | ||
|
|
||
| it('armNoEarlierThan counts down the remainder, not the whole delay', async () => { | ||
| const onFire = vi.fn() | ||
| const timer = new FlushTimer(onFire) | ||
| timer.arm(10_000) | ||
| await vi.advanceTimersByTimeAsync(6000) | ||
| // 4s left, so a 4s request is not longer and must not restart the wait. | ||
| timer.armNoEarlierThan(4000) | ||
|
|
||
| await vi.advanceTimersByTimeAsync(4000) | ||
| expect(onFire).toHaveBeenCalledTimes(1) | ||
| }) | ||
|
|
||
| it('armNoEarlierThan arms when nothing is pending', async () => { | ||
| const onFire = vi.fn() | ||
| const timer = new FlushTimer(onFire) | ||
| timer.armNoEarlierThan(1000) | ||
| expect(timer.pending).toBe(true) | ||
|
|
||
| await vi.advanceTimersByTimeAsync(1000) | ||
| expect(onFire).toHaveBeenCalledTimes(1) | ||
| }) | ||
|
|
||
| it('clear stops a pending timer and is safe to repeat', async () => { | ||
| const onFire = vi.fn() | ||
| const timer = new FlushTimer(onFire) | ||
| timer.arm(1000) | ||
| timer.clear() | ||
| timer.clear() | ||
| expect(timer.pending).toBe(false) | ||
|
|
||
| await vi.advanceTimersByTimeAsync(10_000) | ||
| expect(onFire).not.toHaveBeenCalled() | ||
| }) | ||
| }) |
108 changes: 108 additions & 0 deletions
108
packages/core/src/__tests__/posthog.otlp-retry-after.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| import { createTestClient, PostHogCoreTestClient, PostHogCoreTestClientMocks } from '@/testing' | ||
|
|
||
| // The header has to survive the whole path: response → PostHogFetchHttpError → | ||
| // the retry-later outcome each queue reads. A unit test of the parser alone | ||
| // would still pass if the plumbing were missing. | ||
| describe('OTLP Retry-After', () => { | ||
| let posthog: PostHogCoreTestClient | ||
| let mocks: PostHogCoreTestClientMocks | ||
|
|
||
| const respondWith = (status: number, retryAfter?: string): void => { | ||
| mocks.fetch.mockResolvedValue({ | ||
| status, | ||
| text: () => Promise.resolve(''), | ||
| json: () => Promise.resolve({}), | ||
| headers: { get: (name: string) => (name.toLowerCase() === 'retry-after' && retryAfter ? retryAfter : null) }, | ||
| }) | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| ;[posthog, mocks] = createTestClient('TEST_API_KEY', { | ||
| host: 'http://example.com', | ||
| preloadFeatureFlags: false, | ||
| disableCompression: true, | ||
| fetchRetryCount: 0, | ||
| }) | ||
| }) | ||
|
|
||
| it.each([ | ||
| ['logs', () => posthog._sendLogsBatch({ resourceLogs: [] } as any)], | ||
| ['metrics', () => posthog._sendMetricsBatch({ resourceMetrics: [] } as any)], | ||
| ['traces', () => posthog._sendTracesBatch({ resourceSpans: [] } as any)], | ||
| ])("surfaces the endpoint's Retry-After to the %s queue", async (_signal, send) => { | ||
| respondWith(429, '120') | ||
|
|
||
| const outcome = await send() | ||
|
|
||
| expect(outcome).toMatchObject({ kind: 'retry-later', retryAfterMs: 120_000 }) | ||
| }) | ||
|
|
||
| it('sends once, not once per inner retry, when the endpoint names a wait', async () => { | ||
| // The inner retriable loop retries on a short fixed delay. Spending its | ||
| // attempts here would put three extra requests inside the very window the | ||
| // queue is about to back off for. | ||
| const [client, clientMocks] = createTestClient('TEST_API_KEY', { | ||
| host: 'http://example.com', | ||
| preloadFeatureFlags: false, | ||
| disableCompression: true, | ||
| }) | ||
| clientMocks.fetch.mockResolvedValue({ | ||
| status: 429, | ||
| text: () => Promise.resolve(''), | ||
| json: () => Promise.resolve({}), | ||
| headers: { get: (name: string) => (name.toLowerCase() === 'retry-after' ? '120' : null) }, | ||
| }) | ||
|
|
||
| const pending = client._sendTracesBatch({ resourceSpans: [] } as any) | ||
| await vi.advanceTimersByTimeAsync(60_000) | ||
|
|
||
| expect(await pending).toMatchObject({ kind: 'retry-later', retryAfterMs: 120_000 }) | ||
| expect(clientMocks.fetch).toHaveBeenCalledTimes(1) | ||
| }) | ||
|
|
||
| it('still retries internally when the response names no wait', async () => { | ||
| const [client, clientMocks] = createTestClient('TEST_API_KEY', { | ||
| host: 'http://example.com', | ||
| preloadFeatureFlags: false, | ||
| disableCompression: true, | ||
| }) | ||
| clientMocks.fetch.mockResolvedValue({ | ||
| status: 503, | ||
| text: () => Promise.resolve(''), | ||
| json: () => Promise.resolve({}), | ||
| headers: { get: () => null }, | ||
| }) | ||
|
|
||
| const pending = client._sendTracesBatch({ resourceSpans: [] } as any) | ||
| await vi.advanceTimersByTimeAsync(60_000) | ||
|
|
||
| expect((await pending).kind).toBe('retry-later') | ||
| expect(clientMocks.fetch.mock.calls.length).toBeGreaterThan(1) | ||
| }) | ||
|
|
||
| it('leaves retryAfterMs unset when the response sends no header', async () => { | ||
| respondWith(503) | ||
|
|
||
| const outcome = await posthog._sendTracesBatch({ resourceSpans: [] } as any) | ||
|
|
||
| expect(outcome.kind).toBe('retry-later') | ||
| expect((outcome as { retryAfterMs?: number }).retryAfterMs).toBeUndefined() | ||
| }) | ||
|
|
||
| it('survives a transport whose headers accessor throws', async () => { | ||
| mocks.fetch.mockResolvedValue({ | ||
| status: 503, | ||
| text: () => Promise.resolve(''), | ||
| json: () => Promise.resolve({}), | ||
| headers: { | ||
| get: () => { | ||
| throw new Error('hostile transport') | ||
| }, | ||
| }, | ||
| }) | ||
|
|
||
| const outcome = await posthog._sendTracesBatch({ resourceSpans: [] } as any) | ||
|
|
||
| expect(outcome.kind).toBe('retry-later') | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.