Skip to content

fix: reject requests on rate limit bucket contention instead of queueing threads - #3908

Draft
bdshadow wants to merge 2 commits into
mainfrom
bdshadow/rate-limit-concurrency-cap
Draft

bdshadow wants to merge 2 commits into
mainfrom
bdshadow/rate-limit-concurrency-cap

Conversation

@bdshadow

@bdshadow bdshadow commented Sep 9, 2026 •

Copy link
Copy Markdown
Member

Problem

All rate limit verdicts for a bucket — including "you are rate limited, 429" — are serialized behind a distributed Redisson lock, and RateLimitService.consumeBucketUnless blocks on it indefinitely (lock.lock()).

When many requests share one bucket (e.g. a single API key used by a large fleet of shipped clients), this becomes a self-inflicted denial of service: a turn on the lock costs ~4 Redis round-trips (acquire, bucket get, bucket/strike put, unlock + pub/sub), and Redisson's unfair wake-all handoff degrades to ~25–50 ms per turn with many waiters — so one bucket can only serve a few dozen verdicts per second. Once arrival on the bucket exceeds that, the queue grows without bound (wait = queue position × turn time), clients time out and retry — refilling the queue faster than it drains — and the entire worker pool ends up parked on a single bucket lock. The node stops serving all tenants while still passing health checks on the management port.

Per-IP rate limiting (proxy or app-level) cannot catch this shape: each client stays far below any per-IP threshold; it is the aggregate on one bucket that kills the node. And the failure mode is concurrency × duration, not request rate — so the fix is to stop queueing worker threads, not to tune limits.

Fix

RateLimitService no longer queues unboundedly on the bucket lock:

  1. Per-bucket concurrency cap (per node) — an in-flight counter per bucket; above tolgee.rate-limits.max-concurrent-per-bucket (default 50) requests are rejected with 429 immediately, without touching the lock. Node-local by design: the protected resource (worker threads) is node-local, and the cap keeps working even when Redis itself is slow. Set to 0 to disable.
  2. Bounded lock wait — the bucket lock is acquired via the new LockingProvider.tryWithLocking(name, waitTime, fn) with tolgee.rate-limits.lock-wait-ms (default 500 ms) instead of an infinite lock.lock(); timeout → 429. tryWithLocking is a default interface method; RedissonLockingProvider overrides it only to keep its isHeldByCurrentThread unlock guard.

Neither rejection records a strike (the bucket cannot be read or written safely without holding the lock); both use retryAfter = lock-wait-ms.

New metric: tolgee.ratelimit.concurrency_rejections counter, tagged reason=concurrency_cap|lock_timeout.

Behavior change

A client sending more than max-concurrent-per-bucket truly simultaneous requests per node to one bucket now gets 429s for the excess instead of having them queue briefly. The default of 50 covers the largest known legitimate burst — apps with 40+ namespaces fetch them all in parallel on startup through one API key (one bucket), even if the whole burst lands on a single node — while still limiting a single hot bucket to ~25% of a node's worker pool (~200 threads). The cap only bites the flood shape described above.

Testing

  • New unit tests in RateLimitServiceTest: rejection within the lock-wait bound when the lock is held; cap rejection happens without waiting for the lock (timing-asserted against a 10 s lock wait); cap on one bucket does not affect other buckets; max-concurrent-per-bucket = 0 restores queueing. Metric counters asserted throughout.
  • Existing RateLimitServiceTest, :security rate limit tests, and RateLimitsTest / RedisRateLimitsTest integration suites pass unchanged.

@coderabbitai

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

Comment on lines +23 to +38
fun <T> tryWithLocking(
name: String,
waitTime: Duration,
fn: () -> T,
): T? {
val lock = getLock(name)
if (!lock.tryLock(waitTime.toMillis(), TimeUnit.MILLISECONDS)) {
return null
}
try {
return fn()
} finally {
lock.unlock()
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why same function twice?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Anty0 which one?
tryLock is only in this function

@bdshadow
bdshadow force-pushed the bdshadow/rate-limit-concurrency-cap branch from fee3fd9 to 2093ed9 Compare September 9, 2026 15:19
Comment on lines +34 to +51
override fun <T> tryWithLocking(
name: String,
waitTime: Duration,
fn: () -> T,
): T? {
val lock = this.getLock(name)
if (!lock.tryLock(waitTime.toMillis(), TimeUnit.MILLISECONDS)) {
return null
}
try {
return fn()
} finally {
if (lock.isHeldByCurrentThread) {
lock.unlock()
}
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thought this is basically the same function.

…ing threads

When many requests share one rate limit bucket (e.g. a single API key
used by a large fleet of shipped clients), every worker thread queues
indefinitely on the bucket's distributed lock. The lock serves only a
few dozen verdicts per second under contention, so the queue grows
without bound and the whole thread pool can end up parked on a single
bucket, making the node unresponsive for all tenants while it still
passes health checks.

RateLimitService now:
- caps concurrent consumers per bucket per node
  (tolgee.rate-limits.max-concurrent-per-bucket, default 50) and
  rejects requests above the cap with 429 without touching the lock;
  the default leaves room for legitimate bursts such as apps fetching
  40+ namespaces in parallel on startup,
- waits for the bucket lock at most tolgee.rate-limits.lock-wait-ms
  (default 500) via the new LockingProvider.tryWithLocking instead of
  blocking forever, rejecting with 429 on timeout.

Rejections are counted in the new
tolgee.ratelimit.concurrency_rejections metric, tagged by reason
(concurrency_cap / lock_timeout). No strike is recorded for these
rejections since the bucket cannot be read safely without the lock.
@bdshadow
bdshadow force-pushed the bdshadow/rate-limit-concurrency-cap branch from 2093ed9 to f8d22a1 Compare September 21, 2026 12:51
The Redisson override of tryWithLocking repeated the whole interface
default only to change the release call. The interface now exposes a
releaseLock hook (default: plain unlock). Redisson overrides only the
hook with releaseEvenIfInterrupted and uses it in all three locking
methods.
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.

2 participants