e9fd51d119
- 修复:发码限流原为原子「判+记」,被单号 60s 冷却挡下的重发也占设备额度 → 正常用户连点重发可能被误锁 1 小时。改为「先判后记、只对成功发码计数」: check 判在真发之前(超限直接 429、不真发),record 只在 send_code 成功后调; 被单号冷却 / 供应商失败抛 429 时直接返回、不计数。 - 新增:同一设备(device_id)+ IP 每天最多 20 次发码上限,与原每小时 5 次两道闸并存, 均按成功计数,叠一层日封顶挡低频长时间轰炸。 - 基建:ratelimit.py 新增 RateLimitRule + check_rate_limits / record_rate_limits (peek/commit 拆分);原子的 enforce_rate_limit 仍保留给登录爆破(失败也计)不变。 - 测试:补 2 个用例(冷却挡下不占额度 / 每日上限)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: #136 Co-authored-by: zhuzihao <zhuzihao@wonderable.ai> Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
58 lines
2.6 KiB
Python
58 lines
2.6 KiB
Python
"""ratelimit 内存桶过期清理(GC)测试。
|
|
|
|
回归重点:_buckets 是**全局共享**、混着不同窗口(60s 广告 / 3600s 登录 / 86400s 日闸)的 key。
|
|
GC 必须按【每个 key 自己存的 window_sec】判过期,而不是当前调用方的窗口 —— 否则高频的 60s 端点
|
|
触发 GC 时会把本该存活更久的 3600s/86400s 计数(如短信日闸)一并删掉,使其被反复清零、限流失效。
|
|
用 monkeypatch 把 _GC_THRESHOLD 调 0 强制每次都扫,免造上万条(仿 test_auth 里对 sms._GC_THRESHOLD 的做法)。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from app.core import ratelimit
|
|
|
|
|
|
def test_purge_expired_respects_each_key_own_window(monkeypatch) -> None:
|
|
"""短窗口(60s)触发的 GC 只删真正过期的 key,不得删掉仍在自身窗口内的长窗口 key。"""
|
|
monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 0) # 强制每次都扫
|
|
ratelimit._buckets.clear()
|
|
|
|
now = 1_000_000.0
|
|
# 日闸:100s 前开窗、window=86400 → 远未过期,必须保留
|
|
ratelimit._buckets["sms-send-device-daily:D:IP"] = (now - 100, 7, 86400.0)
|
|
# 登录:1800s、window=3600 → 未过期,保留
|
|
ratelimit._buckets["sms-login-device:D:IP"] = (now - 1800, 2, 3600.0)
|
|
# 广告:120s、window=60 → 已过期,应删
|
|
ratelimit._buckets["ad-watch-report:IP2"] = (now - 120, 3, 60.0)
|
|
|
|
ratelimit._purge_expired(now)
|
|
|
|
assert "sms-send-device-daily:D:IP" in ratelimit._buckets
|
|
assert "sms-login-device:D:IP" in ratelimit._buckets
|
|
assert "ad-watch-report:IP2" not in ratelimit._buckets
|
|
|
|
|
|
def test_purge_expired_keeps_long_window_key_older_than_short_window(monkeypatch) -> None:
|
|
"""反证旧 bug:日闸 key 已老于 3600s,旧代码在 60s/3600s 端点触发 GC 时会误删它;
|
|
现在按自身 86400s 窗口判 → 未过期 → 必须保留。"""
|
|
monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 0)
|
|
ratelimit._buckets.clear()
|
|
|
|
now = 2_000_000.0
|
|
# 3700s 前开窗(> 1 小时),但 window=86400 → 未过期
|
|
ratelimit._buckets["sms-send-device-daily:D:IP"] = (now - 3700, 20, 86400.0)
|
|
|
|
ratelimit._purge_expired(now)
|
|
|
|
assert "sms-send-device-daily:D:IP" in ratelimit._buckets
|
|
|
|
|
|
def test_purge_expired_noop_below_threshold(monkeypatch) -> None:
|
|
"""未超阈值时不扫(即便有过期 key 也不动),避免每次请求都 O(n) 扫全表。"""
|
|
monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 10)
|
|
ratelimit._buckets.clear()
|
|
|
|
now = 3_000_000.0
|
|
ratelimit._buckets["stale:IP"] = (now - 999, 1, 60.0) # 早过期,但没超阈值
|
|
ratelimit._purge_expired(now)
|
|
|
|
assert "stale:IP" in ratelimit._buckets # 桶数没超阈值 → 不清理
|