"""限流原子计数共享 helper。 封装 ``CachePort.incr + expire`` 原子限流范式,消除 ``get/set`` 非原子 读改写导致的高并发超额放行(C-C1)。供 ``rate_limit_stage`` 与 ``admin_message_service`` 复用,避免重复实现错误范式。 故障语义(与 ``health_check_service._checkProbeRateLimit`` 对齐): - ``incr`` 失败(``DependencyError``)→ fail-closed:请求尚未计数,翻译 为 ``RateLimitError`` 拒绝,避免 Redis 故障期间限流失效放行超额请求。 - ``expire`` 失败(``DependencyError``)→ fail-fast:请求已计数,直接 上抛 ``DependencyError`` 暴露基础设施故障(INV-7),不在此处吞异常—— 窗口未设置会导致计数永久累积,限流失效。 - 首次递增(``count == 1``)设置窗口 TTL(对应 Redis ``EXPIRE``,只设 TTL 不修改值,消除"首次 ``set`` 覆盖并发 ``incr`` 计数"的竞态)。 """ from __future__ import annotations from yuxi.channels.contract.errors import DependencyError, RateLimitError from yuxi.channels.contract.ports.driven.cache_port import CachePort from yuxi.channels.contract.ports.driven.logger_port import LoggerPort __all__ = ["RateLimitChecker"] class RateLimitChecker: """原子限流计数器,封装 ``incr + expire`` 范式。 单一职责:基于 ``CachePort`` 原子操作校验单 key 计数是否超限。不负责 key 构造与阈值配置(由调用方传入),``resource`` 字段标识限流作用域。 用法:: checker = RateLimitChecker(cache_port, logger) await checker.check( resource=f"control-plane:{user_id}:{ip}", cache_key=f"rate_limit:{user_id}:{ip}", max_requests=100, window_seconds=60, retry_after_ms=60_000, trace_id=trace_id, ) """ def __init__(self, cache_port: CachePort, logger: LoggerPort | None = None) -> None: """初始化限流计数器。 参数: cache_port: 缓存被驱动端口,提供 ``incr`` / ``expire`` 原子操作。 logger: 日志端口(可选),``incr`` 故障 fail-closed 时记录告警。 """ self._cache = cache_port self._logger = logger async def check( self, *, resource: str, cache_key: str, max_requests: int, window_seconds: int, retry_after_ms: int, trace_id: str | None = None, ) -> None: """校验限流计数是否超限,超限或缓存故障时抛出。 参数: resource: 限流资源标识(写入 ``RateLimitError.resource``)。 cache_key: 缓存计数 key。 max_requests: 窗口内最大请求数(``count > max_requests`` 拒绝)。 window_seconds: 限流窗口秒数(首次递增时设置 TTL)。 retry_after_ms: 建议重试等待毫秒。 trace_id: 追踪 ID(写入异常上下文与日志)。 抛出: RateLimitError: 请求频率超限,或 ``incr`` 缓存故障时 fail-closed。 DependencyError: ``expire`` 缓存故障时 fail-fast(请求已计数)。 """ try: count = await self._cache.incr(cache_key) except DependencyError as exc: # incr 失败时请求尚未计数,fail-closed 翻译为 RateLimitError # 拒绝(与 health_check_service._checkProbeRateLimit 一致,INV-7), # 避免 Redis 故障期间限流失效放行超额请求。 if self._logger is not None: await self._logger.error( "rate_limit cache failure, fail-closed reject", trace_id=trace_id, cache_key=cache_key, error=str(exc), ) raise RateLimitError( resource=resource, retry_after_ms=retry_after_ms, trace_id=trace_id, ) from exc # 首次递增(count == 1)设置 TTL 窗口。expire 只设过期时间不修改值, # 避免 set 覆盖高并发期间其他请求的 incr 计数。 # # 故障策略与 incr 不同:expire 失败时请求已计数,fail-fast 直接抛 # DependencyError 暴露基础设施故障(INV-7),不吞异常——窗口未设置 # 会导致计数永久累积,限流失效。 if count == 1: await self._cache.expire(cache_key, window_seconds) if count > max_requests: raise RateLimitError( resource=resource, retry_after_ms=retry_after_ms, trace_id=trace_id, )