Skip to content

Commit 1a3bde3

Browse files
committed
fix(replay): Ensure we stop for rate limit headers
1 parent 4babd02 commit 1a3bde3

File tree

3 files changed

+79
-65
lines changed

3 files changed

+79
-65
lines changed

packages/replay/src/util/sendReplay.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { captureException, setContext } from '@sentry/core';
22

33
import { RETRY_BASE_INTERVAL, RETRY_MAX_COUNT, UNABLE_TO_SEND_REPLAY } from '../constants';
44
import type { SendReplayData } from '../types';
5-
import { sendReplayRequest, TransportStatusCodeError } from './sendReplayRequest';
5+
import { RateLimitError, sendReplayRequest, TransportStatusCodeError } from './sendReplayRequest';
66

77
/**
88
* Finalize and send the current replay event to Sentry
@@ -25,7 +25,7 @@ export async function sendReplay(
2525
await sendReplayRequest(replayData);
2626
return true;
2727
} catch (err) {
28-
if (err instanceof TransportStatusCodeError) {
28+
if (err instanceof TransportStatusCodeError || err instanceof RateLimitError) {
2929
throw err;
3030
}
3131

packages/replay/src/util/sendReplayRequest.ts

+19
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { getCurrentHub } from '@sentry/core';
22
import type { ReplayEvent, TransportMakeRequestResponse } from '@sentry/types';
3+
import type { RateLimits } from '@sentry/utils';
4+
import { isRateLimited, updateRateLimits } from '@sentry/utils';
35

46
import { REPLAY_EVENT_NAME, UNABLE_TO_SEND_REPLAY } from '../constants';
57
import type { SendReplayData } from '../types';
@@ -128,6 +130,11 @@ export async function sendReplayRequest({
128130
throw new TransportStatusCodeError(response.statusCode);
129131
}
130132

133+
const rateLimits = updateRateLimits({}, response);
134+
if (isRateLimited(rateLimits, 'replay')) {
135+
throw new RateLimitError(rateLimits);
136+
}
137+
131138
return response;
132139
}
133140

@@ -139,3 +146,15 @@ export class TransportStatusCodeError extends Error {
139146
super(`Transport returned status code ${statusCode}`);
140147
}
141148
}
149+
150+
/**
151+
* This error indicates that we hit a rate limit API error.
152+
*/
153+
export class RateLimitError extends Error {
154+
public rateLimits: RateLimits;
155+
156+
public constructor(rateLimits: RateLimits) {
157+
super('Rate limit hit');
158+
this.rateLimits = rateLimits;
159+
}
160+
}
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
import { getCurrentHub } from '@sentry/core';
2-
import type { Transport } from '@sentry/types';
2+
import type { Transport, TransportMakeRequestResponse } from '@sentry/types';
33

44
import { DEFAULT_FLUSH_MIN_DELAY } from '../../src/constants';
55
import type { ReplayContainer } from '../../src/replay';
66
import { clearSession } from '../../src/session/clearSession';
7-
import * as SendReplayRequest from '../../src/util/sendReplayRequest';
87
import { BASE_TIMESTAMP, mockSdk } from '../index';
9-
import { mockRrweb } from '../mocks/mockRrweb';
10-
import { getTestEventCheckout, getTestEventIncremental } from '../utils/getTestEvent';
118
import { useFakeTimers } from '../utils/use-fake-timers';
129

1310
useFakeTimers();
@@ -22,93 +19,91 @@ type MockTransportSend = jest.MockedFunction<Transport['send']>;
2219
describe('Integration | rate-limiting behaviour', () => {
2320
let replay: ReplayContainer;
2421
let mockTransportSend: MockTransportSend;
25-
let mockSendReplayRequest: jest.MockedFunction<any>;
26-
const { record: mockRecord } = mockRrweb();
2722

28-
beforeAll(async () => {
23+
beforeEach(async () => {
2924
jest.setSystemTime(new Date(BASE_TIMESTAMP));
3025

3126
({ replay } = await mockSdk({
27+
autoStart: false,
3228
replayOptions: {
3329
stickySession: false,
3430
},
3531
}));
3632

37-
jest.runAllTimers();
3833
mockTransportSend = getCurrentHub()?.getClient()?.getTransport()?.send as MockTransportSend;
39-
mockSendReplayRequest = jest.spyOn(SendReplayRequest, 'sendReplayRequest');
40-
});
41-
42-
beforeEach(() => {
43-
jest.setSystemTime(new Date(BASE_TIMESTAMP));
44-
mockRecord.takeFullSnapshot.mockClear();
45-
mockTransportSend.mockClear();
46-
47-
// Create a new session and clear mocks because a segment (from initial
48-
// checkout) will have already been uploaded by the time the tests run
49-
clearSession(replay);
50-
replay['_initializeSessionForSampling']();
51-
replay.setInitialState();
52-
53-
mockSendReplayRequest.mockClear();
5434
});
5535

5636
afterEach(async () => {
57-
jest.runAllTimers();
58-
await new Promise(process.nextTick);
59-
jest.setSystemTime(new Date(BASE_TIMESTAMP));
6037
clearSession(replay);
6138
jest.clearAllMocks();
62-
});
6339

64-
afterAll(() => {
6540
replay && replay.stop();
6641
});
6742

68-
it('handles rate-limit 429 responses by stopping the replay', async () => {
69-
expect(replay.session?.segmentId).toBe(0);
70-
jest.spyOn(replay, 'stop');
71-
72-
const TEST_EVENT = getTestEventCheckout({ timestamp: BASE_TIMESTAMP });
43+
it.each([
44+
['429 status code', { statusCode: 429, headers: {} } as TransportMakeRequestResponse],
45+
[
46+
'200 status code with x-sentry-rate-limits header',
47+
{
48+
statusCode: 200,
49+
headers: {
50+
'x-sentry-rate-limits': '30',
51+
},
52+
} as TransportMakeRequestResponse,
53+
],
54+
[
55+
'200 status code with x-sentry-rate-limits replay header',
56+
{
57+
statusCode: 200,
58+
headers: {
59+
'x-sentry-rate-limits': '30:replay',
60+
},
61+
} as TransportMakeRequestResponse,
62+
],
63+
])('handles %s responses by stopping the replay', async (_name, { statusCode, headers }) => {
64+
const mockStop = jest.spyOn(replay, 'stop');
7365

7466
mockTransportSend.mockImplementationOnce(() => {
75-
return Promise.resolve({ statusCode: 429 });
67+
return Promise.resolve({ statusCode, headers });
7668
});
7769

78-
mockRecord._emitter(TEST_EVENT);
79-
80-
// T = base + 5
70+
replay.start();
8171
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
8272

83-
expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
84-
expect(mockTransportSend).toHaveBeenCalledTimes(1);
85-
expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT]) });
86-
87-
expect(replay.stop).toHaveBeenCalledTimes(1);
88-
89-
// No user activity to trigger an update
90-
expect(replay.session).toBe(undefined);
91-
92-
// let's simulate the default rate-limit time of inactivity (60secs) and check that we
93-
// don't do anything in the meantime or after the time has passed
94-
// 60secs are the default we fall back to in the plain 429 case in updateRateLimits()
95-
96-
// T = base + 60
97-
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY * 12);
73+
expect(mockStop).toHaveBeenCalledTimes(1);
74+
expect(replay.session).toBeUndefined();
75+
expect(replay.isEnabled()).toBe(false);
76+
});
9877

99-
expect(mockSendReplayRequest).toHaveBeenCalledTimes(1);
100-
expect(mockTransportSend).toHaveBeenCalledTimes(1);
78+
it.each([
79+
[
80+
'200 status code without x-sentry-rate-limits header',
81+
{
82+
statusCode: 200,
83+
headers: {},
84+
} as TransportMakeRequestResponse,
85+
],
86+
[
87+
'200 status code with x-sentry-rate-limits profile header',
88+
{
89+
statusCode: 200,
90+
headers: {
91+
'x-sentry-rate-limits': '30:profile',
92+
},
93+
} as TransportMakeRequestResponse,
94+
],
95+
])('handles %s responses by not stopping', async (_name, { statusCode, headers }) => {
96+
const mockStop = jest.spyOn(replay, 'stop');
10197

102-
// and let's also emit a new event and check that it is not recorded
103-
const TEST_EVENT3 = getTestEventIncremental({
104-
data: {},
105-
timestamp: BASE_TIMESTAMP + 7 * DEFAULT_FLUSH_MIN_DELAY,
98+
mockTransportSend.mockImplementationOnce(() => {
99+
return Promise.resolve({ statusCode, headers });
106100
});
107-
mockRecord._emitter(TEST_EVENT3);
108101

109-
// T = base + 80
110-
await advanceTimers(20_000);
111-
expect(mockSendReplayRequest).toHaveBeenCalledTimes(1);
112-
expect(mockTransportSend).toHaveBeenCalledTimes(1);
102+
replay.start();
103+
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
104+
105+
expect(mockStop).not.toHaveBeenCalled();
106+
expect(replay.session).toBeDefined();
107+
expect(replay.isEnabled()).toBe(true);
113108
});
114109
});

0 commit comments

Comments
 (0)