Skip to content

fix(rate-limit): namespace Redis buckets so limiters stop sharing a window - #22

Merged
oratis merged 1 commit into
mainfrom
claude/xenodochial-neumann-d9a2cb
Aug 9, 2026
Merged

fix(rate-limit): namespace Redis buckets so limiters stop sharing a window#22
oratis merged 1 commit into
mainfrom
claude/xenodochial-neumann-d9a2cb

Conversation

@oratis

@oratis oratis commented Aug 9, 2026

Copy link
Copy Markdown
Owner

背景

#15 给每个 in-process rateLimit() 实例分配了独立的 bucket 命名空间。但 server/rate-limit.jsREDIS_URL 存在时会透明切换redis-rate-limit.js,而后者把 sorted set 的 key 写成 prefix + rawKey完全忽略 name 选项

结果:#15 只修好了单副本路径。只要配了 Redis,生产环境仍然是老样子 —— auth (10/min)、discovery (5/min)、export (10/min)、sendEmail (20/min) 共用同一个 IP 窗口:

  • 5 次 CSV 导出就能把该 IP 的 discovery 额度耗尽
  • 10 次登录尝试会把导出锁死
  • 每个端点各自的 max 形同虚设,最紧的那个实际上统治了全部四个

consume() 有同样的问题。在 Redis 路径上它和 middleware 都没有命名空间,所以 batch-send 的预留是碰巧落进了正确的 bucket —— 同时也和其他所有限流器共享这个 bucket。

改动

server/redis-rate-limit.js —— 镜像 rate-limit.js 的命名空间实现:

  • rateLimit() 接受 name,缺省回退到位置式 rl<n>
  • consume() 接受 name(默认 'shared'),与 rate-limit.js 契约一致
  • 两者都按 prefix + name|key 建 key

未命名限流器现在会 warn 一次(每进程)。 位置式名字在 in-process 下是安全的(bucket 是本地的),但在 Redis 下 bucket 跨副本共享 —— 两个副本对命名空间不一致时,会静默地把一个逻辑限流器拆成两个 bucket,实际上限翻倍。它只在"所有限流器都在模块加载时无条件、同序构造"的前提下成立。

server/index.js —— 给 inviteLookupLimiter 加上 name,这是最后一个未命名的限流器。六个全部显式命名后,上面那条 warning 今天永远不会触发。

测试桩 bug —— zadd 只接受 (key, score, member) 三参,但 consume() 是变参调用、一次塞 n 张票。桩把第一张之后的票全部静默丢弃了,这会让一个 50 收件人的批量看起来只花 1 张票。

验证

6 个新测试。为确认它们不是同义反复,我把 namespaced() 临时改回 no-op 跑了一遍 —— 4 个失败,包括 discovery-5 / auth-10 那个真实配置场景:

✖ isolation: two limiters with the default keyFn do not share a window
✖ isolation: the tightest limiter no longer governs the others
✖ isolation: the bucket key carries the namespace
✖ isolation: an unnamed consume() cannot silently drain a named limiter
  • npm test 662/662 通过(原 656)
  • node --check 三个文件均干净

部署注意

key 形状变了,已有的 Redis bucket 在部署后会被孤立。无害 —— 最坏情况是少数用户拿到一个新窗口。

未包含

docs/memory.md 的"已关闭"表把限流器桶隔离记为 #15 已解决,实际只对了一半。这次没动,需要的话可以补一行修正。

🤖 Generated with Claude Code

…indow

#15 gave every in-process rateLimit() instance its own bucket namespace, but
rate-limit.js transparently swaps to redis-rate-limit.js whenever REDIS_URL is
set — and that module keyed its sorted set on `prefix + rawKey`, ignoring the
`name` option outright. So the fix only ever covered single-replica
deployments. With Redis configured, auth (10/min), discovery (5/min), export
(10/min) and sendEmail (20/min) still drew from ONE window per IP: five CSV
exports exhausted the discovery limiter for that IP, and the tightest max
effectively governed all four.

consume() had the same gap. On Redis both it and the middleware were
un-namespaced, so batch-send's reservation landed in the right bucket by
accident, while sharing that bucket with every other limiter.

- redis-rate-limit.js: mirror rate-limit.js's namespacing — rateLimit() takes
  `name` (positional `rl<n>` fallback), consume() takes `name` (default
  'shared'), both key on `prefix + name|key`
- warn once per process when a Redis limiter is built unnamed: positional
  names are safe in-process but only agree across replicas while limiter
  construction order is identical, and a disagreement silently splits one
  limiter into two buckets (doubling the effective cap)
- index.js: name inviteLookupLimiter, the last unnamed limiter — all six are
  now explicit, so the warning never fires today
- test stub: zadd only accepted (key, score, member), but consume() calls it
  variadically with n tickets. It was silently dropping every ticket past the
  first, which made a 50-recipient batch look like it cost one ticket

Note: the key shape changes, so existing Redis buckets are orphaned on deploy.
Harmless — worst case a few users get a fresh window.

Verified by reverting namespaced() to a no-op: 4 of the 6 new tests fail
against pre-fix behaviour, including the discovery-5/auth-10 scenario.
npm test 662/662 green (was 656). node --check clean on all three files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oratis
oratis merged commit 424bb53 into main Aug 9, 2026
5 checks passed
@oratis
oratis deleted the claude/xenodochial-neumann-d9a2cb branch August 9, 2026 15:07
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.

1 participant