Skip to content

Add a token-bucket rate limiter - #4

Open
JohnCampionJr wants to merge 1 commit into
mainfrom
feat/rate-limiter
Open

Add a token-bucket rate limiter#4
JohnCampionJr wants to merge 1 commit into
mainfrom
feat/rate-limiter

Conversation

@JohnCampionJr

@JohnCampionJr JohnCampionJr commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

User description

Allows up to burst events, refilling one token per interval.

Allow consumes a token when one is available and reports whether the caller may proceed.


Generated description

Below is a concise technical summary of the changes proposed in this PR:
Add a token-bucket Limiter that allows an initial burst and refills one token per interval. Expose New and Allow to control event admission, with tests covering burst acceptance and exhaustion.

TopicDetails
Rate limiting Implement token-bucket admission with configurable burst capacity and time-based token refilling through New and Allow.
Modified files (1)
  • internal/rate/limiter.go
Latest Contributors(1)
UserCommitDate
john@brightshore.ioAdd a token-bucket rat...August 27, 2026
Limiter behavior Verify that events within the burst are accepted and requests after exhausting tokens are refused.
Modified files (1)
  • internal/rate/limiter_test.go
Latest Contributors(1)
UserCommitDate
john@brightshore.ioAdd a token-bucket rat...August 27, 2026
Review this PR on Baz | Customize your next review

Summary by cubic

Adds a token-bucket rate limiter in internal/rate that allows up to burst events, refilling one token per interval.

Written for commit ee1c38e. Summary will update on new commits.

Review in cubic

Note

Add 'rate.Limiter' token-bucket type with 'New' and 'Allow'

  • Introduces rate.Limiter struct implementing a token-bucket algorithm in limiter.go.
  • rate.New constructor initializes the limiter with a refill interval and burst capacity, starting prefilled with tokens.
  • rate.Limiter.Allow method refills tokens based on elapsed time, caps at burst, and consumes one token to allow events or denies when empty.
📊 Macroscope summarized ee1c38e. 1 file reviewed, 7 issues evaluated, 0 issues filtered, 5 comments posted

🗂️ Filtered Issues

Allows up to burst events, refilling one token per interval.
@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1ba87c89-80fb-4ec2-9bb1-9c0be77d7157

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

@githail
githail Bot requested a lite review from Copilot August 27, 2026 13:14
@githail

githail Bot commented Aug 27, 2026

Copy link
Copy Markdown

@coderabbitai review

@githail

githail Bot commented Aug 27, 2026

Copy link
Copy Markdown

@macroscopeapp review

Comment thread internal/rate/limiter.go
if l.tokens > l.burst {
l.tokens = l.burst
}
l.last = time.Now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium rate/limiter.go:28

Allow loses the elapsed remainder after a refill, so an empty limiter checked 15 seconds into a 10-second interval refills once but cannot refill again until 25 seconds instead of 20 seconds. Advance last by refill * l.interval rather than resetting it to the current time to preserve the configured cadence.

Suggested change
l.last = time.Now()
l.last = l.last.Add(time.Duration(refill) * l.interval)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @internal/rate/limiter.go around line 28:

`Allow` loses the elapsed remainder after a refill, so an empty limiter checked 15 seconds into a 10-second interval refills once but cannot refill again until 25 seconds instead of 20 seconds. Advance `last` by `refill * l.interval` rather than resetting it to the current time to preserve the configured cadence.

Comment thread internal/rate/limiter.go
l.last = time.Now()
}

if l.tokens == 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High rate/limiter.go:31

Concurrent calls to Allow can both return true when only one token remains, exceeding the configured limit and causing a Go data race. The tokens check/decrement and last update are unsynchronized; protect the limiter state with a mutex around the refill and consume operation.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @internal/rate/limiter.go around line 31:

Concurrent calls to `Allow` can both return `true` when only one token remains, exceeding the configured limit and causing a Go data race. The `tokens` check/decrement and `last` update are unsynchronized; protect the limiter state with a mutex around the refill and consume operation.

Comment thread internal/rate/limiter.go
Comment on lines +22 to +27
refill := int(elapsed / l.interval)
if refill > 0 {
l.tokens += refill
if l.tokens > l.burst {
l.tokens = l.burst
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium rate/limiter.go:22

On 32-bit Go targets, an exhausted limiter remains empty after more than about 2.15 seconds of idle time when interval is 1 ns, even though tokens should have refilled. int(elapsed / l.interval) overflows to a negative value before refill > 0 is checked, so the refill is skipped; keep the quotient in time.Duration and cap it at the remaining burst capacity before converting to int.

-\trefill := int(elapsed / l.interval)
+\trefill := elapsed / l.interval
 \tif refill > 0 {
-\t\tl.tokens += refill
-\t\tif l.tokens > l.burst {
+\t\tif refill >= time.Duration(l.burst-l.tokens) {
 \t\t\tl.tokens = l.burst
+\t\t} else {
+\t\t\tl.tokens += int(refill)
 \t\t}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @internal/rate/limiter.go around lines 22-27:

On 32-bit Go targets, an exhausted limiter remains empty after more than about 2.15 seconds of idle time when `interval` is 1 ns, even though tokens should have refilled. `int(elapsed / l.interval)` overflows to a negative value before `refill > 0` is checked, so the refill is skipped; keep the quotient in `time.Duration` and cap it at the remaining `burst` capacity before converting to `int`.

Comment thread internal/rate/limiter.go
Comment on lines +23 to +27
if refill > 0 {
l.tokens += refill
if l.tokens > l.burst {
l.tokens = l.burst
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High rate/limiter.go:23

With burst == math.MaxInt, a refill overflows l.tokens before the capacity clamp: math.MaxInt + 1 becomes negative, bypasses the l.tokens == 0 check, and the subsequent decrement wraps it back to a positive value. The bucket therefore never depletes correctly; clamp the refill against the remaining capacity before adding it.

	if refill > 0 {
-		l.tokens += refill
-		if l.tokens > l.burst {
-			l.tokens = l.burst
-		}
+		if refill >= l.burst-l.tokens {
+			l.tokens = l.burst
+		} else {
+			l.tokens += refill
+		}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @internal/rate/limiter.go around lines 23-27:

With `burst == math.MaxInt`, a refill overflows `l.tokens` before the capacity clamp: `math.MaxInt + 1` becomes negative, bypasses the `l.tokens == 0` check, and the subsequent decrement wraps it back to a positive value. The bucket therefore never depletes correctly; clamp the refill against the remaining capacity before adding it.

Comment thread internal/rate/limiter.go
// Allow reports whether an event may proceed, consuming a token if so.
func (l *Limiter) Allow() bool {
elapsed := time.Since(l.last)
refill := int(elapsed / l.interval)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High rate/limiter.go:22

Invalid inputs to New produce incorrect limiter behavior: New(0, burst).Allow() panics on elapsed / l.interval, New(time.Hour, -1).Allow() returns true indefinitely, and a negative interval permanently rejects events after the initial tokens are spent. Validate that interval > 0 and burst >= 0 in New before constructing the limiter.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @internal/rate/limiter.go around line 22:

Invalid inputs to `New` produce incorrect limiter behavior: `New(0, burst).Allow()` panics on `elapsed / l.interval`, `New(time.Hour, -1).Allow()` returns `true` indefinitely, and a negative interval permanently rejects events after the initial tokens are spent. Validate that `interval > 0` and `burst >= 0` in `New` before constructing the limiter.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ee1c38e671

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/rate/limiter.go

// Allow reports whether an event may proceed, consuming a token if so.
func (l *Limiter) Allow() bool {
elapsed := time.Since(l.last)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Synchronize concurrent calls to Allow

When a limiter is shared by concurrent request handlers, Allow reads and mutates last and tokens without synchronization. Two callers can both observe the final token and proceed; an interleaving can also decrement tokens below zero, after which the tokens == 0 check continues allowing requests. Protect the state transition with a mutex or another atomic design.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Merged into Allow mutates limiter state without synchronization (internal/rate/limiter.go:31), also reported by macroscopeapp, chatgpt-codex-connector.

Resolving this thread as a duplicate — the finding itself is not dismissed, and reopening this thread is the right move if the merge is wrong.

See the consolidated review: #4 (comment)

Comment thread internal/rate/limiter.go
if l.tokens > l.burst {
l.tokens = l.burst
}
l.last = time.Now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve partial refill intervals

When the bucket is still below capacity after a refill, resetting last to the current time discards the fractional interval already elapsed. For example, after exhausting a burst of two, a call at 1.5 intervals consumes the first refill, but the next token is delayed until 2.5 intervals rather than arriving at 2 intervals. Advance last by refill * interval instead, except where time above the full-bucket cap is intentionally discarded.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Merged into Resetting last to now discards the elapsed remainder, slowing refill (internal/rate/limiter.go:28), also reported by macroscopeapp, chatgpt-codex-connector, copilot-pull-request-reviewer.

Resolving this thread as a duplicate — the finding itself is not dismissed, and reopening this thread is the right move if the merge is wrong.

See the consolidated review: #4 (comment)

Comment thread internal/rate/limiter.go
Comment on lines +15 to +16
func New(interval time.Duration, burst int) *Limiter {
return &Limiter{interval: interval, burst: burst, tokens: burst, last: time.Now()}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject a zero refill interval

If interval is zero—such as from an omitted duration in caller configuration—New succeeds but the first call to Allow panics on elapsed / l.interval. Validate that the interval is positive in the constructor, or return an error, so invalid input cannot create a limiter that crashes on use.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Merged into New accepts a non-positive interval; Allow then panics or never refills (internal/rate/limiter.go:16), also reported by macroscopeapp, copilot-pull-request-reviewer, chatgpt-codex-connector.

Resolving this thread as a duplicate — the finding itself is not dismissed, and reopening this thread is the right move if the merge is wrong.

See the consolidated review: #4 (comment)

Comment thread internal/rate/limiter.go
// Allow reports whether an event may proceed, consuming a token if so.
func (l *Limiter) Allow() bool {
elapsed := time.Since(l.last)
refill := int(elapsed / l.interval)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid narrowing the refill count to int

On 32-bit targets, converting the elapsed-interval count to int can overflow during ordinary uptime for sufficiently short intervals—for example, a 1µs interval exceeds MaxInt32 after about 36 minutes. The wrapped value can be nonpositive, causing an exhausted limiter to skip refilling even though tokens are due. Keep the quotient as int64 and clamp it to the available bucket capacity before converting or adding it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Merged into Narrowing the refill quotient to int can skip refills on 32-bit targets (internal/rate/limiter.go:22), also reported by macroscopeapp, chatgpt-codex-connector.

Resolving this thread as a duplicate — the finding itself is not dismissed, and reopening this thread is the right move if the merge is wrong.

See the consolidated review: #4 (comment)

Comment thread internal/rate/limiter.go
Comment on lines +15 to +16
func New(interval time.Duration, burst int) *Limiter {
return &Limiter{interval: interval, burst: burst, tokens: burst, last: time.Now()}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject negative burst sizes

When a negative burst value reaches New, tokens starts below zero and the equality-only exhaustion check never rejects a request; every call instead decrements the value further and returns true, disabling the rate limit entirely. Validate that burst is nonnegative in the constructor, or otherwise ensure nonpositive token counts are refused.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Merged into A negative burst disables rate limiting entirely (internal/rate/limiter.go:31), also reported by macroscopeapp, chatgpt-codex-connector.

Resolving this thread as a duplicate — the finding itself is not dismissed, and reopening this thread is the right move if the merge is wrong.

See the consolidated review: #4 (comment)

Copilot AI 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.

Pull request overview

Adds a new internal rate.Limiter implementing a token-bucket rate limiter (burst capacity with periodic refills) and basic tests validating burst allowance and exhaustion.

Changes:

  • Introduces internal/rate.Limiter with New(interval, burst) and (*Limiter).Allow().
  • Implements time-based token refilling and token consumption logic.
  • Adds initial unit tests for burst acceptance and refusal after exhaustion.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
internal/rate/limiter.go Adds the limiter implementation (constructor + Allow refill/consume logic).
internal/rate/limiter_test.go Adds unit tests for burst allowance and exhaustion behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/rate/limiter.go
Comment on lines +14 to +17
// New builds a limiter that refills one token per interval, up to burst.
func New(interval time.Duration, burst int) *Limiter {
return &Limiter{interval: interval, burst: burst, tokens: burst, last: time.Now()}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Merged into New accepts a non-positive interval; Allow then panics or never refills (internal/rate/limiter.go:16), also reported by macroscopeapp, copilot-pull-request-reviewer, chatgpt-codex-connector.

Resolving this thread as a duplicate — the finding itself is not dismissed, and reopening this thread is the right move if the merge is wrong.

See the consolidated review: #4 (comment)

Comment thread internal/rate/limiter.go
if l.tokens > l.burst {
l.tokens = l.burst
}
l.last = time.Now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Merged into Resetting last to now discards the elapsed remainder, slowing refill (internal/rate/limiter.go:28), also reported by macroscopeapp, chatgpt-codex-connector, copilot-pull-request-reviewer.

Resolving this thread as a duplicate — the finding itself is not dismissed, and reopening this thread is the right move if the merge is wrong.

See the consolidated review: #4 (comment)

Comment on lines +17 to +24
func TestExhaustedBurstIsRefused(t *testing.T) {
l := New(time.Hour, 1)
l.Allow()

if l.Allow() {
t.Error("a second event should be refused")
}
}
@githail

githail Bot commented Aug 27, 2026

Copy link
Copy Markdown

Review reconciled — 7 findings from 3 reviewers

Findings

MAJOR — Allow mutates limiter state without synchronization · internal/rate/limiter.go:31 · macroscopeapp, chatgpt-codex-connector
Allow reads and writes l.tokens and l.last with no mutex or atomics. Two goroutines calling Allow() on a shared limiter with tokens==1 can both pass the l.tokens == 0 check at line 31 and both execute l.tokens--, admitting two events against a burst of one and leaving tokens at -1. Because the exhaustion test is equality-only, a tokens value of -1 never rejects: every subsequent call decrements further and returns true, so the limiter is permanently disabled rather than merely over-admitting once. This is also an unsynchronized read/write on a non-atomic field, i.e. a Go data race under go test -race. The two reviewers landed on different lines (21 and 31) but describe the same defect.

MAJOR — New accepts a non-positive interval; Allow then panics or never refills · internal/rate/limiter.go:16 · macroscopeapp, copilot-pull-request-reviewer, chatgpt-codex-connector
New performs no validation. With interval == 0 (an omitted duration in caller config is the realistic path), the constructor succeeds and the first Allow() panics at line 22 on elapsed / l.interval — integer division by zero. With a negative interval, elapsed / l.interval is negative, so refill > 0 is never true and the bucket never refills: once the initial burst is spent, Allow() returns false forever. Failing fast in the constructor, as time.NewTicker does, keeps the invalid input from producing a limiter that crashes or silently stalls on use. macroscopeapp reported this in a comment that also covers negative burst, so its thread is not safe to consolidate here.

MAJOR — A negative burst disables rate limiting entirely · internal/rate/limiter.go:31 · macroscopeapp, chatgpt-codex-connector
New(time.Hour, -1) sets tokens = -1. On the first Allow(), elapsed < interval so refill is 0, and the guard at line 31 tests l.tokens == 0 rather than l.tokens <= 0. Since -1 != 0 the guard is skipped, tokens decrements to -2, and the call returns true. Every later call moves tokens further from zero, so the guard can never fire again and the limiter admits unbounded traffic. This is a fail-open: the caller believes it is rate limited and is not. Distinct from the interval defect above — different input, different failure — though a single validation block in New fixes both. macroscopeapp reported this inside its combined-validation comment, which is why that thread is not listed as consolidatable.

MINOR — Resetting last to now discards the elapsed remainder, slowing refill · internal/rate/limiter.go:28 · macroscopeapp, chatgpt-codex-connector, copilot-pull-request-reviewer
Line 28 sets l.last = time.Now() after a refill instead of advancing it by the intervals actually consumed. The sub-interval remainder is thrown away, so the next token arrives later than the configured cadence. Concretely, with a 10s interval and an exhausted bucket, a call at t=15s refills one token and resets last to 15s; the next token is due at 25s rather than 20s. The worst case is a call arriving just under two intervals after last, which discards almost a full interval and halves the effective rate. This contradicts the type comment on line 6, which asserts the limiter refills 'one token every interval'. Fix is l.last = l.last.Add(time.Duration(refill) * l.interval), except where the bucket clamped at burst and the excess time is intentionally dropped. All three reviewers found this independently and all three landed on line 28.

MINOR — Narrowing the refill quotient to int can skip refills on 32-bit targets · internal/rate/limiter.go:22 · macroscopeapp, chatgpt-codex-connector
elapsed / l.interval is a time.Duration (int64); line 22 narrows it to int. Where int is 32 bits, a quotient above MaxInt32 wraps and can land non-positive, so refill > 0 at line 23 is false and an exhausted limiter skips a refill that is genuinely due. Both reviewers' arithmetic checks out: a 1ns interval crosses MaxInt32 after ~2.15s of idle time, a 1us interval after ~36 minutes. Keep the quotient in Duration/int64 and clamp it against remaining capacity before converting. Reachable only on 32-bit builds with sub-millisecond intervals, which is why this is ranked below the concurrency and validation defects despite macroscopeapp and chatgpt-codex-connector both flagging it.

MINOR — l.tokens += refill can overflow before the burst clamp · internal/rate/limiter.go:24 · macroscopeapp
Line 24 adds refill to tokens and only then clamps against burst on line 25. With burst at or near math.MaxInt the addition overflows to a negative value, the l.tokens > l.burst clamp does not fire because the negative result is below burst, and the negative tokens then defeats the equality-only exhaustion check the same way a negative burst does — the bucket never depletes. Clamping refill against remaining capacity before adding avoids the intermediate overflow. Two caveats on the report: macroscopeapp states the 'subsequent decrement wraps it back to a positive value', which is wrong (decrementing a negative goes further negative) — the true reason the limiter keeps allowing is the != 0 guard, not a wrap. And reachability is narrow: on 64-bit, driving refill near MaxInt needs a 1ns interval and roughly 292 years of elapsed time. I have kept this separate from the narrowing defect above because the triggering input and the observable failure differ, even though one clamp fixes both.

MINOR — No test exercises the refill path · internal/rate/limiter_test.go:24 · copilot-pull-request-reviewer
Confirmed by reading the file: TestBurstIsAllowed and TestExhaustedBurstIsRefused both construct the limiter with time.Hour, so elapsed never reaches one interval and lines 23-28 of limiter.go never execute. Every other cluster in this list except the validation ones lives in that unexecuted block, which is why the green test check says nothing about them. Backdating l.last exercises the refill and burst-cap paths without sleeping.

Coverage

Absent: coderabbitai, baz-reviewer — this verdict is incomplete, not clean.
Not verified: No finding is contradicted by a deterministic signal. The test check is green, but the two tests both use a time.Hour interval and never enter the refill block, and neither runs concurrently or under -race, so green here does not refute the concurrency, drift, or overflow clusters. Macroscope's own correctness check concluded neutral, not pass. I did not fetch the diff — every finding named a file and line, and both files were read in full at ee1c38e. I did not grep for callers of rate.New or Allow elsewhere in the repo, so real-world reachability of the invalid-input clusters is unconfirmed. I did not inspect the test job to determine its GOARCH, so the 32-bit narrowing and MaxInt overflow claims are reasoned from the source rather than observed failing. No reviewer claimed a defect 'also appears at' additional locations, so no inherited deduplication needed re-verification and none was dropped. Separately: chatgpt-codex-connector left seven inline comments but is not in the configured expect list, so this reconciliation covers a reviewer the repo config does not track.

Verdict: incomplete

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.

2 participants