Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/bun/src/transports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
154 changes: 154 additions & 0 deletions packages/bun/test/transport.test.ts
Original file line number Diff line number Diff line change
@@ -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<EventEnvelope>({ 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');
});
});
});
Loading