From b3c8dab8225e8b475caae1f62be8f2e07778e378 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 31 Aug 2026 18:04:19 +0200 Subject: [PATCH 1/2] ref(transport): Measure rate-limit backoff on a monotonic clock (JAVA-574) Retry-after limits were java.util.Date values derived from System.currentTimeMillis(). A wall clock is the wrong instrument for a backoff window: it steps when the device syncs time, so an NTP correction could lift a 60 second rate limit early or extend it by however far the clock jumped. The limits now live on the elapsed-real-time clock, which counts forward at a steady rate and keeps counting while the device sleeps, which is what a server-dictated wait means. Storing Deadline rather than a timestamp also removes the duplicated parameter on applyRetryAfterOnlyIfLonger, which took both an absolute deadline and the delay needed to reach it, and lets three JdkObsolete and JavaUtilDate suppressions go with the Dates. RateLimiter also took the whole SentryOptions while reading exactly three methods from it. It now depends on RateLimiterConfig, declared next to its consumer, so what a rate limiter touches is three lines to read rather than three hundred. SentryOptions implements it with no new methods, so every existing caller compiles unchanged. Both existing constructors stay, so the .api diff is additions only. The ICurrentDateProvider one is deprecated and adapts the injected provider rather than ignoring it, since a custom ITransportFactory may be passing one. One boundary moves by a nanosecond: a limit used to be active while `now <= deadline` and is now active while `now < deadline`. Co-Authored-By: Claude Opus 5 (1M context) --- sentry/api/sentry.api | 9 +- .../main/java/io/sentry/SentryOptions.java | 6 +- .../java/io/sentry/transport/RateLimiter.java | 122 +++++++++--------- .../sentry/transport/RateLimiterConfig.java | 29 +++++ .../io/sentry/transport/RateLimiterTest.kt | 54 ++++++-- 5 files changed, 141 insertions(+), 79 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/transport/RateLimiterConfig.java diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 287b0a3570..6e850666ca 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -3648,7 +3648,7 @@ public final class io/sentry/SentryOpenTelemetryMode : java/lang/Enum { public static fun values ()[Lio/sentry/SentryOpenTelemetryMode; } -public class io/sentry/SentryOptions { +public class io/sentry/SentryOptions : io/sentry/transport/RateLimiterConfig { public static final field DEFAULT_PROPAGATION_TARGETS Ljava/lang/String; public static final field MAX_EVENT_SIZE_BYTES J protected final field lock Lio/sentry/util/AutoClosableReentrantLock; @@ -7675,6 +7675,7 @@ public final class io/sentry/transport/NoOpTransportGate : io/sentry/transport/I public final class io/sentry/transport/RateLimiter : java/io/Closeable { public fun (Lio/sentry/SentryOptions;)V + public fun (Lio/sentry/time/ElapsedRealtimeClock;Lio/sentry/transport/RateLimiterConfig;)V public fun (Lio/sentry/transport/ICurrentDateProvider;Lio/sentry/SentryOptions;)V public fun addRateLimitObserver (Lio/sentry/transport/RateLimiter$IRateLimitObserver;)V public fun close ()V @@ -7689,6 +7690,12 @@ public abstract interface class io/sentry/transport/RateLimiter$IRateLimitObserv public abstract fun onRateLimitChanged (Lio/sentry/transport/RateLimiter;)V } +public abstract interface class io/sentry/transport/RateLimiterConfig { + public abstract fun getClientReportRecorder ()Lio/sentry/clientreport/IClientReportRecorder; + public abstract fun getLogger ()Lio/sentry/ILogger; + public abstract fun getTimerExecutorService ()Lio/sentry/ISentryExecutorService; +} + public final class io/sentry/transport/ReusableCountLatch { public fun ()V public fun (I)V diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 54f19ba93d..81231157d7 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -27,6 +27,7 @@ import io.sentry.transport.ITransportGate; import io.sentry.transport.NoOpEnvelopeCache; import io.sentry.transport.NoOpTransportGate; +import io.sentry.transport.RateLimiterConfig; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.LazyEvaluator; import io.sentry.util.LoadClass; @@ -56,7 +57,7 @@ /** Sentry SDK options */ @Open -public class SentryOptions { +public class SentryOptions implements RateLimiterConfig { @ApiStatus.Internal public static final @NotNull String DEFAULT_PROPAGATION_TARGETS = ".*"; @@ -845,6 +846,7 @@ public void setDebug(final boolean debug) { * * @return the logger */ + @Override public @NotNull ILogger getLogger() { return logger; } @@ -1608,6 +1610,7 @@ public void setExecutorService(final @NotNull ISentryExecutorService executorSer * @return the timer executor service */ @ApiStatus.Internal + @Override @NotNull public ISentryExecutorService getTimerExecutorService() { return timerExecutorService; @@ -2599,6 +2602,7 @@ public void setInstrumenter(final @NotNull Instrumenter instrumenter) { * @return a client report recorder or NoOp */ @ApiStatus.Internal + @Override public @NotNull IClientReportRecorder getClientReportRecorder() { return clientReportRecorder; } diff --git a/sentry/src/main/java/io/sentry/transport/RateLimiter.java b/sentry/src/main/java/io/sentry/transport/RateLimiter.java index dfbc4cb262..b2cdd009eb 100644 --- a/sentry/src/main/java/io/sentry/transport/RateLimiter.java +++ b/sentry/src/main/java/io/sentry/transport/RateLimiter.java @@ -14,6 +14,8 @@ import io.sentry.hints.DiskFlushNotification; import io.sentry.hints.Retryable; import io.sentry.hints.SubmissionResult; +import io.sentry.time.Deadline; +import io.sentry.time.ElapsedRealtimeClock; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.HintUtils; import io.sentry.util.StringUtils; @@ -22,7 +24,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.Date; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -30,17 +31,18 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Future; import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; 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 static final long HTTP_RETRY_AFTER_DEFAULT_DELAY_MILLIS = 60_000; - private final @NotNull ICurrentDateProvider currentDateProvider; - private final @NotNull SentryOptions options; - private final @NotNull Map sentryRetryAfterLimit = + private final @NotNull ElapsedRealtimeClock clock; + private final @NotNull RateLimiterConfig config; + private final @NotNull Map sentryRetryAfterLimit = new ConcurrentHashMap<>(); private final @NotNull List rateLimitObservers = new CopyOnWriteArrayList<>(); private final @NotNull List> notifyObserversFutures = new ArrayList<>(); @@ -48,14 +50,30 @@ public final class RateLimiter implements Closeable { new AutoClosableReentrantLock(); public RateLimiter( - final @NotNull ICurrentDateProvider currentDateProvider, - final @NotNull SentryOptions options) { - this.currentDateProvider = currentDateProvider; - this.options = options; + final @NotNull ElapsedRealtimeClock clock, final @NotNull RateLimiterConfig config) { + this.clock = clock; + this.config = config; } public RateLimiter(final @NotNull SentryOptions options) { - this(CurrentDateProvider.getInstance(), options); + this(options.getElapsedRealtimeClock(), options); + } + + /** + * @deprecated backoff is measured on {@link SentryOptions#getElapsedRealtimeClock()}; use {@link + * #RateLimiter(SentryOptions)}. An injected wall clock is adapted so that an existing custom + * transport keeps the behaviour it has today, but it is not monotonic and can step. + */ + @Deprecated + public RateLimiter( + final @NotNull ICurrentDateProvider currentDateProvider, + final @NotNull SentryOptions options) { + // ICurrentDateProvider is itself a `long ()` interface, so an unadorned lambda matches this + // constructor as readily as the intended one. The cast is what makes the call non-recursive. + this( + (ElapsedRealtimeClock) + () -> TimeUnit.MILLISECONDS.toNanos(currentDateProvider.getCurrentTimeMillis()), + options); } public @Nullable SentryEnvelope filter( @@ -70,14 +88,14 @@ public RateLimiter(final @NotNull SentryOptions options) { } dropItems.add(item); - options + config .getClientReportRecorder() .recordLostEnvelopeItem(DiscardReason.RATELIMIT_BACKOFF, item); } } if (dropItems != null) { - options + config .getLogger() .log( SentryLevel.WARNING, @@ -94,7 +112,7 @@ public RateLimiter(final @NotNull SentryOptions options) { // no reason to continue if (toSend.isEmpty()) { - options + config .getLogger() .log(SentryLevel.WARNING, "Envelope discarded due all items rate limited."); @@ -107,16 +125,11 @@ public RateLimiter(final @NotNull SentryOptions options) { 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; - } + final @Nullable Deadline allCategories = sentryRetryAfterLimit.get(DataCategory.All); + if (allCategories != null && !allCategories.hasPassed()) { + return true; } // Unknown should not be rate limited @@ -125,24 +138,15 @@ public boolean isActiveForCategory(final @NotNull DataCategory dataCategory) { } // check for specific dataCategory - final Date dateCategory = sentryRetryAfterLimit.get(dataCategory); - if (dateCategory != null) { - return !currentDate.after(dateCategory); - } - - return false; + final @Nullable Deadline categoryLimit = sentryRetryAfterLimit.get(dataCategory); + return categoryLimit != null && !categoryLimit.hasPassed(); } @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; - } + for (final @NotNull Deadline limit : sentryRetryAfterLimit.values()) { + if (!limit.hasPassed()) { + return true; } } @@ -163,7 +167,7 @@ private void markHintWhenSendingFailed(final @NotNull Hint hint, final boolean r DiskFlushNotification.class, (diskFlushNotification) -> { diskFlushNotification.markFlushed(); - options.getLogger().log(SentryLevel.DEBUG, "Disk flush envelope fired due to rate limit"); + config.getLogger().log(SentryLevel.DEBUG, "Disk flush envelope fired due to rate limit"); }); } @@ -251,15 +255,11 @@ public void updateRetryAfterLimits( if (rateLimit.length > 0) { final String retryAfter = rateLimit[0]; - long retryAfterMillis = parseRetryAfterOrDefault(retryAfter); + final @NotNull Deadline deadline = 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); @@ -270,48 +270,43 @@ public void updateRetryAfterLimits( if (catItemCapitalized != null) { dataCategory = DataCategory.valueOf(catItemCapitalized); } else { - options.getLogger().log(ERROR, "Couldn't capitalize: %s", catItem); + config.getLogger().log(ERROR, "Couldn't capitalize: %s", catItem); } } catch (IllegalArgumentException e) { - options.getLogger().log(INFO, e, "Unknown category: %s", catItem); + config.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, retryAfterMillis); + applyRetryAfterOnlyIfLonger(dataCategory, deadline); } } else { // if categories are empty, we should apply to "all" categories. - applyRetryAfterOnlyIfLonger(DataCategory.All, date, retryAfterMillis); + applyRetryAfterOnlyIfLonger(DataCategory.All, deadline); } } } } } 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, retryAfterMillis); + applyRetryAfterOnlyIfLonger(DataCategory.All, parseRetryAfterOrDefault(retryAfterHeader)); } } /** - * apply new timestamp for rate limiting only if its longer than the previous one + * apply the new deadline for rate limiting only if it is longer than the previous one * * @param dataCategory the DataCategory - * @param date the Date to be applied - * @param delayMillis the millis until the rate limit is lifted + * @param deadline when the rate limit is lifted */ - @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) private void applyRetryAfterOnlyIfLonger( - final @NotNull DataCategory dataCategory, final @NotNull Date date, final long delayMillis) { - final Date oldDate = sentryRetryAfterLimit.get(dataCategory); + final @NotNull DataCategory dataCategory, final @NotNull Deadline deadline) { + final @Nullable Deadline oldLimit = sentryRetryAfterLimit.get(dataCategory); - // only overwrite its previous date if the limit is even longer - if (oldDate == null || date.after(oldDate)) { - sentryRetryAfterLimit.put(dataCategory, date); + // only overwrite the previous deadline if the limit is even longer + if (oldLimit == null || deadline.isAfter(oldLimit)) { + sentryRetryAfterLimit.put(dataCategory, deadline); notifyRateLimitObservers(); @@ -326,11 +321,12 @@ private void applyRetryAfterOnlyIfLonger( } try { notifyObserversFutures.add( - options + config .getTimerExecutorService() - .schedule(this::notifyRateLimitObservers, delayMillis)); + .schedule( + this::notifyRateLimitObservers, deadline.remaining(TimeUnit.MILLISECONDS))); } catch (RejectedExecutionException e) { - options + config .getLogger() .log(SentryLevel.WARNING, "Failed to schedule rate limit lifted notification.", e); } @@ -344,7 +340,7 @@ private void applyRetryAfterOnlyIfLonger( * @param retryAfterHeader the header * @return the millis in seconds or the default seconds value */ - private long parseRetryAfterOrDefault(final @Nullable String retryAfterHeader) { + private @NotNull Deadline parseRetryAfterOrDefault(final @Nullable String retryAfterHeader) { long retryAfterMillis = HTTP_RETRY_AFTER_DEFAULT_DELAY_MILLIS; if (retryAfterHeader != null) { try { @@ -354,7 +350,7 @@ private long parseRetryAfterOrDefault(final @Nullable String retryAfterHeader) { // let's use the default then } } - return retryAfterMillis; + return Deadline.in(clock, retryAfterMillis, TimeUnit.MILLISECONDS); } private void notifyRateLimitObservers() { diff --git a/sentry/src/main/java/io/sentry/transport/RateLimiterConfig.java b/sentry/src/main/java/io/sentry/transport/RateLimiterConfig.java new file mode 100644 index 0000000000..789228bcbe --- /dev/null +++ b/sentry/src/main/java/io/sentry/transport/RateLimiterConfig.java @@ -0,0 +1,29 @@ +package io.sentry.transport; + +import io.sentry.ILogger; +import io.sentry.ISentryExecutorService; +import io.sentry.clientreport.IClientReportRecorder; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * The configuration {@link RateLimiter} reads. Declared next to its consumer rather than alongside + * the implementation, so that the collaborators a rate limiter actually touches are three lines to + * read instead of three hundred, and a test can supply them without building a {@link + * io.sentry.SentryOptions}. + * + *

Implementations are expected to delegate to live configuration rather than snapshot it, so + * that a logger or executor replaced after {@code Sentry.init} is still picked up. + */ +@ApiStatus.Internal +public interface RateLimiterConfig { + + @NotNull + ILogger getLogger(); + + @NotNull + IClientReportRecorder getClientReportRecorder(); + + @NotNull + ISentryExecutorService getTimerExecutorService(); +} diff --git a/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt b/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt index 36927df97d..11e6661c82 100644 --- a/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt +++ b/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt @@ -3,6 +3,7 @@ package io.sentry.transport import io.sentry.Attachment import io.sentry.CheckIn import io.sentry.CheckInStatus +import io.sentry.DataCategory import io.sentry.DataCategory.Replay import io.sentry.EnvelopeReader import io.sentry.Hint @@ -38,10 +39,13 @@ import io.sentry.protocol.SentryId import io.sentry.protocol.SentryTransaction import io.sentry.protocol.User import io.sentry.test.getProperty +import io.sentry.time.TestTicker import io.sentry.util.HintUtils import java.io.File import java.util.UUID import java.util.concurrent.Future +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.TimeUnit.SECONDS import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.AfterTest import kotlin.test.Test @@ -61,7 +65,7 @@ import org.mockito.kotlin.whenever class RateLimiterTest { private class Fixture { - val currentDateProvider = mock() + val clock = TestTicker() val clientReportRecorder = mock() val serializer = mock() var executorService: SentryExecutorService? = null @@ -75,7 +79,7 @@ class RateLimiterTest { SentryOptionsManipulator.setClientReportRecorder(options, clientReportRecorder) - return RateLimiter(currentDateProvider, options) + return RateLimiter(clock, options) } } @@ -90,7 +94,6 @@ class RateLimiterTest { @Test fun `uses X-Sentry-Rate-Limit and allows sending if time has passed`() { val rateLimiter = fixture.getSUT() - whenever(fixture.currentDateProvider.currentTimeMillis).thenReturn(0, 0, 1001) val eventItem = SentryEnvelopeItem.fromEvent(fixture.serializer, SentryEvent()) val envelope = SentryEnvelope(SentryEnvelopeHeader(), arrayListOf(eventItem)) @@ -100,6 +103,9 @@ class RateLimiterTest { 1, ) + // the shortest limit in the header has now lapsed + fixture.clock.advance(1001, MILLISECONDS) + val result = rateLimiter.filter(envelope, Hint()) assertNotNull(result) assertEquals(1, result.items.count()) @@ -108,7 +114,6 @@ class RateLimiterTest { @Test fun `parse X-Sentry-Rate-Limit and set its values and retry after should be true`() { val rateLimiter = fixture.getSUT() - whenever(fixture.currentDateProvider.currentTimeMillis).thenReturn(0) val scopes: IScopes = mock() whenever(scopes.options).thenReturn(SentryOptions()) val eventItem = SentryEnvelopeItem.fromEvent(fixture.serializer, SentryEvent()) @@ -129,7 +134,6 @@ class RateLimiterTest { @Test fun `parse X-Sentry-Rate-Limit and set its values and retry after should be false`() { val rateLimiter = fixture.getSUT() - whenever(fixture.currentDateProvider.currentTimeMillis).thenReturn(0, 0, 1001) val scopes: IScopes = mock() whenever(scopes.options).thenReturn(SentryOptions()) val eventItem = SentryEnvelopeItem.fromEvent(fixture.serializer, SentryEvent()) @@ -143,6 +147,9 @@ class RateLimiterTest { 1, ) + // the shortest limit in the header has now lapsed + fixture.clock.advance(1001, MILLISECONDS) + val result = rateLimiter.filter(envelope, Hint()) assertNotNull(result) assertEquals(2, result.items.count()) @@ -151,7 +158,6 @@ class RateLimiterTest { @Test fun `When X-Sentry-Rate-Limit categories are empty, applies to all the categories`() { val rateLimiter = fixture.getSUT() - whenever(fixture.currentDateProvider.currentTimeMillis).thenReturn(0) val eventItem = SentryEnvelopeItem.fromEvent(fixture.serializer, SentryEvent()) val envelope = SentryEnvelope(SentryEnvelopeHeader(), arrayListOf(eventItem)) @@ -164,12 +170,14 @@ class RateLimiterTest { @Test fun `When all categories is set but expired, applies only for specific category`() { val rateLimiter = fixture.getSUT() - whenever(fixture.currentDateProvider.currentTimeMillis).thenReturn(0, 0, 1001) val eventItem = SentryEnvelopeItem.fromEvent(fixture.serializer, SentryEvent()) val envelope = SentryEnvelope(SentryEnvelopeHeader(), arrayListOf(eventItem)) rateLimiter.updateRetryAfterLimits("1::key, 60:default;error;security:organization", null, 1) + // the shortest limit in the header has now lapsed + fixture.clock.advance(1001, MILLISECONDS) + val result = rateLimiter.filter(envelope, Hint()) assertNull(result) } @@ -177,12 +185,14 @@ class RateLimiterTest { @Test fun `When category has shorter rate limiting, do not apply new timestamp`() { val rateLimiter = fixture.getSUT() - whenever(fixture.currentDateProvider.currentTimeMillis).thenReturn(0, 0, 1001) val eventItem = SentryEnvelopeItem.fromEvent(fixture.serializer, SentryEvent()) val envelope = SentryEnvelope(SentryEnvelopeHeader(), arrayListOf(eventItem)) rateLimiter.updateRetryAfterLimits("60:error:key, 1:error:organization", null, 1) + // the shortest limit in the header has now lapsed + fixture.clock.advance(1001, MILLISECONDS) + val result = rateLimiter.filter(envelope, Hint()) assertNull(result) } @@ -190,12 +200,14 @@ class RateLimiterTest { @Test fun `When category has longer rate limiting, apply new timestamp`() { val rateLimiter = fixture.getSUT() - whenever(fixture.currentDateProvider.currentTimeMillis).thenReturn(0, 0, 1001) val eventItem = SentryEnvelopeItem.fromEvent(fixture.serializer, SentryEvent()) val envelope = SentryEnvelope(SentryEnvelopeHeader(), arrayListOf(eventItem)) rateLimiter.updateRetryAfterLimits("1:error:key, 5:error:organization", null, 1) + // the shortest limit in the header has now lapsed + fixture.clock.advance(1001, MILLISECONDS) + val result = rateLimiter.filter(envelope, Hint()) assertNull(result) } @@ -203,12 +215,14 @@ class RateLimiterTest { @Test fun `When both retry headers are not present, default delay is set`() { val rateLimiter = fixture.getSUT() - whenever(fixture.currentDateProvider.currentTimeMillis).thenReturn(0, 0, 1001) val eventItem = SentryEnvelopeItem.fromEvent(fixture.serializer, SentryEvent()) val envelope = SentryEnvelope(SentryEnvelopeHeader(), arrayListOf(eventItem)) rateLimiter.updateRetryAfterLimits(null, null, 429) + // a second in, the 60s default delay is still running + fixture.clock.advance(1001, MILLISECONDS) + val result = rateLimiter.filter(envelope, Hint()) assertNull(result) } @@ -372,10 +386,22 @@ class RateLimiterTest { verifyNoMoreInteractions(fixture.clientReportRecorder) } + @Test + fun `a limit lapses exactly at its deadline, not a millisecond later`() { + val rateLimiter = fixture.getSUT() + + rateLimiter.updateRetryAfterLimits("1:error:key", null, 1) + + fixture.clock.advance(999, MILLISECONDS) + assertTrue(rateLimiter.isActiveForCategory(DataCategory.Error)) + + fixture.clock.advance(1, MILLISECONDS) + assertFalse(rateLimiter.isActiveForCategory(DataCategory.Error)) + } + @Test fun `any limit can be checked`() { val rateLimiter = fixture.getSUT() - whenever(fixture.currentDateProvider.currentTimeMillis).thenReturn(0) val eventItem = SentryEnvelopeItem.fromEvent(fixture.serializer, SentryEvent()) val envelope = SentryEnvelope(SentryEnvelopeHeader(), arrayListOf(eventItem)) @@ -393,7 +419,6 @@ class RateLimiterTest { @Test fun `on rate limit DiskFlushNotification is marked as flushed`() { val rateLimiter = fixture.getSUT() - whenever(fixture.currentDateProvider.currentTimeMillis).thenReturn(0) val sentryEvent = SentryEvent() val eventItem = SentryEnvelopeItem.fromEvent(fixture.serializer, sentryEvent) val envelope = SentryEnvelope(SentryEnvelopeHeader(sentryEvent.eventId), arrayListOf(eventItem)) @@ -668,12 +693,13 @@ class RateLimiterTest { @Test fun `apply rate limits schedules a task to notify observers of lifted limits`() { val rateLimiter = fixture.getSUT() - whenever(fixture.currentDateProvider.currentTimeMillis).thenReturn(0, 1, 2001) - val applied = AtomicBoolean(true) rateLimiter.addRateLimitObserver { applied.set(rateLimiter.isActiveForCategory(Replay)) } rateLimiter.updateRetryAfterLimits("1:replay:key", null, 1) + // the notification is scheduled ~1s out in real time; by then the limit has lapsed + fixture.clock.advance(2, SECONDS) + await.untilFalse(applied) assertFalse(applied.get()) } From eb4c4ade4542e7e1e7752f7262b52c4afcc2a830 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 31 Aug 2026 18:05:04 +0200 Subject: [PATCH 2/2] Add changelog entries for JAVA-574 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d67ca7262..04396a665e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,14 @@ - Add `Session.State.Unhandled` for unhandled errors that do not terminate the process ([#5919](https://github.com/getsentry/sentry-java/pull/5919)) +### Behavioral Changes + +- Measure HTTP rate-limit backoff on a monotonic clock instead of the wall clock, so that a device time change no longer lifts or extends an active rate limit ([#6030](https://github.com/getsentry/sentry-java/pull/6030)) + ### Internal - Add internal `UptimeClock` and `ElapsedRealtimeClock` abstractions with `Deadline` and `Stopwatch` primitives ([#6028](https://github.com/getsentry/sentry-java/pull/6028)) +- Deprecate `RateLimiter(ICurrentDateProvider, SentryOptions)` in favour of `RateLimiter(ElapsedRealtimeClock, RateLimiterConfig)` ([#6030](https://github.com/getsentry/sentry-java/pull/6030)) ### Fixes