mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 04:47:03 +08:00
* fix(download): share site limits and preserve frequency errors * test(startup): record shared site throttle module
599 lines
20 KiB
Python
599 lines
20 KiB
Python
"""
|
||
同步与异步调用的限流策略、退避计时和装饰器。
|
||
"""
|
||
|
||
import functools
|
||
import inspect
|
||
import threading
|
||
import time
|
||
from collections import deque
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Callable, List, Optional, Tuple
|
||
|
||
from app.runtime.log import logger
|
||
from app.schemas.exception import LimitException, RateLimitExceededException
|
||
|
||
|
||
@dataclass
|
||
class _RequestSchedule:
|
||
"""
|
||
单个请求来源的实际开始时间和远端冷却事实。
|
||
|
||
:param last_started: 上次获准请求的单调时钟时间;None 表示尚未请求
|
||
:param starts: 当前计数窗口内的实际请求时间
|
||
:param retry_at: 远端冷却到期的单调时钟时间
|
||
:param reason: 远端明确返回的冷却原因
|
||
"""
|
||
|
||
last_started: Optional[float] = None
|
||
starts: deque[float] = field(default_factory=deque)
|
||
retry_at: float = 0.0
|
||
reason: str = ""
|
||
|
||
|
||
class RequestRateLimiter:
|
||
"""
|
||
按来源原子检查请求间隔、滑动窗口和远端冷却,不持锁等待或执行 I/O。
|
||
|
||
等待者必须重新申请许可,避免线程迟醒或异步取消后按过期预约连续发出请求。
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
"""
|
||
创建进程内共享的来源状态和短临界区锁。
|
||
"""
|
||
self._lock = threading.Lock()
|
||
self._schedules: dict[str, _RequestSchedule] = {}
|
||
|
||
def acquire(self, key: str, *, interval: float, window: float, count: int) -> tuple[float, str]:
|
||
"""
|
||
检查并记录一次实际请求;受限时不消耗请求额度。
|
||
|
||
:param key: 调用方提供的稳定来源标识
|
||
:param interval: 相邻请求的最小间隔秒数,0 表示关闭
|
||
:param window: 滑动计数窗口秒数,0 表示关闭
|
||
:param count: 窗口内最大请求数量,0 表示关闭
|
||
:return: 剩余等待秒数及远端冷却原因;本地排队的原因为空
|
||
"""
|
||
with self._lock:
|
||
now = time.monotonic()
|
||
state = self._schedules.setdefault(key, _RequestSchedule())
|
||
if state.retry_at > now:
|
||
return state.retry_at - now, state.reason
|
||
ready_at = now if state.last_started is None else max(now, state.last_started + interval)
|
||
while state.starts and state.starts[0] <= now - window:
|
||
state.starts.popleft()
|
||
if window > 0 and count > 0 and len(state.starts) >= count:
|
||
ready_at = max(ready_at, state.starts[-count] + window)
|
||
if ready_at > now:
|
||
return ready_at - now, ""
|
||
state.last_started = now
|
||
if window > 0 and count > 0:
|
||
state.starts.append(now)
|
||
return 0.0, ""
|
||
|
||
def defer(self, key: str, seconds: float, reason: str) -> None:
|
||
"""
|
||
延长一个来源的远端冷却,较短的新提示不能提前解除旧冷却。
|
||
|
||
:param key: 需要停止请求的来源标识
|
||
:param seconds: 从当前时刻起的冷却秒数
|
||
:param reason: 向后续调用方传递的原始限流说明
|
||
"""
|
||
with self._lock:
|
||
state = self._schedules.setdefault(key, _RequestSchedule())
|
||
retry_at = time.monotonic() + seconds
|
||
if retry_at > state.retry_at:
|
||
state.retry_at, state.reason = retry_at, reason
|
||
|
||
def cooldown(self, key: str) -> float:
|
||
"""
|
||
查询远端冷却的剩余秒数,不改变到期时间。
|
||
|
||
:param key: 来源的稳定标识
|
||
:return: 剩余秒数,未冷却或已经到期时为 0
|
||
"""
|
||
with self._lock:
|
||
state = self._schedules.get(key)
|
||
return max(0.0, state.retry_at - time.monotonic()) if state else 0.0
|
||
|
||
|
||
class BaseRateLimiter:
|
||
"""
|
||
限流器基类,定义了限流器的通用接口,用于子类实现不同的限流策略
|
||
所有限流器都必须实现 can_call、reset 方法
|
||
"""
|
||
|
||
def __init__(self, source: str = "", enable_logging: bool = True):
|
||
"""
|
||
初始化 BaseRateLimiter 实例。
|
||
|
||
:param source: 业务来源或上下文信息,默认为空字符串
|
||
:param enable_logging: 是否启用日志记录,默认为 True
|
||
"""
|
||
self.source = source
|
||
self.enable_logging = enable_logging
|
||
self.lock = threading.Lock()
|
||
|
||
@property
|
||
def reset_on_success(self) -> bool:
|
||
"""
|
||
是否在成功调用后自动重置限流器状态,默认为 False。
|
||
"""
|
||
return False
|
||
|
||
def can_call(self) -> Tuple[bool, str]:
|
||
"""
|
||
检查是否可以进行调用。
|
||
|
||
:return: 如果允许调用,返回 True 和空消息,否则返回 False 和限流消息
|
||
"""
|
||
raise NotImplementedError
|
||
|
||
def reset(self) -> None:
|
||
"""
|
||
重置限流状态。
|
||
"""
|
||
raise NotImplementedError
|
||
|
||
def trigger_limit(self) -> None:
|
||
"""
|
||
触发限流。
|
||
"""
|
||
pass
|
||
|
||
def record_call(self) -> None:
|
||
"""
|
||
记录一次调用。
|
||
"""
|
||
pass
|
||
|
||
def format_log(self, message: str) -> str:
|
||
"""
|
||
格式化日志消息。
|
||
|
||
:param message: 日志内容
|
||
:return: 格式化后的日志消息
|
||
"""
|
||
return f"[{self.source}] {message}" if self.source else message
|
||
|
||
def log(self, level: str, message: str) -> None:
|
||
"""
|
||
根据日志级别记录日志。
|
||
|
||
:param level: 日志级别
|
||
:param message: 日志内容
|
||
"""
|
||
if self.enable_logging:
|
||
log_method = getattr(logger, level, None)
|
||
if not callable(log_method):
|
||
log_method = logger.info
|
||
log_method(self.format_log(message))
|
||
|
||
def log_info(self, message: str) -> None:
|
||
"""
|
||
记录信息日志。
|
||
"""
|
||
self.log("info", message)
|
||
|
||
def log_warning(self, message: str) -> None:
|
||
"""
|
||
记录警告日志。
|
||
"""
|
||
self.log("warning", message)
|
||
|
||
|
||
class ExponentialBackoffRateLimiter(BaseRateLimiter):
|
||
"""
|
||
基于指数退避的限流器,用于处理单次调用频率的控制
|
||
每次触发限流时,等待时间会成倍增加,直到达到最大等待时间
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
base_wait: float = 60.0,
|
||
max_wait: float = 600.0,
|
||
backoff_factor: float = 2.0,
|
||
source: str = "",
|
||
enable_logging: bool = True,
|
||
):
|
||
"""
|
||
初始化 ExponentialBackoffRateLimiter 实例。
|
||
|
||
:param base_wait: 基础等待时间(秒),默认值为 60 秒(1 分钟)
|
||
:param max_wait: 最大等待时间(秒),默认值为 600 秒(10 分钟)
|
||
:param backoff_factor: 等待时间的递增倍数,默认值为 2.0,表示指数退避
|
||
:param source: 业务来源或上下文信息,默认值为 ""
|
||
:param enable_logging: 是否启用日志记录,默认为 True
|
||
"""
|
||
super().__init__(source, enable_logging)
|
||
self.next_allowed_time = 0.0
|
||
self.current_wait = base_wait
|
||
self.base_wait = base_wait
|
||
self.max_wait = max_wait
|
||
self.backoff_factor = backoff_factor
|
||
self.source = source
|
||
|
||
@property
|
||
def reset_on_success(self) -> bool:
|
||
"""
|
||
指数退避限流器在调用成功后应重置等待时间。
|
||
"""
|
||
return True
|
||
|
||
def can_call(self) -> Tuple[bool, str]:
|
||
"""
|
||
检查是否可以进行调用,如果当前时间超过下一次允许调用的时间,则允许调用。
|
||
|
||
:return: 如果允许调用,返回 True 和空消息,否则返回 False 和限流消息
|
||
"""
|
||
current_time = time.time()
|
||
with self.lock:
|
||
if current_time >= self.next_allowed_time:
|
||
return True, ""
|
||
wait_time = self.next_allowed_time - current_time
|
||
message = f"限流期间,跳过调用,将在 {wait_time:.2f} 秒后允许继续调用"
|
||
self.log_info(message)
|
||
return False, self.format_log(message)
|
||
|
||
def reset(self) -> None:
|
||
"""
|
||
重置等待时间
|
||
当调用成功时调用此方法,重置当前等待时间为基础等待时间
|
||
"""
|
||
with self.lock:
|
||
if self.next_allowed_time != 0 or self.current_wait > self.base_wait:
|
||
self.log_info(f"调用成功,重置限流等待时间为 {self.base_wait} 秒")
|
||
self.next_allowed_time = 0.0
|
||
self.current_wait = self.base_wait
|
||
|
||
def trigger_limit(self) -> None:
|
||
"""
|
||
触发限流
|
||
当触发限流异常时调用此方法,增加下一次允许调用的时间并更新当前等待时间
|
||
"""
|
||
current_time = time.time()
|
||
with self.lock:
|
||
self.next_allowed_time = current_time + self.current_wait
|
||
self.current_wait = min(
|
||
self.current_wait * self.backoff_factor, self.max_wait
|
||
)
|
||
wait_time = self.next_allowed_time - current_time
|
||
self.log_warning(f"触发限流,将在 {wait_time:.2f} 秒后允许继续调用")
|
||
|
||
|
||
class WindowRateLimiter(BaseRateLimiter):
|
||
"""
|
||
基于时间窗口的限流器,用于限制在特定时间窗口内的调用次数
|
||
如果超过允许的最大调用次数,则限流直到窗口期结束
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
max_calls: int,
|
||
window_seconds: float,
|
||
source: str = "",
|
||
enable_logging: bool = True,
|
||
):
|
||
"""
|
||
初始化 WindowRateLimiter 实例。
|
||
|
||
:param max_calls: 在时间窗口内允许的最大调用次数
|
||
:param window_seconds: 时间窗口的持续时间(秒)
|
||
:param source: 业务来源或上下文信息,默认值为 ""
|
||
:param enable_logging: 是否启用日志记录,默认为 True
|
||
"""
|
||
super().__init__(source, enable_logging)
|
||
self.max_calls = max_calls
|
||
self.window_seconds = window_seconds
|
||
self.call_times: deque[float] = deque()
|
||
|
||
def can_call(self) -> Tuple[bool, str]:
|
||
"""
|
||
检查是否可以进行调用,如果在时间窗口内的调用次数少于最大允许次数,则允许调用。
|
||
|
||
:return: 如果允许调用,返回 True 和空消息,否则返回 False 和限流消息
|
||
"""
|
||
current_time = time.time()
|
||
with self.lock:
|
||
# 清理超出时间窗口的调用记录
|
||
while (
|
||
self.call_times
|
||
and current_time - self.call_times[0] > self.window_seconds
|
||
):
|
||
self.call_times.popleft()
|
||
|
||
if len(self.call_times) < self.max_calls:
|
||
return True, ""
|
||
else:
|
||
wait_time = self.window_seconds - (current_time - self.call_times[0])
|
||
message = f"限流期间,跳过调用,将在 {wait_time:.2f} 秒后允许继续调用"
|
||
self.log_info(message)
|
||
return False, self.format_log(message)
|
||
|
||
def reset(self) -> None:
|
||
"""
|
||
重置时间窗口内的调用记录
|
||
当调用成功时调用此方法,清空时间窗口内的调用记录
|
||
"""
|
||
with self.lock:
|
||
self.call_times.clear()
|
||
|
||
def record_call(self) -> None:
|
||
"""
|
||
记录当前时间戳,用于限流检查。
|
||
"""
|
||
current_time = time.time()
|
||
with self.lock:
|
||
self.call_times.append(current_time)
|
||
|
||
|
||
class CompositeRateLimiter(BaseRateLimiter):
|
||
"""
|
||
组合限流器,可以组合多个限流策略
|
||
当任意一个限流策略触发限流时,都会阻止调用
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
limiters: List[BaseRateLimiter],
|
||
source: str = "",
|
||
enable_logging: bool = True,
|
||
):
|
||
"""
|
||
初始化 CompositeRateLimiter 实例。
|
||
|
||
:param limiters: 要组合的限流器列表
|
||
:param source: 业务来源或上下文信息,默认值为 ""
|
||
:param enable_logging: 是否启用日志记录,默认为 True
|
||
"""
|
||
super().__init__(source, enable_logging)
|
||
self.limiters = limiters
|
||
|
||
def can_call(self) -> Tuple[bool, str]:
|
||
"""
|
||
检查是否可以进行调用,当组合的任意限流器触发限流时,阻止调用。
|
||
|
||
:return: 如果所有限流器都允许调用,返回 True 和空消息,否则返回 False 和限流信息。
|
||
"""
|
||
for limiter in self.limiters:
|
||
can_call, message = limiter.can_call()
|
||
if not can_call:
|
||
return False, message
|
||
return True, ""
|
||
|
||
def reset(self) -> None:
|
||
"""
|
||
重置所有组合的限流器状态。
|
||
"""
|
||
for limiter in self.limiters:
|
||
limiter.reset()
|
||
|
||
def record_call(self) -> None:
|
||
"""
|
||
记录所有组合的限流器的调用时间。
|
||
"""
|
||
for limiter in self.limiters:
|
||
limiter.record_call()
|
||
|
||
|
||
def rate_limit_handler(
|
||
limiter: BaseRateLimiter, raise_on_limit: bool = False
|
||
) -> Callable[..., Any]:
|
||
"""
|
||
通用装饰器,允许用户传递自定义的限流器实例,用于处理限流逻辑
|
||
该装饰器可灵活支持任意继承自 BaseRateLimiter 的限流器
|
||
|
||
:param limiter: 限流器实例,必须继承自 BaseRateLimiter
|
||
:param raise_on_limit: 控制在限流时是否抛出异常,默认为 False
|
||
:return: 装饰器函数
|
||
"""
|
||
|
||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||
"""
|
||
为目标函数选择同步或异步限流包装器。
|
||
"""
|
||
@functools.wraps(func)
|
||
def wrapper(*args: Any, **kwargs: Any) -> Optional[Any]:
|
||
# 检查是否传入了 "raise_exception" 参数,优先使用该参数,否则使用默认的 raise_on_limit 值
|
||
"""
|
||
检查限流状态后执行同步调用,按配置跳过或传播限流异常。
|
||
"""
|
||
raise_exception = kwargs.get("raise_exception", raise_on_limit)
|
||
|
||
can_call, message = limiter.can_call()
|
||
if not can_call:
|
||
if raise_exception:
|
||
raise RateLimitExceededException(message)
|
||
return None
|
||
|
||
try:
|
||
result = func(*args, **kwargs)
|
||
limiter.record_call()
|
||
if limiter.reset_on_success:
|
||
limiter.reset()
|
||
return result
|
||
except LimitException as e:
|
||
# 如果目标函数触发了限流相关的异常,执行限流器的触发逻辑(如递增等待时间)
|
||
limiter.trigger_limit()
|
||
logger.error(limiter.format_log(f"触发限流:{str(e)}"))
|
||
if raise_exception:
|
||
raise e
|
||
return None
|
||
|
||
@functools.wraps(func)
|
||
async def async_wrapper(*args: Any, **kwargs: Any) -> Optional[Any]:
|
||
# 检查是否传入了 "raise_exception" 参数,优先使用该参数,否则使用默认的 raise_on_limit 值
|
||
"""
|
||
检查限流状态后等待异步调用,按配置跳过或传播限流异常。
|
||
"""
|
||
raise_exception = kwargs.get("raise_exception", raise_on_limit)
|
||
|
||
can_call, message = limiter.can_call()
|
||
if not can_call:
|
||
if raise_exception:
|
||
raise RateLimitExceededException(message)
|
||
return None
|
||
|
||
try:
|
||
result = await func(*args, **kwargs)
|
||
limiter.record_call()
|
||
if limiter.reset_on_success:
|
||
limiter.reset()
|
||
return result
|
||
except LimitException as e:
|
||
# 如果目标函数触发了限流相关的异常,执行限流器的触发逻辑(如递增等待时间)
|
||
limiter.trigger_limit()
|
||
logger.error(limiter.format_log(f"触发限流:{str(e)}"))
|
||
if raise_exception:
|
||
raise e
|
||
return None
|
||
|
||
if inspect.iscoroutinefunction(func):
|
||
return async_wrapper
|
||
else:
|
||
return wrapper
|
||
|
||
return decorator
|
||
|
||
|
||
def rate_limit_exponential(
|
||
base_wait: float = 60.0,
|
||
max_wait: float = 600.0,
|
||
backoff_factor: float = 2.0,
|
||
raise_on_limit: bool = False,
|
||
source: str = "",
|
||
enable_logging: bool = True,
|
||
) -> Callable[..., Any]:
|
||
"""
|
||
装饰器,用于应用指数退避限流策略
|
||
通过逐渐增加调用等待时间控制调用频率。每次触发限流时,等待时间会成倍增加,直到达到最大等待时间
|
||
|
||
:param base_wait: 基础等待时间(秒),默认值为 60 秒(1 分钟)
|
||
:param max_wait: 最大等待时间(秒),默认值为 600 秒(10 分钟)
|
||
:param backoff_factor: 等待时间递增的倍数,默认值为 2.0,表示指数退避
|
||
:param raise_on_limit: 控制在限流时是否抛出异常,默认为 False
|
||
:param source: 业务来源或上下文信息,默认为空字符串
|
||
:param enable_logging: 是否启用日志记录,默认为 True
|
||
:return: 装饰器函数
|
||
"""
|
||
limiter = ExponentialBackoffRateLimiter(
|
||
base_wait, max_wait, backoff_factor, source, enable_logging
|
||
)
|
||
return rate_limit_handler(limiter, raise_on_limit)
|
||
|
||
|
||
def rate_limit_window(
|
||
max_calls: int,
|
||
window_seconds: float,
|
||
raise_on_limit: bool = False,
|
||
source: str = "",
|
||
enable_logging: bool = True,
|
||
) -> Callable[..., Any]:
|
||
"""
|
||
装饰器,用于应用时间窗口限流策略
|
||
在固定的时间窗口内限制调用次数,当调用次数超过最大值时,触发限流,直到时间窗口结束
|
||
|
||
:param max_calls: 时间窗口内允许的最大调用次数
|
||
:param window_seconds: 时间窗口的持续时间(秒)
|
||
:param raise_on_limit: 控制在限流时是否抛出异常,默认为 False
|
||
:param source: 业务来源或上下文信息,默认为空字符串
|
||
:param enable_logging: 是否启用日志记录,默认为 True
|
||
:return: 装饰器函数
|
||
"""
|
||
limiter = WindowRateLimiter(max_calls, window_seconds, source, enable_logging)
|
||
return rate_limit_handler(limiter, raise_on_limit)
|
||
|
||
|
||
class QpsRateLimiter:
|
||
"""
|
||
速率控制器,精确控制 QPS。
|
||
"""
|
||
|
||
def __init__(self, qps: float | int):
|
||
"""
|
||
按目标 QPS 初始化下一次许可时间。
|
||
"""
|
||
if qps <= 0:
|
||
qps = float("inf")
|
||
self.interval = 1.0 / qps
|
||
self.lock = threading.Lock()
|
||
self.next_call_time = time.monotonic()
|
||
|
||
def acquire(self) -> None:
|
||
"""
|
||
获取调用许可,阻塞直到满足速率限制。
|
||
"""
|
||
sleep_duration: float = 0.0
|
||
with self.lock:
|
||
now = time.monotonic()
|
||
sleep_duration = self.next_call_time - now
|
||
self.next_call_time = max(now, self.next_call_time) + self.interval
|
||
if sleep_duration > 0:
|
||
time.sleep(sleep_duration)
|
||
|
||
|
||
class RateStats:
|
||
"""
|
||
请求速率统计:记录时间戳,计算 QPS / QPM / QPH。
|
||
"""
|
||
|
||
def __init__(self, window_seconds: float = 7200, source: str = ""):
|
||
"""
|
||
创建固定时间窗口的事件合并器。
|
||
|
||
:param window_seconds: 统计窗口(秒),默认 2 小时,用于计算 QPH
|
||
:param source: 日志来源标识
|
||
"""
|
||
self._window = window_seconds
|
||
self._source = source
|
||
self._lock = threading.Lock()
|
||
self._timestamps: deque[float] = deque()
|
||
|
||
def record(self) -> None:
|
||
"""
|
||
记录一次请求。
|
||
"""
|
||
t = time.time()
|
||
with self._lock:
|
||
self._timestamps.append(t)
|
||
while self._timestamps and t - self._timestamps[0] > self._window:
|
||
self._timestamps.popleft()
|
||
|
||
def _count_since(self, seconds: float) -> int:
|
||
"""
|
||
统计指定秒数窗口内记录的请求数。
|
||
"""
|
||
t = time.time()
|
||
with self._lock:
|
||
return sum(1 for ts in self._timestamps if t - ts <= seconds)
|
||
|
||
def get_qps(self) -> float:
|
||
"""
|
||
最近 1 秒内请求数。
|
||
"""
|
||
return self._count_since(1.0)
|
||
|
||
def get_qpm(self) -> float:
|
||
"""
|
||
最近 1 分钟内请求数。
|
||
"""
|
||
return self._count_since(60.0)
|
||
|
||
def get_qph(self) -> float:
|
||
"""
|
||
最近 1 小时内请求数。
|
||
"""
|
||
return self._count_since(3600.0)
|
||
|
||
def log_stats(self, level: str = "info") -> None:
|
||
"""
|
||
输出当前 QPS/QPM/QPH。
|
||
"""
|
||
qps, qpm, qph = self.get_qps(), self.get_qpm(), self.get_qph()
|
||
msg = f"QPS={qps} QPM={qpm} QPH={qph}"
|
||
if self._source:
|
||
msg = f"[{self._source}] {msg}"
|
||
log_fn = getattr(logger, level, logger.info)
|
||
log_fn(msg)
|