diff --git a/packages/bun/src/transports/index.ts b/packages/bun/src/transports/index.ts index 20df5bb4b521..d5041c00bfd7 100644 --- a/packages/bun/src/transports/index.ts +++ b/packages/bun/src/transports/index.ts @@ -15,6 +15,12 @@ export function makeFetchTransport(options: BaseTransportOptions): Transport { try { return suppressTracing(() => { 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 + // 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, headers: { 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'); + }); + }); +});