Skip to content

fix: make standalone ratelimiting namespace aware#3819

Merged
Flo4604 merged 3 commits intomainfrom
fix/bucket-identifier
Aug 21, 2025
Merged

fix: make standalone ratelimiting namespace aware#3819
Flo4604 merged 3 commits intomainfrom
fix/bucket-identifier

Conversation

@Flo4604
Copy link
Member

@Flo4604 Flo4604 commented Aug 20, 2025

What does this PR do?

This fixes a case where the same rate-limit bucket could be used, even though it shouldn't.

type bucketKey struct {
	// identifier is the rate limit subject (user ID, API key, etc)
	identifier string

	// limit is the maximum requests allowed in the duration
	limit int64

	// duration is the time window for the rate limit
	duration time.Duration
}

func (b bucketKey) toString() string {
	return fmt.Sprintf("%s-%d-%d", b.identifier, b.limit, b.duration.Milliseconds())
}

A bucket key is a "unique" key of identifier-limit-duration

If I have namespace A and namespace B, and I rate-limit the identifier test-123, this works fine because in 99% of all cases, I will have different durations/limits set for them. Obviously, this is not 100% guaranteed.

This becomes more likely with rate-limit overrides.
If you have two namespaces with different rate limits but you override the same identifier to have a new limit and duration that are the same in both namespaces, it will be the same limit, even though it's a different action.


EDIT:
After thinking about it, why do we consider the duration and limit for a unique bucket?

If I change the rate limit, wouldn't it make sense to keep whatever usage we have already recorded?

For example, I have a "duration": 300000 and a limit of 15.
Now, if I change the limit to 10, this is a completely new limit, yet I still have 9 remaining, even though I could have exceeded the limit before.
Just as food for thought.


Example:

curl --silent --request POST \
  --url https://api.unkey.com/v2/ratelimit.limit \
  --header 'Authorization: Bearer <ROOT_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{
  "namespace": "test",
  "cost": 1,
  "identifier": "user-1",
  "limit": 15,
  "duration": 300000
}' 
{
  "data": {
    "limit": 15,
    "remaining": 14,
    "reset": 1755762600000,
    "success": true
  },
  "meta": {
    "requestId": "req_4dze2QH3xbY3G6qB"
  }
}
curl --silent --request POST \
  --url https://api.unkey.com/v2/ratelimit.limit \
  --header 'Authorization: Bearer <ROOT_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{
  "namespace": "test2",
  "cost": 1,
  "identifier": "user-1",
  "limit": 15,
  "duration": 300000
}
{
  "data": {
    "limit": 15,
    "remaining": 13,
    "reset": 1755762600000,
    "success": true
  },
  "meta": {
    "requestId": "req_4dze6HdXWTurR3Gx"
  }
}

As you can see in this example the second request started of at remaining 13 instead of 14, which it should have been instead.

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?

Use the standalone ratelimiting API with different namespaces to see that the same identifier-limit-duration combination is treated as a different limit per namespace and not one globally.

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

Summary by CodeRabbit

  • Bug Fixes
    • Rate limiting now scopes identifiers to their namespace, preventing unintended throttling or collisions when the same identifier is used in different namespaces.

@changeset-bot
Copy link

changeset-bot bot commented Aug 20, 2025

⚠️ No Changeset found

Latest commit: 50776cb

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 Aug 20, 2025

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

Project Deployment Preview Comments Updated (UTC)
dashboard Ready Ready Preview Comment Aug 21, 2025 1:03pm
1 Skipped Deployment
Project Deployment Preview Comments Updated (UTC)
engineering Ignored Ignored Preview Aug 21, 2025 1:03pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 20, 2025

📝 Walkthrough

Walkthrough

Handler now prefixes the rate-limit lookup key with the namespace ID by concatenating namespace.ID and the incoming request identifier; no other fields, control flow, or exported signatures were changed.

Changes

Cohort / File(s) Change summary
Rate-limit identifier composition
go/apps/api/routes/v2_ratelimit_limit/handler.go
Added fmt import; LimitRequest.Identifier changed to fmt.Sprintf("%s:%s", namespace.ID, req.Identifier) to form a composite lookup key; no other logic, error handling, or exported API changes.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Handler
    participant NamespaceSvc as Namespace
    participant RateLimiter

    Client->>Handler: v2 rate-limit request (req.Identifier)
    Handler->>Namespace: resolve namespace (namespace.ID)
    Namespace-->>Handler: namespace.ID
    Note right of Handler: compose key = namespace.ID + ":" + req.Identifier
    Handler->>RateLimiter: lookup/check using composite key
    RateLimiter-->>Handler: limit result
    Handler-->>Client: response
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Suggested labels

Bug, Needs Approval

Suggested reviewers

  • perkinsjr
  • chronark
  • imeyer

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/bucket-identifier

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
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

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

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@Flo4604 Flo4604 changed the title fix: make ratelimiting namespace aware fix: make standalone ratelimiting namespace aware Aug 20, 2025
@vercel vercel bot temporarily deployed to Preview – engineering August 20, 2025 16:23 Inactive
@vercel vercel bot temporarily deployed to Preview – dashboard August 20, 2025 16:23 Inactive
@Flo4604 Flo4604 marked this pull request as ready for review August 21, 2025 07:50
@github-actions
Copy link
Contributor

github-actions bot commented Aug 21, 2025

Thank you for following the naming conventions for pull request titles! 🙏

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
go/apps/api/routes/v2_ratelimit_limit/handler.go (3)

184-189: Nit: prefer simple concatenation over fmt.Sprintf and document rationale.

Sprintf allocates; in a hot path like ratelimiting, a simple concat is cheaper and clearer. Also, add a short AIDEV-RATIONALE comment to prevent regressions.

Apply this diff:

-        Identifier: fmt.Sprintf("%s:%s", namespace.ID, req.Identifier),
+        // AIDEV-RATIONALE: Include namespace ID to ensure per-namespace isolation of standalone buckets.
+        Identifier: namespace.ID + ":" + req.Identifier,

241-259: Overrides remain keyed by raw identifier — add tests to lock intent.

Because overrides are stored per-namespace and matched against the unscoped identifier, behavior remains correct. Add tests asserting:

  • Same identifier hits different limits in two namespaces with different overrides.
  • Wildcard override in one namespace does not affect another.

I can help scaffold these if useful.


183-189: Rollout note: key format change can cause split-buckets during rolling deploys.

Mixed-version nodes will write to different bucket keys (“identifier-…” vs “nsID:identifier-…”), temporarily doubling capacity. If this endpoint is served by multiple instances, consider:

  • Fast/atomic deploy or brief maintenance window, or
  • A short-lived feature flag (e.g., UNKEY_API_RATELIMIT_NAMESPACE_SCOPED) to flip on simultaneously, or
  • Accepting transient leniency given bucket TTL equals duration.
📜 Review details

Configuration used: CodeRabbit UI
Review profile: ASSERTIVE
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 672c2e4 and fbbb78a.

📒 Files selected for processing (1)
  • go/apps/api/routes/v2_ratelimit_limit/handler.go (2 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.go

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.go: Follow comprehensive documentation guidelines for Go code as described in go/GO_DOCUMENTATION_GUIDELINES.md
Every public function/type in Go code must be documented
Prefer interfaces for testability in Go code
Use AIDEV-* comments for complex/important code in Go services

Files:

  • go/apps/api/routes/v2_ratelimit_limit/handler.go
**/*.{env,js,ts,go}

📄 CodeRabbit inference engine (CLAUDE.md)

All environment variables must follow the format: UNKEY_<SERVICE_NAME>_VARNAME

Files:

  • go/apps/api/routes/v2_ratelimit_limit/handler.go
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Build / Build
  • GitHub Check: Test Go API Local / Test
  • GitHub Check: Test API / API Test Local
🔇 Additional comments (2)
go/apps/api/routes/v2_ratelimit_limit/handler.go (2)

184-189: Namespace-scoped key eliminates cross-namespace bucket reuse (correct).

Scoping the ratelimit Identifier with namespace.ID is the right fix and uses an immutable identifier rather than the namespace name. This directly prevents bucket collisions when identifier/limit/duration tuples are identical across namespaces and carries no API surface change.


210-219: Verify namespace-aware grouping for ratelimit metrics

I’ve confirmed that the RatelimitRequestV1 struct and buffer call include both NamespaceID and Identifier, and the raw ClickHouse table has corresponding columns, but you’ll still need to audit every downstream query and dashboard for proper grouping:

  • In go/pkg/clickhouse/schema/requests.go, the struct has both fields defined:
    type RatelimitRequestV1 struct {
        …  
        NamespaceID string `ch:"namespace_id" json:"namespace_id"`
        Identifier  string `ch:"identifier" json:"identifier"`
        …  
    }
  • The handler (handler.go:210–219) buffers with both NamespaceID: namespace.ID and Identifier: req.Identifier.
  • Tests in v2_ratelimit_limit/200_test.go explicitly select with WHERE workspace_id = … AND namespace_id = …, ensuring namespace filtering in smoke tests.
  • The ClickHouse client writes straight to ratelimits.raw_ratelimits_v1 with no transformation beyond buffering those fields.

Next steps:

  • Manually audit any ETL pipelines, materialized views, or SQL dashboards to ensure they always include both namespace_id and identifier (or otherwise partition by namespace) when aggregating.
  • Verify that no reporting queries group solely on identifier, which would cause cross-namespace metric contamination.

Copy link
Contributor

@ogzhanolguncu ogzhanolguncu left a comment

Choose a reason for hiding this comment

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

I tested it against main first and it actually worked as you described, different namespaces used the same quota. Then, switched to your branch everything worked as expected. LFG!

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
go/apps/api/routes/v2_ratelimit_limit/handler.go (1)

71-73: Optional: fail fast on missing namespace input.

Currently an empty req.Namespace falls through to a DB lookup and later returns NotFound. Consider an explicit validation error (400) for better UX and cheaper path.

I can draft a small patch with your preferred fault code for invalid arguments if you share the canonical code constant.

♻️ Duplicate comments (1)
go/apps/api/routes/v2_ratelimit_limit/handler.go (1)

3-25: Previous nit resolved: no unused fmt import.

The earlier comment about removing an unused fmt import no longer applies; the import is absent now.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: ASSERTIVE
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between fbbb78a and f0dfdf0.

📒 Files selected for processing (1)
  • go/apps/api/routes/v2_ratelimit_limit/handler.go (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.go

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.go: Follow comprehensive documentation guidelines for Go code as described in go/GO_DOCUMENTATION_GUIDELINES.md
Every public function/type in Go code must be documented
Prefer interfaces for testability in Go code
Use AIDEV-* comments for complex/important code in Go services

Files:

  • go/apps/api/routes/v2_ratelimit_limit/handler.go
**/*.{env,js,ts,go}

📄 CodeRabbit inference engine (CLAUDE.md)

All environment variables must follow the format: UNKEY_<SERVICE_NAME>_VARNAME

Files:

  • go/apps/api/routes/v2_ratelimit_limit/handler.go
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: Test API / API Test Local
  • GitHub Check: Build / Build
  • GitHub Check: Test Go API Local / Test
  • GitHub Check: Analyze (javascript-typescript)
🔇 Additional comments (3)
go/apps/api/routes/v2_ratelimit_limit/handler.go (3)

183-183: LGTM: Namespacing the identifier fixes cross-namespace bucket collisions.

Prefixing the identifier with namespace.ID aligns the bucket key with namespace scope and matches the PR objective. No regressions apparent in overrides, permissions, or metrics emission.


209-218: Confirm intended metrics semantics: raw identifier vs namespaced identifier.

Telemetry buffers Identifier: req.Identifier while scoping is added only when calling the ratelimiter. That’s likely fine since NamespaceID is recorded separately, but double-check any consumers that group by “Identifier” alone—those dashboards could mix namespaces.

If grouping-by-identifier alone exists, consider emitting both fields or updating consumers to group by (NamespaceID, Identifier).


181-188: Confirm namespace prefix on all RatelimitRequest constructions

Please review the following call sites and ensure that their Identifier values include the proper namespace.ID prefix (or are otherwise guaranteed to be namespaced upstream):

• go/internal/services/keys/validation.go:196

response, err := k.rateLimiter.Ratelimit(ctx, ratelimit.RatelimitRequest{
    Identifier: config.Identifier, // verify whether config.Identifier already includes namespace.ID
})

• apps/agent/pkg/api/routes/v1_ratelimit_multiRatelimit/handler.go:29

ratelimits[i] = &ratelimitv1.RatelimitRequest{
    Identifier: r.Identifier, // needs namespace.ID + ":" + r.Identifier?
}

• apps/agent/pkg/api/routes/v1_ratelimit_ratelimit/handler.go:38

res, err := svc.Ratelimit.Ratelimit(ctx, &ratelimitv1.RatelimitRequest{
    Identifier: req.Identifier, // should this be prefixed?
})

Also confirm that internal bucket-key generation (bucketKey{req.Identifier,…} and its toString()) only uses the identifier as supplied, to avoid double-prefixing.

@vercel vercel bot temporarily deployed to Preview – dashboard August 21, 2025 13:03 Inactive
Copy link
Collaborator

@chronark chronark left a comment

Choose a reason for hiding this comment

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

A different duration would also result in a new counter, cause the duration is used to determine the window sequence.
So in a way it is redundant anyways

I don't have a good reason for having the limit in the key. My thinking was that if I edit it, a reset would be fine.

@graphite-app
Copy link

graphite-app bot commented Aug 21, 2025

Sports gif. A WWE official avatar swings a rubbery arm around and gives an elongated thumbs up. (Added via Giphy)

@Flo4604 Flo4604 added this pull request to the merge queue Aug 21, 2025
@graphite-app
Copy link

graphite-app bot commented Aug 21, 2025

Graphite Automations

"Post a GIF when PR approved" took an action on this PR • (08/21/25)

1 gif was posted to this PR based on Andreas Thomas's automation.

@github-merge-queue github-merge-queue bot removed this pull request from the merge queue due to failed status checks Aug 21, 2025
@Flo4604 Flo4604 added this pull request to the merge queue Aug 21, 2025
@github-merge-queue github-merge-queue bot removed this pull request from the merge queue due to failed status checks Aug 21, 2025
@Flo4604 Flo4604 added this pull request to the merge queue Aug 21, 2025
Merged via the queue into main with commit f66a269 Aug 21, 2025
17 checks passed
@Flo4604 Flo4604 deleted the fix/bucket-identifier branch August 21, 2025 16:13
@coderabbitai coderabbitai bot mentioned this pull request Aug 21, 2025
18 tasks
@coderabbitai coderabbitai bot mentioned this pull request Nov 13, 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.

3 participants