forked from getsentry/sentry-java
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathRateLimiter.java
More file actions
387 lines (342 loc) · 13.5 KB
/
RateLimiter.java
File metadata and controls
387 lines (342 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
package io.sentry.transport;
import static io.sentry.SentryLevel.ERROR;
import static io.sentry.SentryLevel.INFO;
import io.sentry.DataCategory;
import io.sentry.Hint;
import io.sentry.ISentryLifecycleToken;
import io.sentry.SentryEnvelope;
import io.sentry.SentryEnvelopeItem;
import io.sentry.SentryLevel;
import io.sentry.SentryOptions;
import io.sentry.clientreport.DiscardReason;
import io.sentry.hints.DiskFlushNotification;
import io.sentry.hints.Retryable;
import io.sentry.hints.SubmissionResult;
import io.sentry.util.AutoClosableReentrantLock;
import io.sentry.util.HintUtils;
import io.sentry.util.StringUtils;
import java.io.Closeable;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/** Controls retry limits on different category types sent to Sentry. */
public final class RateLimiter implements Closeable {
private static final int HTTP_RETRY_AFTER_DEFAULT_DELAY_MILLIS = 60000;
private final @NotNull ICurrentDateProvider currentDateProvider;
private final @NotNull SentryOptions options;
private final @NotNull Map<DataCategory, @NotNull Date> sentryRetryAfterLimit =
new ConcurrentHashMap<>();
private final @NotNull List<IRateLimitObserver> rateLimitObservers = new CopyOnWriteArrayList<>();
private @Nullable Timer timer = null;
private final @NotNull AutoClosableReentrantLock timerLock = new AutoClosableReentrantLock();
public RateLimiter(
final @NotNull ICurrentDateProvider currentDateProvider,
final @NotNull SentryOptions options) {
this.currentDateProvider = currentDateProvider;
this.options = options;
}
public RateLimiter(final @NotNull SentryOptions options) {
this(CurrentDateProvider.getInstance(), options);
}
public @Nullable SentryEnvelope filter(
final @NotNull SentryEnvelope envelope, final @NotNull Hint hint) {
// Optimize for/No allocations if no items are under 429
List<SentryEnvelopeItem> dropItems = null;
for (SentryEnvelopeItem item : envelope.getItems()) {
// using the raw value of the enum to not expose SentryEnvelopeItemType
if (isRetryAfter(item.getHeader().getType().getItemType())) {
if (dropItems == null) {
dropItems = new ArrayList<>();
}
dropItems.add(item);
options
.getClientReportRecorder()
.recordLostEnvelopeItem(DiscardReason.RATELIMIT_BACKOFF, item);
}
}
if (dropItems != null) {
options
.getLogger()
.log(
SentryLevel.WARNING,
"%d envelope items will be dropped due rate limiting.",
dropItems.size());
// Need a new envelope
List<SentryEnvelopeItem> toSend = new ArrayList<>();
for (SentryEnvelopeItem item : envelope.getItems()) {
if (!dropItems.contains(item)) {
toSend.add(item);
}
}
// no reason to continue
if (toSend.isEmpty()) {
options
.getLogger()
.log(SentryLevel.WARNING, "Envelope discarded due all items rate limited.");
markHintWhenSendingFailed(hint, false);
return null;
}
return new SentryEnvelope(envelope.getHeader(), toSend);
}
return envelope;
}
@SuppressWarnings({"JdkObsolete", "JavaUtilDate"})
public boolean isActiveForCategory(final @NotNull DataCategory dataCategory) {
final Date currentDate = new Date(currentDateProvider.getCurrentTimeMillis());
// check all categories
final Date dateAllCategories = sentryRetryAfterLimit.get(DataCategory.All);
if (dateAllCategories != null) {
if (!currentDate.after(dateAllCategories)) {
return true;
}
}
// Unknown should not be rate limited
if (DataCategory.Unknown.equals(dataCategory)) {
return false;
}
// check for specific dataCategory
final Date dateCategory = sentryRetryAfterLimit.get(dataCategory);
if (dateCategory != null) {
return !currentDate.after(dateCategory);
}
return false;
}
@SuppressWarnings({"JdkObsolete", "JavaUtilDate"})
public boolean isAnyRateLimitActive() {
final Date currentDate = new Date(currentDateProvider.getCurrentTimeMillis());
for (DataCategory dataCategory : sentryRetryAfterLimit.keySet()) {
final Date dateCategory = sentryRetryAfterLimit.get(dataCategory);
if (dateCategory != null) {
if (!currentDate.after(dateCategory)) {
return true;
}
}
}
return false;
}
/**
* It marks the hint when sending has failed, so it's not necessary to wait the timeout
*
* @param hint the Hints
* @param retry if event should be retried or not
*/
private void markHintWhenSendingFailed(final @NotNull Hint hint, final boolean retry) {
HintUtils.runIfHasType(hint, SubmissionResult.class, result -> result.setResult(false));
HintUtils.runIfHasType(hint, Retryable.class, retryable -> retryable.setRetry(retry));
HintUtils.runIfHasType(
hint,
DiskFlushNotification.class,
(diskFlushNotification) -> {
diskFlushNotification.markFlushed();
options.getLogger().log(SentryLevel.DEBUG, "Disk flush envelope fired due to rate limit");
});
}
/**
* Check if an itemType is retry after or not
*
* @param itemType the itemType (eg event, session, etc...)
* @return true if retry after or false otherwise
*/
@SuppressWarnings({"JdkObsolete", "JavaUtilDate"})
private boolean isRetryAfter(final @NotNull String itemType) {
final List<DataCategory> dataCategory = getCategoryFromItemType(itemType);
for (DataCategory category : dataCategory) {
if (isActiveForCategory(category)) {
return true;
}
}
return false;
}
/**
* Returns a rate limiting category from item itemType
*
* @param itemType the item itemType (eg event, session, attachment, ...)
* @return the DataCategory eg (DataCategory.Error, DataCategory.Session, DataCategory.Attachment)
*/
private @NotNull List<DataCategory> getCategoryFromItemType(final @NotNull String itemType) {
switch (itemType) {
case "event":
return Collections.singletonList(DataCategory.Error);
case "session":
return Collections.singletonList(DataCategory.Session);
case "attachment":
return Collections.singletonList(DataCategory.Attachment);
case "profile":
return Collections.singletonList(DataCategory.Profile);
// When we send a profile chunk, we have to check for profile_chunk_ui rate limiting,
// because that's what relay returns to rate limit Android.
// And ProfileChunk rate limiting for JVM.
case "profile_chunk":
return Arrays.asList(DataCategory.ProfileChunkUi, DataCategory.ProfileChunk);
case "transaction":
return Collections.singletonList(DataCategory.Transaction);
case "check_in":
return Collections.singletonList(DataCategory.Monitor);
case "replay_video":
return Collections.singletonList(DataCategory.Replay);
case "feedback":
return Collections.singletonList(DataCategory.Feedback);
case "log":
return Collections.singletonList(DataCategory.LogItem);
case "span":
return Collections.singletonList(DataCategory.Span);
case "trace_metric":
return Collections.singletonList(DataCategory.TraceMetric);
default:
return Collections.singletonList(DataCategory.Unknown);
}
}
/**
* Reads and update the rate limit Dictionary
*
* @param sentryRateLimitHeader the sentry rate limit header
* @param retryAfterHeader the retry after header
* @param errorCode the error code if set
*/
@SuppressWarnings({"JdkObsolete", "JavaUtilDate"})
public void updateRetryAfterLimits(
final @Nullable String sentryRateLimitHeader,
final @Nullable String retryAfterHeader,
final int errorCode) {
// example: 2700:metric_bucket:organization:quota_exceeded:custom,...
if (sentryRateLimitHeader != null) {
for (String limit : sentryRateLimitHeader.split(",", -1)) {
// Java 11 or so has strip() :(
limit = limit.replace(" ", "");
final String[] rateLimit = limit.split(":", -1);
// These can be ignored by the SDK.
// final String scope = rateLimit.length > 2 ? rateLimit[2] : null;
// final String reasonCode = rateLimit.length > 3 ? rateLimit[3] : null;
// final @Nullable String limitNamespaces = rateLimit.length > 4 ? rateLimit[4] : null;
if (rateLimit.length > 0) {
final String retryAfter = rateLimit[0];
long retryAfterMillis = parseRetryAfterOrDefault(retryAfter);
if (rateLimit.length > 1) {
final String allCategories = rateLimit[1];
// we dont care if Date is UTC as we just add the relative seconds
final Date date =
new Date(currentDateProvider.getCurrentTimeMillis() + retryAfterMillis);
if (allCategories != null && !allCategories.isEmpty()) {
final String[] categories = allCategories.split(";", -1);
for (final String catItem : categories) {
DataCategory dataCategory = DataCategory.Unknown;
try {
final String catItemCapitalized = StringUtils.camelCase(catItem);
if (catItemCapitalized != null) {
dataCategory = DataCategory.valueOf(catItemCapitalized);
} else {
options.getLogger().log(ERROR, "Couldn't capitalize: %s", catItem);
}
} catch (IllegalArgumentException e) {
options.getLogger().log(INFO, e, "Unknown category: %s", catItem);
}
// we dont apply rate limiting for unknown categories
if (DataCategory.Unknown.equals(dataCategory)) {
continue;
}
applyRetryAfterOnlyIfLonger(dataCategory, date);
}
} else {
// if categories are empty, we should apply to "all" categories.
applyRetryAfterOnlyIfLonger(DataCategory.All, date);
}
}
}
}
} else if (errorCode == 429) {
final long retryAfterMillis = parseRetryAfterOrDefault(retryAfterHeader);
// we dont care if Date is UTC as we just add the relative seconds
final Date date = new Date(currentDateProvider.getCurrentTimeMillis() + retryAfterMillis);
applyRetryAfterOnlyIfLonger(DataCategory.All, date);
}
}
/**
* apply new timestamp for rate limiting only if its longer than the previous one
*
* @param dataCategory the DataCategory
* @param date the Date to be applied
*/
@SuppressWarnings({"JdkObsolete", "JavaUtilDate"})
private void applyRetryAfterOnlyIfLonger(
final @NotNull DataCategory dataCategory, final @NotNull Date date) {
final Date oldDate = sentryRetryAfterLimit.get(dataCategory);
// only overwrite its previous date if the limit is even longer
if (oldDate == null || date.after(oldDate)) {
sentryRetryAfterLimit.put(dataCategory, date);
notifyRateLimitObservers();
try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) {
if (timer == null) {
timer = new Timer(true);
}
timer.schedule(
new TimerTask() {
@Override
public void run() {
notifyRateLimitObservers();
}
},
date);
}
}
}
/**
* Parses a millis string to a seconds number
*
* @param retryAfterHeader the header
* @return the millis in seconds or the default seconds value
*/
private long parseRetryAfterOrDefault(final @Nullable String retryAfterHeader) {
long retryAfterMillis = HTTP_RETRY_AFTER_DEFAULT_DELAY_MILLIS;
if (retryAfterHeader != null) {
try {
retryAfterMillis =
(long) (Double.parseDouble(retryAfterHeader) * 1000L); // seconds -> milliseconds
} catch (NumberFormatException ignored) {
// let's use the default then
}
}
return retryAfterMillis;
}
private void notifyRateLimitObservers() {
for (IRateLimitObserver observer : rateLimitObservers) {
observer.onRateLimitChanged(this);
}
}
public void addRateLimitObserver(@NotNull final IRateLimitObserver observer) {
rateLimitObservers.add(observer);
}
public void removeRateLimitObserver(@NotNull final IRateLimitObserver observer) {
rateLimitObservers.remove(observer);
}
@Override
public void close() throws IOException {
try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) {
if (timer != null) {
timer.cancel();
timer = null;
}
}
rateLimitObservers.clear();
}
public interface IRateLimitObserver {
/**
* Invoked whenever the rate limit changed. You should use {@link
* RateLimiter#isActiveForCategory(DataCategory)} to check whether the category you're
* interested in has changed.
*
* @param rateLimiter this {@link RateLimiter} instance which you can use to check if the rate
* limit is active for a specific category
*/
void onRateLimitChanged(@NotNull RateLimiter rateLimiter);
}
}