ratelimits: stricter() should always prefer denied decisions#8674
Merged
Conversation
aarongable
approved these changes
Mar 12, 2026
jsha
approved these changes
Mar 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
In ratelimits.BatchSpend(), stricter() selects the most restrictive decision across all rate limits in a batch. It compares decisions solely by retryIn, assuming a longer wait is always stricter, and never checks the allowed field. An allowed decision with a high retryIn beats a denied decision with a low retryIn, causing the batch to return allowed: true despite one or more limits denying the request.
This can occur at NewOrder time via checkNewOrderLimits(), which batches:
A subscriber who exhausts their 300 new order quota gets a denied retryIn of at most 36s. If any check-only limit in the same batch has exactly 1 token remaining at request time, that last token produces an allowed decision with retryIn equal to the limit's full emission interval, all of which dwarf 36s. Because check-only transactions never deduct from the bucket, the token is never consumed and the same oversized retryIn wins on every subsequent request. Orders can pile up well past the 300 limit until countCertificateIssued() runs a separate spend-only batch after issuance.
This can also occur for IPv6 clients at NewAccount time via checkNewAccountLimits(), which batches:
These are both check-and-spend with the same 3 hour period. The emission intervals differ (18min vs 21.6s), so the bug is technically possible here too. For IPv6, the per-IP limit (10) will almost always exhaust before the /48 range limit (500), so the reverse scenario (per-range denied, per-IP allowed) is unlikely but not impossible if many IPs in the same /48 are registering. If NewRegistrationsPerIPv6Range denies (retryIn = 21.6s) while NewRegistrationsPerIPAddress allows with 1 remaining (retryIn = 18min), stricter() picks the allowed decision. Both transactions are check-and-spend, so the request that sneaks through deducts from the per-IP bucket, exhausting it. After that, both limits deny and the bug no longer triggers, unlike NewOrder.
Restructure stricter() so denied decisions are always picked over allowed ones regardless of retryIn. The retryIn and remaining comparisons now serve only as tiebreakers within the same allowed/denied category.