Skip to content

fix: reject ratelimit changes#4087

Merged
Flo4604 merged 4 commits into10-07-test_rejected_requests_should_not_modify_ratelimit_statefrom
flo-reject-changes
Oct 9, 2025
Merged

fix: reject ratelimit changes#4087
Flo4604 merged 4 commits into10-07-test_rejected_requests_should_not_modify_ratelimit_statefrom
flo-reject-changes

Conversation

@Flo4604
Copy link
Member

@Flo4604 Flo4604 commented Oct 9, 2025

What does this PR do?

Fixes # (issue)

If there is not an issue for this, please create one first. This is used to tracking purposes and also helps use understand why this PR exists

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • Chore (refactoring code, technical debt, workflow improvements)
  • Enhancement (small improvements)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

How should this be tested?

  • Test A
  • Test B

Checklist

Required

  • Filled out the "How to test" section in this PR
  • Read Contributing Guide
  • Self-reviewed my own code
  • Commented on my code in hard-to-understand areas
  • Ran pnpm build
  • Ran pnpm fmt
  • Checked for warnings, there are none
  • Removed all console.logs
  • Merged the latest changes from main onto my branch with git pull origin main
  • My changes don't cause any responsiveness issues

Appreciated

  • If a UI change was made: Added a screen recording or screenshots to this PR
  • Updated the Unkey Docs if changes were necessary

@changeset-bot
Copy link

changeset-bot bot commented Oct 9, 2025

⚠️ No Changeset found

Latest commit: f3f8232

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel
Copy link

vercel bot commented Oct 9, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Preview Comments Updated (UTC)
dashboard Ignored Ignored Preview Oct 9, 2025 10:48am
engineering Ignored Ignored Preview Oct 9, 2025 10:48am

@vercel vercel bot temporarily deployed to Preview – dashboard October 9, 2025 07:37 Inactive
@vercel vercel bot temporarily deployed to Preview – engineering October 9, 2025 07:37 Inactive
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 9, 2025

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

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.

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

📝 Walkthrough

Walkthrough

Refactors rate limiting from slice-based to single-request API, adds a new atomic batch method, updates service interface, and adapts callers. The API handler now uses the single-request path; key validation chooses single vs batch paths. The service implements deterministic locking, batch evaluation, and helper routines for atomic multi-request checks.

Changes

Cohort / File(s) Summary
API v2 handler shift to single-request
go/apps/api/routes/v2_ratelimit_limit/handler.go
Replaces slice-based Ratelimit call with single-request variant; updates usage to consume a single result (Success, Remaining, Reset) without indexing.
Key validation: single vs many path
go/internal/services/keys/validation.go
Chooses single-request Ratelimit when exactly one limit; uses RatelimitMany for multiple limits. Iteration adjusted to index-based access of responses and success checks. Error handling preserved.
Ratelimit interface changes
go/internal/services/ratelimit/interface.go
Updates Service: Ratelimit now accepts one RatelimitRequest and returns one RatelimitResponse; adds RatelimitMany for batch processing. Comments updated.
Ratelimit service refactor and atomic batch
go/internal/services/ratelimit/service.go
Introduces RatelimitMany with deterministic locking, deduped bucket set, and atomic evaluation; adds single-request Ratelimit path; adds helper checkBucketWithLockHeld; new internal types for request and bucket metadata; imports updated (e.g., sort).

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant C as Client
  participant H as API Handler
  participant K as Key Validation
  participant RL as Ratelimit Service
  participant L as Bucket Locks
  participant R as Redis

  rect rgb(245,248,255)
    note over C,H: Single-limit request
    C->>H: POST /v2/ratelimit
    H->>RL: Ratelimit(req)
    RL->>RL: Validate window, compute key
    alt Local decision possible
      RL->>RL: Sliding window check
    else Needs backfill
      RL->>R: Fetch window state
      R-->>RL: State
      RL->>RL: Decide
    end
    RL->>R: Buffer/increment on success
    RL-->>H: RatelimitResponse
    H-->>C: 200/429 with Remaining/Reset
  end

  rect rgb(245,255,245)
    note over C,K: Multi-limit (atomic)
    C->>K: Validate API key (N limits)
    alt N == 1
      K->>RL: Ratelimit(req)
      RL-->>K: Response
    else N > 1
      K->>RL: RatelimitMany(reqs[])
      RL->>RL: Build keys, sort reqs
      RL->>L: Lock buckets in order
      RL->>RL: Check all with locks
      alt All pass
        RL->>R: Batch increments/buffer
      else Any fail
        RL->>RL: Mark denied, clamp remaining
      end
      L-->>RL: Unlock
      RL-->>K: Responses[]
    end
    K-->>C: Validation outcome
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • perkinsjr
  • imeyer
  • chronark
  • ogzhanolguncu

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Title Check ⚠️ Warning The title “flo: reject ratelimit changes” does not clearly summarize the main changes in the pull request and includes a non-standard author prefix. It is vague about the actual refactoring of the ratelimit API to single-request handling and the introduction of an atomic multi-request path. Because it neither accurately nor fully conveys the primary intent, it fails to meet the clarity and specificity requirements. Please replace the title with a concise, clear summary of the core change, for example “Refactor ratelimit API to single-request calls and add atomic batch handling,” without author prefixes or ambiguous terms.
Description Check ⚠️ Warning The pull request description is unchanged from the repository’s template and contains placeholder text without any actual details on what the PR does, which issue it fixes, the type of change, or specific testing instructions. Because no sections have been filled out, the description does not inform reviewers about the changes, motivations, or validation steps. Fill out each section of the template with concrete information: summarize the implemented ratelimit refactor, reference the relevant issue number, mark the type of change, provide detailed test steps, and complete the checklist items after running the required commands.
✅ Passed checks (1 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@Flo4604 Flo4604 changed the base branch from main to 10-07-test_rejected_requests_should_not_modify_ratelimit_state October 9, 2025 08:20
@Flo4604
Copy link
Member Author

Flo4604 commented Oct 9, 2025

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 9, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Flo4604 Flo4604 changed the title flo: reject ratelimit changes fix: reject ratelimit changes Oct 9, 2025
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a776840 and fcd184b.

📒 Files selected for processing (4)
  • go/apps/api/routes/v2_ratelimit_limit/handler.go (1 hunks)
  • go/internal/services/keys/validation.go (2 hunks)
  • go/internal/services/ratelimit/interface.go (1 hunks)
  • go/internal/services/ratelimit/service.go (4 hunks)

Comment on lines +243 to 286
for _, rwk := range reqsWithKeys {
bucket := bucketMap[rwk.key]

// Check limit with lock already held
res, err := s.checkBucketWithLockHeld(ctx, rwk.req, bucket, rwk.key)
if err != nil {
return nil, err
}
responses[rwk.index] = res

if !res.Success {
allPassed = false
for j := range i {
s.rollback(reqs[j])
}
span.SetAttributes(attribute.Bool("denied", true))
break
// Don't break - check all limits to return complete status
}
}

// If all passed, increment all counters (still holding locks!)
if allPassed {
span.SetAttributes(attribute.Bool("passed", true))
for _, rwk := range reqsWithKeys {
bucket := bucketMap[rwk.key]
currentWindow, _ := bucket.getCurrentWindow(rwk.req.Time)
currentWindow.counter += rwk.req.Cost

for _, req := range reqs {
s.replayBuffer.Buffer(req)
// Buffer for async replay to Redis
s.replayBuffer.Buffer(rwk.req)
}
} else {
// When batch fails, add back the cost since we're not consuming tokens
for i := range responses {
responses[i].Remaining += reqs[i].Cost
span.SetAttributes(attribute.Bool("denied", true))

// At least one failed - adjust remaining values
for i, res := range responses {
if res.Success {
responses[i].Remaining += reqs[i].Cost
}
}
}

// Clamp all Remaining values to 0 before returning
// Clamp all remaining values
for i := range responses {
responses[i].Remaining = max(0, responses[i].Remaining)
}

return responses, nil
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Ensure Current reflects actual bucket state after batch processing

When one or more requests pass in RatelimitMany, the Current value we return is inconsistent with the real bucket state:

  • In the “all passed” path, checkBucketWithLockHeld returns success responses whose Current either comes from effectiveCount (local branch) or from the pre-increment counter (origin branch). We increment the buckets later if every limit passes, but the response still contains the stale count (missing the just-applied cost).
  • In the rollback path (allPassed == false), we add the cost back to Remaining, yet the success entries keep the inflated Current that included the tentative cost even though we never incremented the bucket, so clients see tokens “consumed” that were actually rolled back.

RatelimitResponse.Current is documented as “how many tokens have been consumed in this window”; callers rely on that number to report usage. Returning stale or rolled-back values breaks that contract.

A straightforward fix is to normalize the responses after we know the final outcome: for every successful entry, read the bucket’s current window (while we still hold the locks) and set Current to currentWindow.counter, which now reflects the true persisted state (with the cost applied only when allPassed is true). Example:

@@
 	if allPassed {
 		span.SetAttributes(attribute.Bool("passed", true))
 		for _, rwk := range reqsWithKeys {
 			bucket := bucketMap[rwk.key]
 			currentWindow, _ := bucket.getCurrentWindow(rwk.req.Time)
 			currentWindow.counter += rwk.req.Cost
 			// Buffer for async replay to Redis
 			s.replayBuffer.Buffer(rwk.req)
 		}
 	} else {
 		span.SetAttributes(attribute.Bool("denied", true))
 
 		// At least one failed - adjust remaining values
 		for i, res := range responses {
 			if res.Success {
 				responses[i].Remaining += reqs[i].Cost
 			}
 		}
 	}
 
+	// Ensure successful responses report the actual bucket count after commit/rollback.
+	for _, rwk := range reqsWithKeys {
+		if responses[rwk.index].Success {
+			currentWindow, _ := bucketMap[rwk.key].getCurrentWindow(rwk.req.Time)
+			responses[rwk.index].Current = currentWindow.counter
+		}
+	}
+
 	// Clamp all remaining values
 	for i := range responses {
 		responses[i].Remaining = max(0, responses[i].Remaining)
 	}

This keeps the observable state consistent for both the fast path and the batch path. Please adjust accordingly.

Also applies to: 415-496

* refactor: deduplicate and make more maintaianable

* test: correct remaining counts

* fix: span attributes
@chronark chronark marked this pull request as ready for review October 9, 2025 10:48
@Flo4604 Flo4604 merged commit 7af2b69 into 10-07-test_rejected_requests_should_not_modify_ratelimit_state Oct 9, 2025
9 checks passed
@Flo4604 Flo4604 deleted the flo-reject-changes branch October 9, 2025 10:51
@coderabbitai coderabbitai bot mentioned this pull request Nov 12, 2025
19 tasks
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