Skip to content

ref(transport): Measure rate-limit backoff on a monotonic clock (JAVA-574) - #6030

Draft
runningcode wants to merge 2 commits into
no/java-717-connection-cache-deadlinefrom
no/java-574-ratelimiter-deadline
Draft

ref(transport): Measure rate-limit backoff on a monotonic clock (JAVA-574)#6030
runningcode wants to merge 2 commits into
no/java-717-connection-cache-deadlinefrom
no/java-574-ratelimiter-deadline

Conversation

@runningcode

Copy link
Copy Markdown
Contributor

📜 Description

RateLimiter stored retry-after limits as java.util.Date values built from System.currentTimeMillis(). They are now Deadlines on the elapsed-real-time clock.

// before
private final Map<DataCategory, Date> sentryRetryAfterLimit = new ConcurrentHashMap<>();
final Date date = new Date(currentDateProvider.getCurrentTimeMillis() + retryAfterMillis);
applyRetryAfterOnlyIfLonger(dataCategory, date, retryAfterMillis);

// after
private final Map<DataCategory, Deadline> sentryRetryAfterLimit = new ConcurrentHashMap<>();
applyRetryAfterOnlyIfLonger(dataCategory, parseRetryAfterOrDefault(retryAfter));

Stacked on #6029, which is stacked on #6028.

💡 Motivation and Context

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 elapsed-real-time clock counts forward at a steady rate and keeps counting while the device is suspended — which is what a wait the server asked for means.

These limits are in-memory only. They live in a ConcurrentHashMap, are never serialized and never compared across processes, so nothing about this reaches a payload and it is safe to land before v9.

Two things fall out of storing a Deadline instead of a timestamp:

  • applyRetryAfterOnlyIfLonger took the same information twice — an absolute deadline and the delay needed to reach it — because the scheduled "limit lifted" notification needed the delay. It now asks the deadline: deadline.remaining(MILLISECONDS).
  • Three JdkObsolete / 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. RateLimiterConfig is @ApiStatus.Internal, which is what makes adding it as a supertype of a class with four public subclasses safe — if it ever becomes public API, this is worth revisiting.

  • resolves: JAVA-574

💚 How did you test it?

One boundary moves by a nanosecond. A limit used to be active while now <= deadline (!currentDate.after(limit)) and is now active while now < deadline (!deadline.hasPassed()). A new test pins both sides: active at 999 ms into a one-second limit, not active at exactly 1000 ms.

The existing tests dropped their positionally-stubbed ICurrentDateProvider for TestTicker. Deadline reads its clock lazily and a different number of times than the old code, so thenReturn(0, 0, 1001) sequences would break on any change in call count. Four of them were also asserting against a third stub value that was never consumed — When both retry headers are not present, default delay is set never actually advanced past anything — and now genuinely exercise expiry.

sentry module: 3519 tests, 0 failures. sentry-apache-http-client-5, sentry-spring-jakarta and sentry-android-core compile unchanged.

.api is additions only:

+ public fun <init> (Lio/sentry/time/ElapsedRealtimeClock;Lio/sentry/transport/RateLimiterConfig;)V
+ public abstract interface class io/sentry/transport/RateLimiterConfig
- public class io/sentry/SentryOptions {
+ public class io/sentry/SentryOptions : io/sentry/transport/RateLimiterConfig {

Both existing constructors stay. RateLimiter(ICurrentDateProvider, SentryOptions) is deprecated and adapts the injected provider rather than ignoring it — a custom ITransportFactory may be passing one, and silently dropping it would pass CI while breaking someone's suite. Note that ICurrentDateProvider is itself a long () functional interface, so the adapting lambda needs an explicit cast or it resolves back to the deprecated constructor; there is a comment at the call site saying so.

📝 Checklist

  • I added GH Issue ID & Linear ID
  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.
  • Public API changes reviewed by another Mobile SDK team member or implemented according to the develop docs spec.

…-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) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Aug 31, 2026

Copy link
Copy Markdown

JAVA-574

@github-actions

Copy link
Copy Markdown
Contributor

🚨 Detected changes in high risk code 🚨

High-risk code has higher potential to break the SDK and may be hard to test. To prevent severe bugs, apply the rollout process for releasing such changes and be extra careful when changing and reviewing these files:

  • sentry/src/main/java/io/sentry/transport/RateLimiter.java

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

🚨 Detected changes in high risk code 🚨

High-risk code has higher potential to break the SDK and may be hard to test. To prevent severe bugs, apply the rollout process for releasing such changes and be extra careful when changing and reviewing these files:

  • sentry/src/main/java/io/sentry/transport/RateLimiter.java

@sentry

sentry Bot commented Aug 31, 2026

Copy link
Copy Markdown

📲 Install Builds

Android

🔗 App Name App ID Version Configuration
SDK Size io.sentry.tests.size 8.54.0 (1) release

⚙️ sentry-android Build Distribution Settings

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant