Skip to content

Commit 7b9198f

Browse files
authored
fix(browser): Ensure keepalive flag is correctly set for parallel requests (#7553)
We noticed that sometimes request would remain in a seemingly pending state. After some investigation, we found out that the limit of 64kb for keepalive-enabled fetch requests is not per request but for all parallel requests running at the same time. This fixes this by keeping track of how large the pending body sizes are, plus the # of pending requests, and setting keepalive accordingly.
1 parent 5c5ac2c commit 7b9198f

File tree

2 files changed

+81
-11
lines changed

2 files changed

+81
-11
lines changed

packages/browser/src/transports/fetch.ts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,14 @@ export function makeFetchTransport(
1313
options: BrowserTransportOptions,
1414
nativeFetch: FetchImpl = getNativeFetchImplementation(),
1515
): Transport {
16+
let pendingBodySize = 0;
17+
let pendingCount = 0;
18+
1619
function makeRequest(request: TransportRequest): PromiseLike<TransportMakeRequestResponse> {
20+
const requestSize = request.body.length;
21+
pendingBodySize += requestSize;
22+
pendingCount++;
23+
1724
const requestOptions: RequestInit = {
1825
body: request.body,
1926
method: 'POST',
@@ -25,23 +32,31 @@ export function makeFetchTransport(
2532
// frequently sending events right before the user is switching pages (eg. whenfinishing navigation transactions).
2633
// Gotchas:
2734
// - `keepalive` isn't supported by Firefox
28-
// - As per spec (https://fetch.spec.whatwg.org/#http-network-or-cache-fetch), a request with `keepalive: true`
29-
// and a content length of > 64 kibibytes returns a network error. We will therefore only activate the flag when
30-
// we're below that limit.
31-
keepalive: request.body.length <= 65536,
35+
// - As per spec (https://fetch.spec.whatwg.org/#http-network-or-cache-fetch):
36+
// If the sum of contentLength and inflightKeepaliveBytes is greater than 64 kibibytes, then return a network error.
37+
// We will therefore only activate the flag when we're below that limit.
38+
// There is also a limit of requests that can be open at the same time, so we also limit this to 15
39+
// See https://github.com/getsentry/sentry-javascript/pull/7553 for details
40+
keepalive: pendingBodySize <= 60_000 && pendingCount < 15,
3241
...options.fetchOptions,
3342
};
3443

3544
try {
36-
return nativeFetch(options.url, requestOptions).then(response => ({
37-
statusCode: response.status,
38-
headers: {
39-
'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'),
40-
'retry-after': response.headers.get('Retry-After'),
41-
},
42-
}));
45+
return nativeFetch(options.url, requestOptions).then(response => {
46+
pendingBodySize -= requestSize;
47+
pendingCount--;
48+
return {
49+
statusCode: response.status,
50+
headers: {
51+
'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'),
52+
'retry-after': response.headers.get('Retry-After'),
53+
},
54+
};
55+
});
4356
} catch (e) {
4457
clearCachedFetchImplementation();
58+
pendingBodySize -= requestSize;
59+
pendingCount--;
4560
return rejectedSyncPromise(e);
4661
}
4762
}

packages/browser/test/unit/transports/fetch.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ const ERROR_ENVELOPE = createEnvelope<EventEnvelope>({ event_id: 'aa3ff046696b4b
1616
[{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem,
1717
]);
1818

19+
const LARGE_ERROR_ENVELOPE = createEnvelope<EventEnvelope>(
20+
{ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' },
21+
[[{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', message: 'x'.repeat(10 * 900) }] as EventItem],
22+
);
23+
1924
class Headers {
2025
headers: { [key: string]: string } = {};
2126
get(key: string) {
@@ -107,4 +112,54 @@ describe('NewFetchTransport', () => {
107112
await expect(() => transport.send(ERROR_ENVELOPE)).not.toThrow();
108113
expect(mockFetch).toHaveBeenCalledTimes(1);
109114
});
115+
116+
it('correctly sets keepalive flag', async () => {
117+
const mockFetch = jest.fn(() =>
118+
Promise.resolve({
119+
headers: new Headers(),
120+
status: 200,
121+
text: () => Promise.resolve({}),
122+
}),
123+
) as unknown as FetchImpl;
124+
125+
const REQUEST_OPTIONS: RequestInit = {
126+
referrerPolicy: 'strict-origin',
127+
referrer: 'http://example.org',
128+
};
129+
130+
const transport = makeFetchTransport(
131+
{ ...DEFAULT_FETCH_TRANSPORT_OPTIONS, fetchOptions: REQUEST_OPTIONS },
132+
mockFetch,
133+
);
134+
135+
const promises: PromiseLike<unknown>[] = [];
136+
for (let i = 0; i < 30; i++) {
137+
promises.push(transport.send(LARGE_ERROR_ENVELOPE));
138+
}
139+
140+
await Promise.all(promises);
141+
142+
for (let i = 1; i <= 30; i++) {
143+
// After 7 requests, we hit the total limit of >64kb of size
144+
// Starting there, keepalive should be false
145+
const keepalive = i < 7;
146+
expect(mockFetch).toHaveBeenNthCalledWith(i, expect.any(String), expect.objectContaining({ keepalive }));
147+
}
148+
149+
(mockFetch as jest.Mock<unknown>).mockClear();
150+
151+
// Limit resets when requests have resolved
152+
// Now try based on # of pending requests
153+
const promises2 = [];
154+
for (let i = 0; i < 20; i++) {
155+
promises2.push(transport.send(ERROR_ENVELOPE));
156+
}
157+
158+
await Promise.all(promises2);
159+
160+
for (let i = 1; i <= 20; i++) {
161+
const keepalive = i < 15;
162+
expect(mockFetch).toHaveBeenNthCalledWith(i, expect.any(String), expect.objectContaining({ keepalive }));
163+
}
164+
});
110165
});

0 commit comments

Comments
 (0)