feat: add redis six-gate rate limiter backend
This commit is contained in:
+88
-14
@@ -1,18 +1,30 @@
|
||||
"""契约测试共享 fixture: 后端参数化(M1 仅 memory,M2 增 redis 零改测试)。
|
||||
"""契约测试共享 fixture: 后端参数化(memory + redis,D3 双后端同一契约)。
|
||||
|
||||
FakeClock 仅对支持时钟注入的后端有效(memory);M2 接入 Redis 后端时,
|
||||
依赖时钟推进的用例按后端能力跳过或改用真实等待。
|
||||
结构(M2 设计 §2.3 定案): 单一 `backend` fixture 承载参数化;`clock` 与两工厂
|
||||
都依赖它——memory 用 FakeClock(时钟注入,时间语义可快进);redis 用真实实验室
|
||||
Redis(db3),FakeClock 对服务器时钟不可注入,故 `clock.advance()` 是哨兵:
|
||||
触发 skip,对应用例由 tests/integration/test_redis_governance_time.py 的
|
||||
1:1 真实等待变体覆盖(人类拍板: 不缩放时长)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from dotenv import dotenv_values
|
||||
|
||||
from polygateway.backends.memory.breaker import InMemoryGate
|
||||
from polygateway.backends.memory.limiter import InMemoryLimiter
|
||||
from polygateway.types import BreakerConfig, GlobalLimits, SourceConfig
|
||||
|
||||
_HEADROOM_S = 10 # 分钟窗口剩余不足则等翻滚,防 RPM/TPM 用例跨窗 flake(CHS 同款)
|
||||
|
||||
|
||||
class FakeClock:
|
||||
"""确定性单调时钟;契约测试推进时间验证租约/冷却语义。"""
|
||||
"""确定性单调时钟;memory 后端的契约测试推进时间验证租约/冷却语义。"""
|
||||
|
||||
def __init__(self, start: float = 1000.0) -> None:
|
||||
self.t = start
|
||||
@@ -24,6 +36,16 @@ class FakeClock:
|
||||
self.t += seconds
|
||||
|
||||
|
||||
class SkipClock:
|
||||
"""redis 后端的哨兵时钟: 服务器时钟不可注入,依赖快进的用例整例跳过。"""
|
||||
|
||||
def __call__(self) -> float: # pragma: no cover - 不应被消费
|
||||
raise AssertionError("redis 后端不消费注入时钟")
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
pytest.skip("redis 时间语义由 tests/integration/test_redis_governance_time.py 变体覆盖")
|
||||
|
||||
|
||||
def make_source(name: str = "s1", **overrides) -> SourceConfig:
|
||||
base = {
|
||||
"name": name,
|
||||
@@ -37,32 +59,84 @@ def make_source(name: str = "s1", **overrides) -> SourceConfig:
|
||||
return SourceConfig(**base)
|
||||
|
||||
|
||||
def redis_url_from_env() -> str | None:
|
||||
"""读 REDIS_URL(.env 与进程环境合并,后者优先);供契约与集成测试共用。"""
|
||||
merged = {**dotenv_values(".env"), **os.environ}
|
||||
return merged.get("REDIS_URL") or None
|
||||
|
||||
|
||||
async def await_window_headroom(client, min_headroom_s: int = _HEADROOM_S) -> None:
|
||||
"""按 Redis 服务器时钟等待分钟窗口翻滚防抖(CHS test_redis_limiter.py:17-30)。"""
|
||||
sec, _ = await client.time()
|
||||
remaining = 60 - int(sec) % 60
|
||||
if remaining < min_headroom_s:
|
||||
await asyncio.sleep(remaining + 0.5)
|
||||
|
||||
|
||||
class _Backend:
|
||||
def __init__(self, name: str, redis=None) -> None:
|
||||
self.name = name
|
||||
self.redis = redis
|
||||
|
||||
|
||||
@pytest.fixture(params=["memory", "redis"])
|
||||
async def backend(request):
|
||||
if request.param == "memory":
|
||||
yield _Backend("memory")
|
||||
return
|
||||
url = redis_url_from_env()
|
||||
if url is None:
|
||||
pytest.skip("REDIS_URL 未配置,跳过 redis 后端契约")
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
client = aioredis.from_url(url)
|
||||
try:
|
||||
await await_window_headroom(client)
|
||||
yield _Backend("redis", client)
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock() -> FakeClock:
|
||||
return FakeClock()
|
||||
def clock(backend):
|
||||
return FakeClock() if backend.name == "memory" else SkipClock()
|
||||
|
||||
|
||||
@pytest.fixture(params=["memory"])
|
||||
def limiter_factory(request, clock):
|
||||
@pytest.fixture
|
||||
def limiter_factory(backend, clock):
|
||||
"""返回 (sources, global_limits, lease_ttl_s) -> RateLimiter 的工厂。"""
|
||||
|
||||
def make(sources: list[SourceConfig], global_limits: GlobalLimits, lease_ttl_s: float = 100.0):
|
||||
return InMemoryLimiter(
|
||||
scope="llm",
|
||||
if backend.name == "memory":
|
||||
return InMemoryLimiter(
|
||||
scope="llm",
|
||||
sources={s.name: s for s in sources},
|
||||
global_limits=global_limits,
|
||||
lease_ttl_s=lease_ttl_s,
|
||||
now=clock,
|
||||
)
|
||||
from polygateway.backends.redis.limiter import RedisLimiter
|
||||
|
||||
return RedisLimiter(
|
||||
scope=f"t{uuid4().hex[:8]}",
|
||||
sources={s.name: s for s in sources},
|
||||
global_limits=global_limits,
|
||||
redis=backend.redis,
|
||||
lease_ttl_s=lease_ttl_s,
|
||||
now=clock,
|
||||
)
|
||||
|
||||
return make
|
||||
|
||||
|
||||
@pytest.fixture(params=["memory"])
|
||||
def gate_factory(request, clock):
|
||||
@pytest.fixture
|
||||
def gate_factory(backend, clock):
|
||||
"""返回 (BreakerConfig) -> ProviderGate 的工厂。"""
|
||||
|
||||
def make(config: BreakerConfig):
|
||||
return InMemoryGate(config=config, now=clock)
|
||||
if backend.name == "memory":
|
||||
return InMemoryGate(config=config, now=clock)
|
||||
from polygateway.backends.redis.breaker import RedisGate
|
||||
|
||||
return RedisGate(config=config, redis=backend.redis, scope=f"t{uuid4().hex[:8]}")
|
||||
|
||||
return make
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""RedisLimiter 纯函数部分(key 布局、秒毫秒换算)——不需要真实 Redis。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from polygateway.backends.redis.limiter import RedisLimiter
|
||||
from polygateway.types import GlobalLimits, SourceConfig
|
||||
|
||||
|
||||
class _StubRedis:
|
||||
"""仅满足构造期 register_script 的桩;任何执行路径不可达。"""
|
||||
|
||||
def register_script(self, script: str):
|
||||
def _never(**kwargs):
|
||||
raise AssertionError("unit 测试不应执行 Lua")
|
||||
|
||||
return _never
|
||||
|
||||
|
||||
def _limiter(**overrides) -> RedisLimiter:
|
||||
src = SourceConfig(
|
||||
name="s1",
|
||||
provider="p",
|
||||
base_url="https://gw.example/v1",
|
||||
api_key="sk",
|
||||
model="m",
|
||||
timeout_s=10.0,
|
||||
)
|
||||
base = {
|
||||
"scope": "llm",
|
||||
"sources": {"s1": src},
|
||||
"global_limits": GlobalLimits(max_concurrency=0, rpm=0, tpm=0),
|
||||
"redis": _StubRedis(),
|
||||
"lease_ttl_s": 30.0,
|
||||
}
|
||||
base.update(overrides)
|
||||
return RedisLimiter(**base)
|
||||
|
||||
|
||||
class TestKeyLayout:
|
||||
def test_lease_keys_prefixed_pgw(self):
|
||||
gl, sl = _limiter()._lease_keys("s1")
|
||||
assert gl == "pgw:limit:GLOBAL:llm:lease"
|
||||
assert sl == "pgw:limit:llm:s1:lease"
|
||||
|
||||
def test_window_keys_carry_window_suffix(self):
|
||||
wk = _limiter()._window_keys("s1", 12345)
|
||||
assert wk["g_rpm"] == "pgw:limit:GLOBAL:llm:rpm:12345"
|
||||
assert wk["s_rpm"] == "pgw:limit:llm:s1:rpm:12345"
|
||||
assert wk["g_tpm"] == "pgw:limit:GLOBAL:llm:tpm:12345"
|
||||
assert wk["s_tpm"] == "pgw:limit:llm:s1:tpm:12345"
|
||||
|
||||
def test_progress_key_scope_global(self):
|
||||
assert _limiter()._progress_key() == "pgw:limit:GLOBAL:llm:progress_ms"
|
||||
|
||||
def test_scope_lowercased(self):
|
||||
gl, _ = _limiter(scope="LLM")._lease_keys("s1")
|
||||
assert gl == "pgw:limit:GLOBAL:llm:lease"
|
||||
|
||||
|
||||
class TestConversions:
|
||||
def test_lease_ttl_seconds_to_ms(self):
|
||||
# 契约量纲为秒,Redis 内部毫秒是后端私事(ports.py docstring)
|
||||
assert _limiter(lease_ttl_s=30.0)._lease_ttl_ms == 30_000
|
||||
assert _limiter(lease_ttl_s=0.5)._lease_ttl_ms == 500
|
||||
|
||||
def test_invalid_lease_ttl_rejected(self):
|
||||
with pytest.raises(ValueError):
|
||||
_limiter(lease_ttl_s=0)
|
||||
|
||||
def test_unknown_source_rejected(self):
|
||||
from polygateway.errors import GovernanceBackendError
|
||||
|
||||
with pytest.raises(GovernanceBackendError):
|
||||
_limiter()._cfg("nope")
|
||||
|
||||
|
||||
class TestLuaFidelity:
|
||||
"""Lua 常量的移植锚点:守卫与判据语义(逐段比对 CHS scripts.py:6-34)。"""
|
||||
|
||||
def test_acquire_has_zero_disabled_guards(self):
|
||||
"""0=闸不启用 是对 CHS 的有意偏离(设计 §9 勘误):每道闸带 limit>0 守卫。"""
|
||||
from polygateway.backends.redis.limiter import ACQUIRE
|
||||
|
||||
assert ACQUIRE.count("> 0 and") == 6
|
||||
|
||||
def test_acquire_keeps_chs_gate_operators(self):
|
||||
"""并发/RPM 用 >=(占后即满),TPM 用 + est >(预扣后是否超)——CHS 同款。"""
|
||||
from polygateway.backends.redis.limiter import ACQUIRE
|
||||
|
||||
assert ACQUIRE.count(">=") == 4
|
||||
assert ACQUIRE.count("+ est >") == 2
|
||||
|
||||
def test_settle_lands_on_acquire_window(self):
|
||||
"""SETTLE 的 key 由 Python 侧按 acquire 时窗口生成;Lua 只做 INCRBY。"""
|
||||
from polygateway.backends.redis.limiter import SETTLE
|
||||
|
||||
assert "INCRBY" in SETTLE and "TIME" not in SETTLE
|
||||
Reference in New Issue
Block a user