Skip to content

Commit 7b26cea

Browse files
committed
cater for retry-after
1 parent b362612 commit 7b26cea

File tree

2 files changed

+90
-36
lines changed

2 files changed

+90
-36
lines changed

packages/core/src/transports/offline.ts

Lines changed: 46 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
11
import type { Envelope, InternalBaseTransportOptions, Transport, TransportMakeRequestResponse } from '@sentry/types';
2-
import { forEachEnvelopeItem, logger } from '@sentry/utils';
2+
import { forEachEnvelopeItem, logger, parseRetryAfterHeader } from '@sentry/utils';
33

4-
export const BETWEEN_DELAY = 100; // 100 ms
4+
export const MIN_DELAY = 100; // 100 ms
55
export const START_DELAY = 5_000; // 5 seconds
66
const MAX_DELAY = 3.6e6; // 1 hour
77
const DEFAULT_QUEUE_SIZE = 30;
88

9+
function isReplayEnvelope(envelope: Envelope): boolean {
10+
let isReplay = false;
11+
12+
forEachEnvelopeItem(envelope, (_, type) => {
13+
if (type === 'replay_event') {
14+
isReplay = true;
15+
}
16+
});
17+
18+
return isReplay;
19+
}
20+
921
type MaybeAsync<T> = T | Promise<T>;
1022

1123
interface OfflineTransportOptions extends InternalBaseTransportOptions {
@@ -44,18 +56,6 @@ export type CreateOfflineStore = (maxQueueCount: number) => OfflineStore;
4456

4557
type Timer = number | { unref?: () => void };
4658

47-
function isReplayEnvelope(envelope: Envelope): boolean {
48-
let isReplay = false;
49-
50-
forEachEnvelopeItem(envelope, (_, type) => {
51-
if (type === 'replay_event') {
52-
isReplay = true;
53-
}
54-
});
55-
56-
return isReplay;
57-
}
58-
5959
/**
6060
* Wraps a transport and stores and retries events when they fail to send.
6161
*
@@ -74,6 +74,10 @@ export function makeOfflineTransport<TO>(
7474
let retryDelay = START_DELAY;
7575
let flushTimer: Timer | undefined;
7676

77+
function log(msg: string, error?: Error): void {
78+
__DEBUG_BUILD__ && logger.info(`[Offline]: ${msg}`, error);
79+
}
80+
7781
function shouldQueue(env: Envelope, error: Error, retryDelay: number): MaybeAsync<boolean> {
7882
if (isReplayEnvelope(env)) {
7983
return false;
@@ -86,25 +90,19 @@ export function makeOfflineTransport<TO>(
8690
return true;
8791
}
8892

89-
function flushLater(overrideDelay?: number): void {
93+
function flushIn(delay: number): void {
9094
if (flushTimer) {
91-
if (overrideDelay) {
92-
clearTimeout(flushTimer as ReturnType<typeof setTimeout>);
93-
} else {
94-
return;
95-
}
95+
clearTimeout(flushTimer as ReturnType<typeof setTimeout>);
9696
}
9797

98-
const delay = overrideDelay || retryDelay;
99-
10098
flushTimer = setTimeout(async () => {
10199
flushTimer = undefined;
102100

103101
const found = await store.pop();
104102
if (found) {
105-
__DEBUG_BUILD__ && logger.info('[Offline]: Attempting to send previously queued event');
103+
log('Attempting to send previously queued event');
106104
void send(found).catch(e => {
107-
__DEBUG_BUILD__ && logger.info('[Offline]: Failed to send when retrying', e);
105+
log('Failed to retry sending', e);
108106
});
109107
}
110108
}, delay) as Timer;
@@ -113,6 +111,14 @@ export function makeOfflineTransport<TO>(
113111
if (typeof flushTimer !== 'number' && typeof flushTimer.unref === 'function') {
114112
flushTimer.unref();
115113
}
114+
}
115+
116+
function flushWithBackOff(): void {
117+
if (flushTimer) {
118+
return;
119+
}
120+
121+
flushIn(retryDelay);
116122

117123
retryDelay *= 2;
118124

@@ -124,18 +130,27 @@ export function makeOfflineTransport<TO>(
124130
async function send(envelope: Envelope): Promise<void | TransportMakeRequestResponse> {
125131
try {
126132
const result = await transport.send(envelope);
127-
// If the status code wasn't a server error, reset retryDelay and flush
128-
if (result && (result.statusCode || 500) < 400) {
129-
retryDelay = START_DELAY;
130-
flushLater(BETWEEN_DELAY);
133+
134+
let delay = MIN_DELAY;
135+
136+
if (result) {
137+
// If there's a retry-after header, use that as the next delay.
138+
if (result.headers && result.headers['retry-after']) {
139+
delay = parseRetryAfterHeader(result.headers['retry-after']);
140+
} // If we have a server error, return now so we don't flush the queue.
141+
else if ((result.statusCode || 0) >= 400) {
142+
return result;
143+
}
131144
}
132145

146+
flushIn(delay);
147+
retryDelay = START_DELAY;
133148
return result;
134149
} catch (e) {
135150
if (await shouldQueue(envelope, e, retryDelay)) {
136151
await store.insert(envelope);
137-
flushLater();
138-
__DEBUG_BUILD__ && logger.info('[Offline]: Event queued', e);
152+
flushWithBackOff();
153+
log('Error sending. Event queued', e);
139154
return {};
140155
} else {
141156
throw e;
@@ -144,7 +159,7 @@ export function makeOfflineTransport<TO>(
144159
}
145160

146161
if (options.flushAtStartup) {
147-
flushLater();
162+
flushWithBackOff();
148163
}
149164

150165
return {

packages/core/test/lib/transports/offline.test.ts

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { TextEncoder } from 'util';
1818

1919
import { createTransport } from '../../../src';
2020
import type { CreateOfflineStore } from '../../../src/transports/offline';
21-
import { BETWEEN_DELAY, makeOfflineTransport, START_DELAY } from '../../../src/transports/offline';
21+
import { makeOfflineTransport, MIN_DELAY, START_DELAY } from '../../../src/transports/offline';
2222

2323
const ERROR_ENVELOPE = createEnvelope<EventEnvelope>({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [
2424
[{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem,
@@ -140,7 +140,7 @@ describe('makeOfflineTransport', () => {
140140
expect(queuedCount).toEqual(0);
141141
expect(getSendCount()).toEqual(1);
142142

143-
await delay(BETWEEN_DELAY * 2);
143+
await delay(MIN_DELAY * 2);
144144

145145
// After a successful send, the store should be checked
146146
expect(getCalls()).toEqual(['pop']);
@@ -154,7 +154,7 @@ describe('makeOfflineTransport', () => {
154154

155155
expect(result).toEqual({ statusCode: 200 });
156156

157-
await delay(BETWEEN_DELAY * 3);
157+
await delay(MIN_DELAY * 3);
158158

159159
expect(getSendCount()).toEqual(2);
160160
// After a successful send from the store, the store should be checked again to ensure it's empty
@@ -179,7 +179,7 @@ describe('makeOfflineTransport', () => {
179179

180180
expect(result).toEqual({});
181181

182-
await delay(BETWEEN_DELAY * 2);
182+
await delay(MIN_DELAY * 2);
183183

184184
expect(getSendCount()).toEqual(0);
185185
expect(queuedCount).toEqual(1);
@@ -204,7 +204,7 @@ describe('makeOfflineTransport', () => {
204204

205205
expect(result).toEqual({ statusCode: 500 });
206206

207-
await delay(BETWEEN_DELAY * 2);
207+
await delay(MIN_DELAY * 2);
208208

209209
expect(getSendCount()).toEqual(1);
210210
expect(queuedCount).toEqual(0);
@@ -282,4 +282,43 @@ describe('makeOfflineTransport', () => {
282282
expect(getSendCount()).toEqual(0);
283283
expect(getCalls()).toEqual([]);
284284
});
285+
286+
it('Follows the Retry-After header', async () => {
287+
const { getCalls, store } = createTestStore(ERROR_ENVELOPE);
288+
const { getSendCount, baseTransport } = createTestTransport(
289+
{
290+
statusCode: 429,
291+
headers: { 'x-sentry-rate-limits': '', 'retry-after': '3' },
292+
},
293+
{ statusCode: 200 },
294+
);
295+
296+
let queuedCount = 0;
297+
const transport = makeOfflineTransport(
298+
baseTransport,
299+
store,
300+
)({
301+
...transportOptions,
302+
shouldStore: () => {
303+
queuedCount += 1;
304+
return true;
305+
},
306+
});
307+
const result = await transport.send(ERROR_ENVELOPE);
308+
309+
expect(result).toEqual({
310+
statusCode: 429,
311+
headers: { 'x-sentry-rate-limits': '', 'retry-after': '3' },
312+
});
313+
314+
await delay(MIN_DELAY * 2);
315+
316+
expect(getSendCount()).toEqual(1);
317+
318+
await delay(4_000);
319+
320+
expect(getSendCount()).toEqual(2);
321+
expect(queuedCount).toEqual(0);
322+
expect(getCalls()).toEqual(['pop', 'pop']);
323+
}, 7_000);
285324
});

0 commit comments

Comments
 (0)