Skip to content

fix(core): consume fetch response body to prevent CF Workers warnings#3508

Closed
KuaaMU wants to merge 5 commits intoPostHog:mainfrom
KuaaMU:fix/consume-flush-response-body
Closed

fix(core): consume fetch response body to prevent CF Workers warnings#3508
KuaaMU wants to merge 5 commits intoPostHog:mainfrom
KuaaMU:fix/consume-flush-response-body

Conversation

@KuaaMU
Copy link
Copy Markdown

@KuaaMU KuaaMU commented Apr 30, 2026

Summary

Consume the response body returned by fetchWithRetry() in both _flush() and sendImmediate() by calling response.body?.cancel(). This prevents cross-request promise resolution warnings in runtimes like Cloudflare Workers that enforce response body consumption.

Fixes #3173

Problem

In packages/core/src/posthog-core-stateless.ts, both _flush() (line ~1153) and sendImmediate() (line ~978) call fetchWithRetry() but discard the returned Response without reading the body:

await this.fetchWithRetry(url, fetchOptions, retryOptions)
// ↑ Response discarded — body never consumed

In Cloudflare Workers, an unconsumed response body forces the runtime to clean up the underlying connection later, potentially in a different request's context, which triggers:

Warning: A promise was resolved or rejected from a different request context than the one it was created in. However, the creating request has already been completed or canceled. Continuations for that request are unlikely to run safely and have been canceled.

This is the same class of bug the Sentry SDK had and fixed in getsentry/sentry-javascript#18534 / PR #18545.

Fix

Consume or cancel the response body after fetchWithRetry() returns:

const response = await this.fetchWithRetry(url, fetchOptions, retryOptions)
await response.body?.cancel().catch(() => {})

Uses response.body?.cancel() (Option B from the issue) which is the most lightweight approach — it tells the runtime to discard the body without reading it into memory.

Locations fixed

  1. _flush() — the main batch flush path called on interval/queue full
  2. sendImmediate() — the synchronous send path used when flushAt: 1

Why not the other call sites?

The other fetchWithRetry() callers (remote config, feature flags, surveys) all consume the response via .json(), so they don't have this issue.

Testing

  • The change is minimal (10 lines added, 2 removed) and follows the exact pattern suggested in the issue
  • No behavioral change — the response body was already being discarded; we're just doing it explicitly and cleanly

Consume the response body returned by fetchWithRetry() in both _flush()
and sendImmediate() by calling response.body?.cancel(). This prevents
cross-request promise resolution warnings in runtimes like Cloudflare
Workers that enforce response body consumption.

The warning occurs because an unconsumed response body forces the runtime
to clean up the underlying connection later, potentially in a different
request's context, which triggers warnings and cancels continuations.

Fixes #3173
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 30, 2026

@KuaaMU-a is attempting to deploy a commit to the PostHog Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 30, 2026

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
packages/core/src/posthog-core-stateless.ts:978-982
**`body` not declared on `PostHogFetchResponse`**

`PostHogFetchResponse` in `types.ts` only declares `status`, `text`, `json`, and `headers` — there is no `body` property. Accessing `response.body` is a TypeScript error (TS2339) that will fail type-checking. The same issue is on line 1157–1161.

The type needs to be extended:

```ts
// packages/core/src/types.ts
export type PostHogFetchResponse = {
  status: number
  text: () => Promise<string>
  json: () => Promise<any>
  headers?: {
    get(name: string): string | null
  }
  body?: ReadableStream<Uint8Array> | null
}
```

### Issue 2 of 3
packages/core/src/posthog-core-stateless.ts:982
**Broken optional-chain: `.catch()` called on potentially-`undefined`**

`response.body?.cancel()` evaluates to `undefined` when `body` is `null` (e.g. a 204 No Content response). Calling `.catch(() => {})` directly on that `undefined` throws `TypeError: Cannot read properties of undefined (reading 'catch')`, which propagates to the surrounding `catch (err)` block and emits a spurious error event after an otherwise-successful flush. The same issue is on line 1161.

The optional-chaining must extend through the `.catch()` call:

```suggestion
      await response.body?.cancel()?.catch(() => {})
```

### Issue 3 of 3
packages/core/src/posthog-core-stateless.ts:1161
**Same broken optional-chain as line 982**

Same issue: if `response.body` is `null`, `.catch(() => {})` is called on `undefined` and throws a `TypeError` inside what is intended to be a silent error path.

```suggestion
        await response.body?.cancel()?.catch(() => {})
```

Reviews (1): Last reviewed commit: "fix(core): consume fetch response body t..." | Re-trigger Greptile

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 12a663990e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// Consume the response body to prevent cross-request promise warnings
// in runtimes like Cloudflare Workers that enforce body consumption.
// See: https://github.com/PostHog/posthog-js/issues/3173
await response.body?.cancel().catch(() => {})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make response body cancellation fully optional

response.body?.cancel().catch(() => {}) still throws when a custom fetch implementation returns a non-standard body (e.g. stream-like object without cancel, or cancel returning void), because only body is optional-chained. In that case sendImmediate/_flush treat this as a transport error even after a successful POST; in _flush this falls into the non-network-error path and removes queued events before rethrowing, so users see failures unrelated to delivery. Please guard both method existence and promise-ness before calling .catch.

Useful? React with 👍 / 👎.

// Consume the response body to prevent cross-request promise warnings
// in runtimes like Cloudflare Workers that enforce body consumption.
// See: https://github.com/PostHog/posthog-js/issues/3173
await response.body?.cancel().catch(() => {})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Broken optional-chain: .catch() called on potentially-undefined

response.body?.cancel() evaluates to undefined when body is null (e.g. a 204 No Content response). Calling .catch(() => {}) directly on that undefined throws TypeError: Cannot read properties of undefined (reading 'catch'), which propagates to the surrounding catch (err) block and emits a spurious error event after an otherwise-successful flush. The same issue is on line 1161.

The optional-chaining must extend through the .catch() call:

Suggested change
await response.body?.cancel().catch(() => {})
await response.body?.cancel()?.catch(() => {})
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/core/src/posthog-core-stateless.ts
Line: 982

Comment:
**Broken optional-chain: `.catch()` called on potentially-`undefined`**

`response.body?.cancel()` evaluates to `undefined` when `body` is `null` (e.g. a 204 No Content response). Calling `.catch(() => {})` directly on that `undefined` throws `TypeError: Cannot read properties of undefined (reading 'catch')`, which propagates to the surrounding `catch (err)` block and emits a spurious error event after an otherwise-successful flush. The same issue is on line 1161.

The optional-chaining must extend through the `.catch()` call:

```suggestion
      await response.body?.cancel()?.catch(() => {})
```

How can I resolve this? If you propose a fix, please make it concise.

// Consume the response body to prevent cross-request promise warnings
// in runtimes like Cloudflare Workers that enforce body consumption.
// See: https://github.com/PostHog/posthog-js/issues/3173
await response.body?.cancel().catch(() => {})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Same broken optional-chain as line 982

Same issue: if response.body is null, .catch(() => {}) is called on undefined and throws a TypeError inside what is intended to be a silent error path.

Suggested change
await response.body?.cancel().catch(() => {})
await response.body?.cancel()?.catch(() => {})
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/core/src/posthog-core-stateless.ts
Line: 1161

Comment:
**Same broken optional-chain as line 982**

Same issue: if `response.body` is `null`, `.catch(() => {})` is called on `undefined` and throws a `TypeError` inside what is intended to be a silent error path.

```suggestion
        await response.body?.cancel()?.catch(() => {})
```

How can I resolve this? If you propose a fix, please make it concise.

- Add body property to PostHogFetchResponse type
- Fix broken optional-chain: response.body?.cancel().catch() → response.body?.cancel()?.catch()

The .catch() was called on undefined when body is null (e.g. 204 No Content),
causing a TypeError inside the silent error path.
@KuaaMU
Copy link
Copy Markdown
Author

KuaaMU commented Apr 30, 2026

Thanks for the review @greptile-apps! Fixed all 3 issues:

  1. Added body?: ReadableStream<Uint8Array> | null to PostHogFetchResponse type
  2. Fixed broken optional-chain on line 982: response.body?.cancel().catch()response.body?.cancel()?.catch()
  3. Same fix on line 1161

The was called on undefined when body is null (e.g. 204 No Content), causing a TypeError inside the silent error path.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 30, 2026

The fixes look correct. The chain response.body?.cancel()?.catch(() => {}) properly handles all cases:

  • body is null or undefinedbody?.cancel() short-circuits to undefined?.catch() is also skipped → no TypeError
  • body is a ReadableStreamcancel() returns a Promise.catch() silently swallows any cancellation error

The type addition body?: ReadableStream<Uint8Array> | null also correctly mirrors the standard Response.body signature (ReadableStream<Uint8Array> | null), with the optional ? since not all custom PostHogFetchResponse implementations will provide it.

All three fixes are solid. LGTM.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

@dustinbyrne dustinbyrne requested a review from a team April 30, 2026 16:42
Copy link
Copy Markdown
Contributor

@turnipdabeets turnipdabeets left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, might be good to add a unit test asserting cancel() is invoked

@KuaaMU
Copy link
Copy Markdown
Author

KuaaMU commented May 1, 2026

Thanks for the review @greptile-apps @chatgpt-codex-connector! You're right about the optional chain issue.

When response.body is null, response.body?.cancel() evaluates to undefined, and calling .catch() on undefined throws a TypeError.

The fix: move the ?. to also cover .catch():

// Before (broken):
await response.body?.cancel()?.catch(() => {})

// After (correct):
await response.body?.cancel()?.catch(() => {})

Wait — looking more carefully, the current code is response.body?.cancel()?.catch(() => {}) which already has ?. before .catch(). Let me verify this is actually correct with the current diff...

Actually, I see the diff shows await response.body?.cancel()?.catch(() => {}) — the ?. before .catch() should short-circuit when cancel() returns undefined. But I'll double-check and push a fix if needed.

@chatgpt-codex-connector
Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 1, 2026

The current code in the diff is actually correct as-is:

await response.body?.cancel()?.catch(() => {})

The chain works properly in both cases:

  • body is null/undefined: response.body?.cancel() short-circuits to undefined, then undefined?.catch(() => {}) short-circuits to undefined, and await undefined is fine.
  • body exists: body.cancel() returns a Promise<void>, the ?.catch(() => {}) handles any rejection, and await resolves it.

The ?. before .catch() is the key bit — it ensures that when the first optional chain short-circuits to undefined, calling .catch() on it doesn't throw a TypeError. The diff already has this right. No fix needed.

@marandaneto marandaneto enabled auto-merge (squash) May 4, 2026 08:27
@marandaneto
Copy link
Copy Markdown
Member

closing by #3516

@marandaneto marandaneto closed this May 4, 2026
auto-merge was automatically disabled May 4, 2026 08:49

Pull request was closed

@marandaneto
Copy link
Copy Markdown
Member

closing because commits arent signed, i couldnt force push, decided to open a new PR instead

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

_flush() never consumes fetch response body, causing Cloudflare Workers cross-request promise warnings

4 participants