Skip to content

Commit 4c2a170

Browse files
committed
rfe(replay): Stop recording when hitting a rate limit
1 parent b9a004f commit 4c2a170

File tree

4 files changed

+15
-242
lines changed

4 files changed

+15
-242
lines changed

packages/replay/src/replay.ts

+1-33
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
import { EventType, record } from '@sentry-internal/rrweb';
33
import { captureException } from '@sentry/core';
44
import type { Breadcrumb, ReplayRecordingMode } from '@sentry/types';
5-
import type { RateLimits } from '@sentry/utils';
6-
import { disabledUntil, logger } from '@sentry/utils';
5+
import { logger } from '@sentry/utils';
76

87
import {
98
ERROR_CHECKOUT_TIME,
@@ -40,7 +39,6 @@ import { isExpired } from './util/isExpired';
4039
import { isSessionExpired } from './util/isSessionExpired';
4140
import { overwriteRecordDroppedEvent, restoreRecordDroppedEvent } from './util/monkeyPatchRecordDroppedEvent';
4241
import { sendReplay } from './util/sendReplay';
43-
import { RateLimitError } from './util/sendReplayRequest';
4442

4543
/**
4644
* The main replay container class, which holds all the state and methods for recording and sending replays.
@@ -809,11 +807,6 @@ export class ReplayContainer implements ReplayContainerInterface {
809807
} catch (err) {
810808
this._handleException(err);
811809

812-
if (err instanceof RateLimitError) {
813-
this._handleRateLimit(err.rateLimits);
814-
return;
815-
}
816-
817810
// This means we retried 3 times, and all of them failed
818811
// In this case, we want to completely stop the replay - otherwise, we may get inconsistent segments
819812
this.stop();
@@ -873,29 +866,4 @@ export class ReplayContainer implements ReplayContainerInterface {
873866
saveSession(this.session);
874867
}
875868
}
876-
877-
/**
878-
* Pauses the replay and resumes it after the rate-limit duration is over.
879-
*/
880-
private _handleRateLimit(rateLimits: RateLimits): void {
881-
// in case recording is already paused, we don't need to do anything, as we might have already paused because of a
882-
// rate limit
883-
if (this.isPaused()) {
884-
return;
885-
}
886-
887-
const rateLimitEnd = disabledUntil(rateLimits, 'replay');
888-
const rateLimitDuration = rateLimitEnd - Date.now();
889-
890-
if (rateLimitDuration > 0) {
891-
__DEBUG_BUILD__ && logger.warn('[Replay]', `Rate limit hit, pausing replay for ${rateLimitDuration}ms`);
892-
this.pause();
893-
this._debouncedFlush.cancel();
894-
895-
setTimeout(() => {
896-
__DEBUG_BUILD__ && logger.info('[Replay]', 'Resuming replay after rate limit');
897-
this.resume();
898-
}, rateLimitDuration);
899-
}
900-
}
901869
}

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 { RateLimitError, sendReplayRequest, TransportStatusCodeError } from './sendReplayRequest';
5+
import { 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 RateLimitError || err instanceof TransportStatusCodeError) {
28+
if (err instanceof TransportStatusCodeError) {
2929
throw err;
3030
}
3131

packages/replay/src/util/sendReplayRequest.ts

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

65
import { REPLAY_EVENT_NAME, UNABLE_TO_SEND_REPLAY } from '../constants';
76
import type { SendReplayData } from '../types';
@@ -125,11 +124,6 @@ export async function sendReplayRequest({
125124
return response;
126125
}
127126

128-
const rateLimits = updateRateLimits({}, response);
129-
if (isRateLimited(rateLimits, 'replay')) {
130-
throw new RateLimitError(rateLimits);
131-
}
132-
133127
// If the status code is invalid, we want to immediately stop & not retry
134128
if (typeof response.statusCode === 'number' && (response.statusCode < 200 || response.statusCode >= 300)) {
135129
throw new TransportStatusCodeError(response.statusCode);
@@ -138,18 +132,6 @@ export async function sendReplayRequest({
138132
return response;
139133
}
140134

141-
/**
142-
* This error indicates that we hit a rate limit API error.
143-
*/
144-
export class RateLimitError extends Error {
145-
public rateLimits: RateLimits;
146-
147-
public constructor(rateLimits: RateLimits) {
148-
super('Rate limit hit');
149-
this.rateLimits = rateLimits;
150-
}
151-
}
152-
153135
/**
154136
* This error indicates that the transport returned an invalid status code.
155137
*/

packages/replay/test/integration/rateLimiting.test.ts

+11-188
Original file line numberDiff line numberDiff line change
@@ -64,120 +64,9 @@ describe('Integration | rate-limiting behaviour', () => {
6464
replay && replay.stop();
6565
});
6666

67-
it.each([
68-
{
69-
statusCode: 429,
70-
headers: {
71-
'x-sentry-rate-limits': '30',
72-
'retry-after': null,
73-
},
74-
},
75-
{
76-
statusCode: 429,
77-
headers: {
78-
'x-sentry-rate-limits': '30:replay',
79-
'retry-after': null,
80-
},
81-
},
82-
{
83-
statusCode: 429,
84-
headers: {
85-
'x-sentry-rate-limits': null,
86-
'retry-after': '30',
87-
},
88-
},
89-
] as TransportMakeRequestResponse[])(
90-
'pauses recording and flushing a rate limit is hit and resumes both after the rate limit duration is over %j',
91-
async rateLimitResponse => {
92-
expect(replay.session?.segmentId).toBe(0);
93-
jest.spyOn(replay, 'pause');
94-
jest.spyOn(replay, 'resume');
95-
// @ts-ignore private API
96-
jest.spyOn(replay, '_handleRateLimit');
97-
98-
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 };
99-
100-
mockTransportSend.mockImplementationOnce(() => {
101-
return Promise.resolve(rateLimitResponse);
102-
});
103-
104-
mockRecord._emitter(TEST_EVENT);
105-
106-
// T = base + 5
107-
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
108-
109-
expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
110-
expect(mockTransportSend).toHaveBeenCalledTimes(1);
111-
expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT]) });
112-
113-
expect(replay['_handleRateLimit']).toHaveBeenCalledTimes(1);
114-
// resume() was called once before we even started
115-
expect(replay.resume).not.toHaveBeenCalled();
116-
expect(replay.pause).toHaveBeenCalledTimes(1);
117-
118-
// No user activity to trigger an update
119-
expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP);
120-
expect(replay.session?.segmentId).toBe(1);
121-
122-
// let's simulate the rate-limit time of inactivity (30secs) and check that we don't do anything in the meantime
123-
const TEST_EVENT2 = { data: {}, timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY, type: 3 };
124-
for (let i = 0; i < 5; i++) {
125-
const ev = {
126-
...TEST_EVENT2,
127-
timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY * (i + 1),
128-
};
129-
mockRecord._emitter(ev);
130-
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
131-
expect(replay.isPaused()).toBe(true);
132-
expect(mockSendReplayRequest).toHaveBeenCalledTimes(1);
133-
expect(mockTransportSend).toHaveBeenCalledTimes(1);
134-
}
135-
136-
// T = base + 35
137-
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
138-
139-
// now, recording should resume and first, we expect a checkout event to be sent, as resume()
140-
// should trigger a full snapshot
141-
expect(replay.resume).toHaveBeenCalledTimes(1);
142-
expect(replay.isPaused()).toBe(false);
143-
144-
expect(mockSendReplayRequest).toHaveBeenCalledTimes(2);
145-
expect(replay).toHaveLastSentReplay({
146-
recordingData: JSON.stringify([
147-
{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY * 7, type: 2 },
148-
]),
149-
});
150-
151-
// and let's also emit a new event and check that it is recorded
152-
const TEST_EVENT3 = {
153-
data: {},
154-
timestamp: BASE_TIMESTAMP + 7 * DEFAULT_FLUSH_MIN_DELAY,
155-
type: 3,
156-
};
157-
mockRecord._emitter(TEST_EVENT3);
158-
159-
// T = base + 40
160-
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
161-
expect(mockSendReplayRequest).toHaveBeenCalledTimes(3);
162-
expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT3]) });
163-
164-
// nothing should happen afterwards
165-
// T = base + 60
166-
await advanceTimers(20_000);
167-
expect(mockSendReplayRequest).toHaveBeenCalledTimes(3);
168-
expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT3]) });
169-
170-
// events array should be empty
171-
expect(replay.eventBuffer?.hasEvents).toBe(false);
172-
},
173-
);
174-
175-
it('handles rate-limits from a plain 429 response without any retry time', async () => {
67+
it('handles rate-limit 429 responses by stopping the replay', async () => {
17668
expect(replay.session?.segmentId).toBe(0);
177-
jest.spyOn(replay, 'pause');
178-
jest.spyOn(replay, 'resume');
179-
// @ts-ignore private API
180-
jest.spyOn(replay, '_handleRateLimit');
69+
jest.spyOn(replay, 'stop');
18170

18271
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 };
18372

@@ -194,99 +83,33 @@ describe('Integration | rate-limiting behaviour', () => {
19483
expect(mockTransportSend).toHaveBeenCalledTimes(1);
19584
expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT]) });
19685

197-
expect(replay['_handleRateLimit']).toHaveBeenCalledTimes(1);
198-
// resume() was called once before we even started
199-
expect(replay.resume).not.toHaveBeenCalled();
200-
expect(replay.pause).toHaveBeenCalledTimes(1);
86+
expect(replay.stop).toHaveBeenCalledTimes(1);
20187

20288
// No user activity to trigger an update
20389
expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP);
20490
expect(replay.session?.segmentId).toBe(1);
20591

206-
// let's simulate the rate-limit time of inactivity (60secs) and check that we don't do anything in the meantime
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
20794
// 60secs are the default we fall back to in the plain 429 case in updateRateLimits()
208-
const TEST_EVENT2 = { data: {}, timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY, type: 3 };
209-
for (let i = 0; i < 11; i++) {
210-
const ev = {
211-
...TEST_EVENT2,
212-
timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY * (i + 1),
213-
};
214-
mockRecord._emitter(ev);
215-
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
216-
expect(replay.isPaused()).toBe(true);
217-
expect(mockSendReplayRequest).toHaveBeenCalledTimes(1);
218-
expect(mockTransportSend).toHaveBeenCalledTimes(1);
219-
}
22095

22196
// T = base + 60
222-
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
223-
224-
// now, recording should resume and first, we expect a checkout event to be sent, as resume()
225-
// should trigger a full snapshot
226-
expect(replay.resume).toHaveBeenCalledTimes(1);
227-
expect(replay.isPaused()).toBe(false);
97+
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY * 12);
22898

229-
expect(mockSendReplayRequest).toHaveBeenCalledTimes(2);
230-
expect(replay).toHaveLastSentReplay({
231-
recordingData: JSON.stringify([
232-
{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY * 13, type: 2 },
233-
]),
234-
});
99+
expect(mockSendReplayRequest).toHaveBeenCalledTimes(1);
100+
expect(mockTransportSend).toHaveBeenCalledTimes(1);
235101

236-
// and let's also emit a new event and check that it is recorded
102+
// and let's also emit a new event and check that it is not recorded
237103
const TEST_EVENT3 = {
238104
data: {},
239105
timestamp: BASE_TIMESTAMP + 7 * DEFAULT_FLUSH_MIN_DELAY,
240106
type: 3,
241107
};
242108
mockRecord._emitter(TEST_EVENT3);
243109

244-
// T = base + 65
245-
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
246-
expect(mockSendReplayRequest).toHaveBeenCalledTimes(3);
247-
expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT3]) });
248-
249-
// nothing should happen afterwards
250-
// T = base + 85
110+
// T = base + 80
251111
await advanceTimers(20_000);
252-
expect(mockSendReplayRequest).toHaveBeenCalledTimes(3);
253-
expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT3]) });
254-
255-
// events array should be empty
256-
expect(replay.eventBuffer?.hasEvents).toBe(false);
257-
});
258-
259-
it("doesn't do anything, if a rate limit is hit and recording is already paused", async () => {
260-
let paused = false;
261-
expect(replay.session?.segmentId).toBe(0);
262-
jest.spyOn(replay, 'isPaused').mockImplementation(() => {
263-
return paused;
264-
});
265-
jest.spyOn(replay, 'pause');
266-
jest.spyOn(replay, 'resume');
267-
// @ts-ignore private API
268-
jest.spyOn(replay, '_handleRateLimit');
269-
270-
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 };
271-
272-
mockTransportSend.mockImplementationOnce(() => {
273-
return Promise.resolve({ statusCode: 429 });
274-
});
275-
276-
mockRecord._emitter(TEST_EVENT);
277-
paused = true;
278-
279-
// T = base + 5
280-
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
281-
282-
expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
112+
expect(mockSendReplayRequest).toHaveBeenCalledTimes(1);
283113
expect(mockTransportSend).toHaveBeenCalledTimes(1);
284-
285-
expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([TEST_EVENT]) });
286-
287-
expect(replay['_handleRateLimit']).toHaveBeenCalledTimes(1);
288-
expect(replay.resume).not.toHaveBeenCalled();
289-
expect(replay.isPaused).toHaveBeenCalledTimes(2);
290-
expect(replay.pause).not.toHaveBeenCalled();
291114
});
292115
});

0 commit comments

Comments
 (0)