Skip to content

Commit 6ddc5cd

Browse files
authored
feat(replay): Stop recording when hitting a rate limit (#7018)
We want to entirely stop recording the replay once we receive a 429 rate limit response. This patch gets rid of all the "pause on rate limit" logic. Because we already stop when we receive other http error responses, we can simply use this logic instead.
1 parent a90ba73 commit 6ddc5cd

File tree

4 files changed

+16
-243
lines changed

4 files changed

+16
-243
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
*/
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getCurrentHub } from '@sentry/core';
2-
import type { Transport, TransportMakeRequestResponse } from '@sentry/types';
2+
import type { Transport } from '@sentry/types';
33

44
import { DEFAULT_FLUSH_MIN_DELAY, SESSION_IDLE_DURATION } from '../../src/constants';
55
import type { ReplayContainer } from '../../src/replay';
@@ -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)