Skip to content

fix(utils): advance the token bucket timestamp even when the bucket is full - #2652

Merged
auvipy merged 3 commits into
celery:mainfrom
MaxFreedomPollard:fix/tokenbucket-timestamp
Sep 19, 2026
Merged

auvipy merged 3 commits into
celery:mainfrom
MaxFreedomPollard:fix/tokenbucket-timestamp

Conversation

@MaxFreedomPollard

Copy link
Copy Markdown
Contributor

Fixes #680.

TokenBucket grants one extra full refill on the first burst after an idle period, so a rate limit built on it is exceeded at exactly the moment traffic resumes. At the default capacity=1 that is two consumes where one was configured.

The refill method in kombu/utils/limits.py (lines 81-87 on main) is def _get_tokens(self): if self._tokens < self.capacity: now = monotonic(); ...; self.timestamp = now. Both the clock read and the timestamp write sit inside the if, so while the bucket is at capacity that branch never runs and timestamp stays frozen at whatever it was, which for a fresh bucket is its construction time. The first can_consume() drains tokens without ever touching timestamp. The very next call then computes delta = self.fill_rate * (now - self.timestamp) against that stale value, so the entire idle period is counted as refill time and min() tops the bucket straight back up to capacity. Any gap long enough to notionally refill the bucket produces the same over-admission; a longer gap does not make it worse, because min() clamps at capacity.

from unittest.mock import patch
import kombu.utils.limits as L
now = [1000.0]
with patch.object(L, 'monotonic', lambda: now[0]):
    b = L.TokenBucket(fill_rate=0.1, capacity=1)
    now[0] = 1600.0
    print(b.can_consume())  # True
    now[0] = 1600.001
    print(b.can_consume())  # True on main, should be False

At one token per 10 seconds the bucket grants two consumes 1ms apart. Measured on unpatched main, the size of the hole is exactly one full refill: a capacity=10 bucket idle for 600s admits 11 one-token consumes instead of 10, and grants can_consume(10) twice in a row where it should grant it once.

Issue #680 proposed this change in 2017, character for character: hoist now = monotonic() above the if and move self.timestamp = now below it, matching the ActiveState recipe the class was taken from. auvipy replied "contributions are welcome" in January 2018. This PR is that diff plus tests and a changelog entry. The only other open PR touching this file, #1525 ("Annotate and refactor limits.py", conflicted and untouched since December 2022), only annotates the _get_tokens signature and leaves its body unchanged, so there is no overlap.

That is two moved lines. Nothing else in the class changes: can_consume, expected_time, add, pop, clear_pending and every other public attribute are untouched, and normal refill at fill_rate behaves exactly as before. expected_time() now also advances timestamp when the bucket is full; below capacity it already did, and at capacity there is nothing to lose. The stale #: comment on timestamp is updated to say what it now records. In-tree, ConsumerMixin.restart_limit in kombu/mixins.py is unaffected, because it is drained on the same call that creates it and never sits idle at capacity. Downstream, celery's per-task rate limits get the limit they were configured with instead of one extra on the first burst.

t/unit/utils/test_limits.py is new, since the module had no tests at all. test_can_consume_does_not_double_admit_after_idle and test_idle_bucket_admits_only_its_capacity both fail on unpatched main and pass with the fix; the second one covers the general capacity=10 case. test_refills_at_fill_rate passes both before and after, so ordinary refill is proven unchanged.

Verification, on Python 3.11 with requirements/default.txt and requirements/test.txt installed. pytest t/unit/utils/test_limits.py -q gives 2 failed, 1 passed in 0.11s with main's limits.py restored, failing on assert True is False and on At index 10 diff: True != False, and 3 passed in 0.06s with the fix. For the before/after comparison, the offline unit suite with the new test file removed gives 952 passed, 189 skipped both with main's limits.py and with the fix, so nothing else in the suite moves; with the new file it is 955 passed, 189 skipped in 17.05s. Those runs exclude t/unit/asynchronous/aws, t/unit/transport/SQS, test_azurestoragequeues.py, test_azureservicebus.py and test_gcpubsub.py, which need the boto3, azure and google cloud SDKs from requirements/test-ci.txt and are not installed locally. flake8 -j2 kombu t and pydocstyle kombu both exit 0, and mypy --config-file setup.cfg reports Success: no issues found in 22 source files.

…s full

TokenBucket._get_tokens() read the clock and wrote self.timestamp inside
the `if self._tokens < self.capacity` branch, so a bucket sitting at full
capacity never advanced its timestamp. The first can_consume() drained the
bucket without touching the timestamp, and the next call then measured the
refill delta against the stale value, which covered the whole idle period
and instantly refilled the bucket to capacity.

The effect was one extra full refill on the first burst after an idle gap
long enough to notionally refill the bucket: a capacity=1 bucket admitted
two consumes instead of one, and a capacity=10 bucket admitted eleven
one-token consumes instead of ten. Reading the clock and storing the
timestamp now happen unconditionally, while the refill arithmetic stays
behind the same guard, so normal refill behavior at fill_rate is unchanged.

Refs celery#680
@codecov

codecov Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 83.89%. Comparing base (b43c9c8) to head (b9fde7a).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #2652   +/-   ##
=======================================
  Coverage   83.89%   83.89%           
=======================================
  Files          80       80           
  Lines       11196    11196           
  Branches     1336     1336           
=======================================
  Hits         9393     9393           
  Misses       1585     1585           
  Partials      218      218           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@auvipy auvipy added this to the 5.7.0 milestone Sep 15, 2026
@auvipy

auvipy commented Sep 19, 2026

Copy link
Copy Markdown
Member

@MehrazRumman please review

@auvipy
auvipy self-requested a review September 19, 2026 07:30
@auvipy

auvipy commented Sep 19, 2026

Copy link
Copy Markdown
Member

@GangEunzzang sorry to bother you here

@GangEunzzang GangEunzzang left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Checked with a real Celery worker: at rate_limit='6/m' a burst after idle ran two tasks at once on main and one on this branch. LGTM.

@auvipy
auvipy merged commit 6170fb8 into celery:main Sep 19, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

timestamp should be moved out of if statement in TokenBucket._get_tokens()

3 participants