From c4dd7cb3d54afd5355c63ecca4c1fca85b5ac0d9 Mon Sep 17 00:00:00 2001 From: Alp Date: Sat, 21 Mar 2026 15:00:20 -0400 Subject: [PATCH 1/4] fix(bun): Consume fetch response body to prevent memory leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bun's fetch implementation retains the backing ArrayBuffer of unconsumed response bodies indefinitely. This causes a memory leak when sending many Sentry envelopes, as each response's ArrayBuffer accumulates in memory. This applies the same fix that was made for the Cloudflare transport in #18545 — consuming the response body with response.text() after extracting the needed headers. In production, this leak manifests as ~8KB ArrayBuffers accumulating at ~8/sec (one per envelope), leading to OOM kills after ~5 hours on containers with 4GB memory limits. --- packages/bun/src/transports/index.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/bun/src/transports/index.ts b/packages/bun/src/transports/index.ts index 20df5bb4b521..149a7c499e32 100644 --- a/packages/bun/src/transports/index.ts +++ b/packages/bun/src/transports/index.ts @@ -14,7 +14,18 @@ export function makeFetchTransport(options: BaseTransportOptions): Transport { try { return suppressTracing(() => { - return fetch(options.url, requestOptions).then(response => { + return fetch(options.url, requestOptions).then(async response => { + // Consume the response body to prevent memory leaks in Bun's fetch implementation. + // Bun retains the backing ArrayBuffer of unconsumed response bodies indefinitely, + // causing memory to accumulate when sending many Sentry envelopes. + // See: https://github.com/getsentry/sentry-javascript/issues/18534 + try { + await response.text(); + } catch { + // We don't care about the response body, but consuming it is necessary + // to prevent memory leaks in Bun's fetch implementation + } + return { statusCode: response.status, headers: { From 9064c20d27171b4fad69a334a9c7784c15ef181a Mon Sep 17 00:00:00 2001 From: Alp Date: Sat, 21 Mar 2026 15:06:53 -0400 Subject: [PATCH 2/4] refactor: use void instead of await for response body drain Use `void response.text().catch(() => {})` instead of `await response.text()` to avoid adding latency to Sentry envelope sends. We don't need the body content, just need to trigger the drain so Bun can free the ArrayBuffer. --- packages/bun/src/transports/index.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/bun/src/transports/index.ts b/packages/bun/src/transports/index.ts index 149a7c499e32..ff1614e63893 100644 --- a/packages/bun/src/transports/index.ts +++ b/packages/bun/src/transports/index.ts @@ -14,17 +14,10 @@ export function makeFetchTransport(options: BaseTransportOptions): Transport { try { return suppressTracing(() => { - return fetch(options.url, requestOptions).then(async response => { - // Consume the response body to prevent memory leaks in Bun's fetch implementation. - // Bun retains the backing ArrayBuffer of unconsumed response bodies indefinitely, - // causing memory to accumulate when sending many Sentry envelopes. - // See: https://github.com/getsentry/sentry-javascript/issues/18534 - try { - await response.text(); - } catch { - // We don't care about the response body, but consuming it is necessary - // to prevent memory leaks in Bun's fetch implementation - } + return fetch(options.url, requestOptions).then(response => { + // Drain response body to prevent Bun from retaining the backing ArrayBuffer. + // See: https://github.com/oven-sh/bun/issues/10763, https://github.com/oven-sh/bun/issues/27358 + void response.text().catch(() => {}); return { statusCode: response.status, From 319f434b4cbfd1982957b047167008b4012347fd Mon Sep 17 00:00:00 2001 From: Alp Date: Sat, 21 Mar 2026 15:15:12 -0400 Subject: [PATCH 3/4] test(bun): Add transport test for response body consumption --- packages/bun/test/transport.test.ts | 154 ++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 packages/bun/test/transport.test.ts diff --git a/packages/bun/test/transport.test.ts b/packages/bun/test/transport.test.ts new file mode 100644 index 000000000000..492cd11a3c5f --- /dev/null +++ b/packages/bun/test/transport.test.ts @@ -0,0 +1,154 @@ +import type { EventEnvelope, EventItem } from '@sentry/core'; +import { createEnvelope, serializeEnvelope } from '@sentry/core'; +import { afterAll, describe, expect, it, mock } from 'bun:test'; +import { makeFetchTransport } from '../src/transports'; + +const DEFAULT_TRANSPORT_OPTIONS = { + url: 'https://sentry.io/api/42/store/?sentry_key=123&sentry_version=7', + recordDroppedEvent: () => undefined, +}; + +const ERROR_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, +]); + +const mockFetch = mock(); + +const oldFetch = globalThis.fetch; +globalThis.fetch = mockFetch as typeof fetch; + +afterAll(() => { + globalThis.fetch = oldFetch; +}); + +describe('Bun Fetch Transport', () => { + it('calls fetch with the given URL', async () => { + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + headers: new Headers(), + status: 200, + text: () => Promise.resolve(''), + }), + ); + + const transport = makeFetchTransport(DEFAULT_TRANSPORT_OPTIONS); + + expect(mockFetch).toHaveBeenCalledTimes(0); + await transport.send(ERROR_ENVELOPE); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenLastCalledWith(DEFAULT_TRANSPORT_OPTIONS.url, { + body: serializeEnvelope(ERROR_ENVELOPE), + method: 'POST', + headers: undefined, + }); + }); + + it('sets rate limit headers', async () => { + const headers = { + get: mock((key: string) => { + if (key === 'X-Sentry-Rate-Limits') return 'rate-limit-value'; + if (key === 'Retry-After') return '42'; + return null; + }), + }; + + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + headers, + status: 200, + text: () => Promise.resolve(''), + }), + ); + + const transport = makeFetchTransport(DEFAULT_TRANSPORT_OPTIONS); + + const result = await transport.send(ERROR_ENVELOPE); + + expect(headers.get).toHaveBeenCalledTimes(2); + expect(headers.get).toHaveBeenCalledWith('X-Sentry-Rate-Limits'); + expect(headers.get).toHaveBeenCalledWith('Retry-After'); + expect(result).toEqual({ + statusCode: 200, + headers: { + 'x-sentry-rate-limits': 'rate-limit-value', + 'retry-after': '42', + }, + }); + }); + + describe('Response body consumption (issue #18534)', () => { + it('consumes the response body to prevent memory leaks in Bun', async () => { + const textMock = mock(() => Promise.resolve('OK')); + const headers = { + get: mock(() => null), + }; + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + headers, + status: 200, + text: textMock, + }), + ); + + const transport = makeFetchTransport(DEFAULT_TRANSPORT_OPTIONS); + + await transport.send(ERROR_ENVELOPE); + + expect(textMock).toHaveBeenCalledTimes(1); + expect(headers.get).toHaveBeenCalledTimes(2); + expect(headers.get).toHaveBeenCalledWith('X-Sentry-Rate-Limits'); + expect(headers.get).toHaveBeenCalledWith('Retry-After'); + }); + + it('handles response body consumption errors gracefully', async () => { + const textMock = mock(() => Promise.reject(new Error('Body read error'))); + const headers = { + get: mock(() => null), + }; + + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + headers, + status: 200, + text: textMock, + }), + ); + + const transport = makeFetchTransport(DEFAULT_TRANSPORT_OPTIONS); + + // Should not throw even though text() rejects + const result = await transport.send(ERROR_ENVELOPE); + + expect(result).toBeDefined(); + expect(textMock).toHaveBeenCalledTimes(1); + expect(headers.get).toHaveBeenCalledTimes(2); + expect(headers.get).toHaveBeenCalledWith('X-Sentry-Rate-Limits'); + expect(headers.get).toHaveBeenCalledWith('Retry-After'); + }); + + it('handles a response without a text method', async () => { + const headers = { + get: mock(() => null), + }; + + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + headers, + status: 200, + // No text method on the response + }), + ); + + const transport = makeFetchTransport(DEFAULT_TRANSPORT_OPTIONS); + + // Should not throw even without text() + const result = await transport.send(ERROR_ENVELOPE); + + expect(result).toBeDefined(); + expect(headers.get).toHaveBeenCalledTimes(2); + expect(headers.get).toHaveBeenCalledWith('X-Sentry-Rate-Limits'); + expect(headers.get).toHaveBeenCalledWith('Retry-After'); + }); + }); +}); From 4d256a6b22e8e147ad1ccd14df79f830f49c8549 Mon Sep 17 00:00:00 2001 From: Alp Date: Sat, 21 Mar 2026 15:26:31 -0400 Subject: [PATCH 4/4] fix(bun): wrap response body drain in try/catch for sync errors The `void response.text().catch(() => {})` pattern only handles async promise rejections. If `response.text` is not a function, a synchronous TypeError is thrown before `.catch()` is reached, rejecting the entire promise chain and preventing the transport from returning status/headers. Wrap in try/catch to handle both: - try/catch: synchronous TypeError (response.text not a function) - .catch(): async rejection (body read fails mid-stream) --- packages/bun/src/transports/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/bun/src/transports/index.ts b/packages/bun/src/transports/index.ts index ff1614e63893..d5041c00bfd7 100644 --- a/packages/bun/src/transports/index.ts +++ b/packages/bun/src/transports/index.ts @@ -17,7 +17,9 @@ export function makeFetchTransport(options: BaseTransportOptions): Transport { return fetch(options.url, requestOptions).then(response => { // Drain response body to prevent Bun from retaining the backing ArrayBuffer. // See: https://github.com/oven-sh/bun/issues/10763, https://github.com/oven-sh/bun/issues/27358 - void response.text().catch(() => {}); + // try/catch: guards against synchronous TypeError if response.text is not a function. + // .catch(): handles async rejection if body read fails mid-stream (prevents unhandled promise rejection). + try { void response.text().catch(() => {}); } catch {} // eslint-disable-line no-empty return { statusCode: response.status,