52 lines
2.1 KiB
Python
52 lines
2.1 KiB
Python
"""限流策略值与错误载具(文档 §3.5 的 429、§3.6 的 `RATE_LIMITED`)。
|
||||
|
|
|
|||
|
|
放在 `app/core` 而不是 `errors.py`:码值与状态码的唯一口径在 `app/core/errors.py`,
|
|||
|
|
这里只做两件事——把配置读成不可变策略对象,以及给 `429` 挂上文档要求的
|
|||
|
|
`Retry-After` 数值载体(`RateLimitedError` 本身不携带秒数)。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from dataclasses import dataclass
|
|||
|
|
|
|||
|
|
from app.core.config import Settings
|
|||
|
|
from app.core.errors import RateLimitedError
|
|||
|
|
|
|||
|
|
|
|||
|
|
class RateLimitExceededError(RateLimitedError):
|
|||
|
|
"""`429 RATE_LIMITED` 的运行时载具:额外携带 `Retry-After` 秒数。
|
|||
|
|
|
|||
|
|
`code` / `status_code` / `retryable` 全部继承 `RateLimitedError`(文档 §3.6 口径:
|
|||
|
|
429、可重试),`app/main.py` 的处理器据此输出统一错误信封并附加 `Retry-After` 头。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, message: str, retry_after_seconds: int) -> None:
|
|||
|
|
super().__init__(message)
|
|||
|
|
# 头部取值必须是正整数秒:0 会让"立刻重试"变成忙等,负数非法。
|
|||
|
|
self.retry_after_seconds = max(1, int(retry_after_seconds))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class RateLimitPolicy:
|
|||
|
|
"""限流策略:按"用户 + 方法 + 路由"在固定窗口内计数。
|
|||
|
|
|
|||
|
|
维度取"用户 + 接口"而不是全站总量:单个客户端的异常流量不应该把其他用户一起
|
|||
|
|
拖下水;不同接口的成本差异很大,混在一个计数器里会让慢接口拖累快接口。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
enabled: bool
|
|||
|
|
window_seconds: int
|
|||
|
|
max_requests: int
|
|||
|
|
key_prefix: str
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def from_settings(cls, settings: Settings) -> "RateLimitPolicy":
|
|||
|
|
return cls(
|
|||
|
|
enabled=settings.rate_limit_enabled,
|
|||
|
|
window_seconds=settings.rate_limit_window_seconds,
|
|||
|
|
max_requests=settings.rate_limit_max_requests,
|
|||
|
|
key_prefix=settings.rate_limit_key_prefix,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def key(self, user_id: str, method: str, route_path: str) -> str:
|
|||
|
|
"""计数键。用路由**模板**而不是原始 URL:`/{run_id}` 下每个 run 都是同一接口。"""
|
|||
|
|
return f"{self.key_prefix}:{user_id}:{method.upper()}:{route_path}"
|