45073486a7
A fail-closed limiter or breaker backend means the scope cannot emit a single request, which is exactly scope-level unavailability. But the error sat directly under PolyGatewayError, so a caller writing only `except GatewayUnavailableError` dropped it into the catch-all branch: Redis blips once and a backlog of tasks burns its business failure budget into the dead letter queue, over a fault a restart would clear. Three gate paths leak to callers rather than being absorbed by _record_quietly (try_acquire, try_enter, progress_age_s); each is now pinned by a test, since none of them had one before. The two unknown-source sites move to SourceNotConfiguredError instead of following along. They report a misconfigured source name, not an outage, and letting them into the retryable family would be the mirror of the bug being fixed here: the task would retry forever and never surface.
101 lines
3.6 KiB
Python
101 lines
3.6 KiB
Python
"""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):
|
|
"""未知源是装配缺陷,不是后端故障(issue #7 §3.4)。"""
|
|
from polygateway.errors import GatewayUnavailableError, SourceNotConfiguredError
|
|
|
|
with pytest.raises(SourceNotConfiguredError) as ei:
|
|
_limiter()._cfg("nope")
|
|
# 关键: 若归入 scope 级家族,配置写错的任务会永远延期重投、永不进死信
|
|
assert not isinstance(ei.value, GatewayUnavailableError)
|
|
|
|
|
|
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
|