Files
group_fqcd_jr/app/infrastructure/rate_limiter.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

97 lines
3.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""限流计数后端:Redis 固定窗口计数,**Redis 不可用时一律放行**。
取舍(为什么是固定窗口,而不是 ZSET 滑动窗口或令牌桶):
- 固定窗口用一条 `INCR` + `TTL` 就能原子地完成"计数 + 取重试等待时间",不需要 Lua
或事务,故障面最小;滑动窗口要 `ZREMRANGEBYSCORE`/`ZADD`/`ZCARD`/`EXPIRE` 四条命令
才能近似原子,令牌桶还要在服务端保存补充速率的状态。本平台的限流目的是**保护**
底座不被单个客户端打爆,不是做精确计费,窗口边界最多放过一个窗口的量可以接受。
- 计数键带 TTL,Redis 自己回收,不需要额外的清理任务,也不会留下永久脏键。
降级语义(用户要求,也是 `MemoryCacheAdapter` 的同一原则):限流是保护措施,不能因为
Redis 故障把正常请求全部拒掉。因此后端**只返回 `None` 表示"无法判定"**,由调用方放行;
这里绝不抛异常、绝不返回"计数超限"。
Redis 客户端是**懒建**的:进程启动时 Redis 不可用不应该让应用起不来,第一次真正需要
限流判定时才建连(与 `bootstrap.py` 的记忆缓存适配器一致)。
"""
import logging
from typing import Any, Protocol
from app.core.config import get_settings
logger = logging.getLogger(__name__)
class CounterBackend(Protocol):
"""限流计数后端。
`increment` 返回 `(窗口内计数, 剩余秒数)`;返回 `None` 表示后端不可用,
调用方必须**放行**(fail-open)。
"""
async def increment(self, key: str, window_seconds: int) -> tuple[int, int] | None: ...
class RedisCounterBackend:
def __init__(
self,
redis_url: str,
*,
connect_timeout_seconds: float,
client_factory: Any | None = None,
) -> None:
self._redis_url = redis_url
self._connect_timeout_seconds = connect_timeout_seconds
# 注入点仅用于测试:默认走 `redis.asyncio.Redis.from_url`。
self._client_factory = client_factory
self._client: Any | None = None
async def _client_or_none(self) -> Any | None:
if self._client is not None:
return self._client
try:
if self._client_factory is not None:
client = self._client_factory()
else:
from redis.asyncio import Redis
client = Redis.from_url(
self._redis_url,
socket_connect_timeout=self._connect_timeout_seconds,
socket_timeout=self._connect_timeout_seconds,
decode_responses=True,
)
self._client = client
except Exception:
logger.warning("限流后端不可用:Redis 客户端构造失败,本次降级放行", exc_info=True)
return None
return self._client
async def increment(self, key: str, window_seconds: int) -> tuple[int, int] | None:
client = await self._client_or_none()
if client is None:
return None
try:
async with client.pipeline(transaction=False) as pipe:
pipe.incr(key)
pipe.ttl(key)
count, ttl = await pipe.execute()
remaining = int(ttl)
if remaining < 0:
# 键首次创建(或历史上丢过 TTL):补一次过期为窗口长度。
await client.expire(key, window_seconds)
remaining = window_seconds
return int(count), max(1, remaining)
except Exception:
logger.warning("限流后端不可用:Redis 计数失败,本次降级放行", exc_info=True)
return None
def default_counter_backend() -> CounterBackend:
settings = get_settings()
return RedisCounterBackend(
settings.redis_url, connect_timeout_seconds=settings.redis_connect_timeout_seconds
)