ref(transport): Measure rate-limit backoff on a monotonic clock (JAVA-574) - #6030
ref(transport): Measure rate-limit backoff on a monotonic clock (JAVA-574)#6030runningcode wants to merge 2 commits into
Conversation
🚨 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:
|
1 similar comment
🚨 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:
|
📲 Install BuildsAndroid
|
00b4937 to
7eae6db
Compare
eb4c4ad to
0a21879
Compare
🚨 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:
|
7eae6db to
e20e3fa
Compare
0a21879 to
074890f
Compare
e20e3fa to
2b35532
Compare
074890f to
781c864
Compare
🚨 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:
|
…-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 monotonic 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>
2b35532 to
76fef57
Compare
781c864 to
8bb8665
Compare
🚨 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:
|
PR Stack (Clock semantics hardening)
📜 Description
RateLimiterstored retry-after limits asjava.util.Datevalues built fromSystem.currentTimeMillis(). They are nowDeadlines on the monotonic clock.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 monotonic 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
Deadlineinstead of a timestamp:applyRetryAfterOnlyIfLongertook 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).JdkObsolete/JavaUtilDatesuppressions go with theDates.RateLimiteralso took the wholeSentryOptionswhile reading exactly three methods from it. It now depends onRateLimiterConfig, declared next to its consumer, so what a rate limiter touches is three lines to read rather than three hundred.SentryOptionsimplements it with no new methods, so every existing caller compiles unchanged.RateLimiterConfigis@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.💚 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 whilenow < 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
ICurrentDateProviderforTestMonotonicClock.Deadlinereads its clock lazily and a different number of times than the old code, sothenReturn(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 setnever actually advanced past anything — and now genuinely exercise expiry.sentrymodule: 3519 tests, 0 failures.sentry-apache-http-client-5,sentry-spring-jakartaandsentry-android-corecompile unchanged..apiis additions only:Both existing constructors stay.
RateLimiter(ICurrentDateProvider, SentryOptions)is deprecated and adapts the injected provider rather than ignoring it — a customITransportFactorymay be passing one, and silently dropping it would pass CI while breaking someone's suite. Note thatICurrentDateProvideris itself along ()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
sendDefaultPIIis enabled.