ForcePilot/backend/package/yuxi/channel/message/rate_limiter.py

218 lines
6.7 KiB
Python
Raw Normal View History

import asyncio
import logging
import time
from collections.abc import AsyncIterator, Callable
from dataclasses import dataclass
logger = logging.getLogger(__name__)
_global_semaphore = asyncio.Semaphore(50)
def get_global_semaphore() -> asyncio.Semaphore:
return _global_semaphore
DEFAULT_RATE = 10.0
DEFAULT_CAPACITY = 20
DEFAULT_PER_ACCOUNT_CONCURRENCY = 5
DEFAULT_QUEUE_DEPTH = 20
DEFAULT_MAX_LIMITERS = 10_000
@dataclass(slots=True)
class AcquireResult:
allowed: bool
retry_after_sec: float = 0.0
def __bool__(self) -> bool:
return self.allowed
class TokenBucket:
"""令牌桶算法 — 控制单账户消息速率。
参数
- rate: 每秒补充的令牌数默认 10
- capacity: 桶容量上限默认 20允许短时突发
- time_func: 可选时钟注入便于测试默认 time.monotonic
"""
def __init__(
self,
rate: float = DEFAULT_RATE,
capacity: int = DEFAULT_CAPACITY,
time_func: Callable[[], float] = time.monotonic,
):
self._rate = rate
self._capacity = capacity
self._tokens = float(capacity)
self._time = time_func
self._last_refill = self._time()
self._lock = asyncio.Lock()
async def acquire(self) -> AcquireResult:
async with self._lock:
now = self._time()
elapsed = now - self._last_refill
self._tokens = min(self._capacity, self._tokens + elapsed * self._rate)
self._last_refill = now
if self._tokens >= 1.0:
self._tokens -= 1.0
return AcquireResult(allowed=True)
if self._rate <= 0.0:
return AcquireResult(allowed=False, retry_after_sec=float("inf"))
retry_after = (1.0 - self._tokens) / self._rate
return AcquireResult(allowed=False, retry_after_sec=retry_after)
def reset(self) -> None:
self._tokens = float(self._capacity)
self._last_refill = self._time()
class ChannelRateLimiter:
"""单渠道账户的限流控制器,组合令牌桶 + 并发信号量。
线程安全所有方法在 asyncio 上下文中调用
"""
def __init__(
self,
rate: float = DEFAULT_RATE,
per_account_concurrency: int = DEFAULT_PER_ACCOUNT_CONCURRENCY,
queue_depth: int = DEFAULT_QUEUE_DEPTH,
):
self._rate = rate
self._per_account_concurrency = per_account_concurrency
self._bucket = TokenBucket(rate=rate, capacity=DEFAULT_CAPACITY)
self._semaphore = asyncio.Semaphore(per_account_concurrency)
self._queue_depth = queue_depth
self._waiting: int = 0
async def try_acquire(self) -> AcquireResult:
"""尝试获取消息处理许可。
返回 AcquireResult(allowed=..., retry_after_sec=...)
"""
result = await self._bucket.acquire()
if result.allowed:
return result
if self._waiting >= self._queue_depth:
logger.warning(
"Rate limit queue full (depth=%d), rejecting message",
self._queue_depth,
)
return AcquireResult(allowed=False, retry_after_sec=result.retry_after_sec)
self._waiting += 1
try:
await asyncio.sleep(result.retry_after_sec)
retry = await self._bucket.acquire()
if not retry.allowed:
return AcquireResult(allowed=False, retry_after_sec=retry.retry_after_sec)
return retry
finally:
self._waiting -= 1
async def run_with_limit(self, coro) -> AsyncIterator:
"""在并发限制内执行 Agent 调用。
先获取账户级信号量再获取全局信号量
"""
async with self._semaphore:
async with _global_semaphore:
async for chunk in coro:
yield chunk
def reset(self) -> None:
self._bucket.reset()
@property
def waiting_count(self) -> int:
return self._waiting
@property
def config(self) -> dict:
return {
"rate": self._rate,
"per_account_concurrency": self._per_account_concurrency,
"queue_depth": self._queue_depth,
}
class RateLimitManager:
"""限流管理器 — 按账户维度管理限流器实例。
每个 (channel_type, account_id) 组合共享同一个 ChannelRateLimiter
内置自动清理机制防止无限增长
"""
def __init__(self, max_limiters: int = DEFAULT_MAX_LIMITERS):
self._limiters: dict[str, tuple[float, ChannelRateLimiter]] = {}
self._max_limiters = max_limiters
self._last_cleanup = time.monotonic()
self._lock = asyncio.Lock()
def _key(self, channel_type: str, account_id: str) -> str:
return f"{channel_type}:{account_id}"
async def get_limiter(
self,
channel_type: str,
account_id: str,
rate: float = DEFAULT_RATE,
per_account_concurrency: int = DEFAULT_PER_ACCOUNT_CONCURRENCY,
) -> ChannelRateLimiter:
async with self._lock:
self._maybe_cleanup()
key = self._key(channel_type, account_id)
if key not in self._limiters:
self._limiters[key] = (
time.monotonic(),
ChannelRateLimiter(
rate=rate,
per_account_concurrency=per_account_concurrency,
),
)
else:
self._limiters[key] = (time.monotonic(), self._limiters[key][1])
return self._limiters[key][1]
async def remove_limiter(self, channel_type: str, account_id: str) -> None:
async with self._lock:
key = self._key(channel_type, account_id)
self._limiters.pop(key, None)
async def clear(self) -> None:
async with self._lock:
self._limiters.clear()
async def get_stats(self) -> dict[str, dict]:
async with self._lock:
return {
key: {
"waiting": limiter.waiting_count,
**limiter.config,
}
for key, (_, limiter) in self._limiters.items()
}
def _maybe_cleanup(self) -> None:
if len(self._limiters) <= self._max_limiters:
return
now = time.monotonic()
if now - self._last_cleanup < 300:
return
self._last_cleanup = now
expired = [key for key, (last_touch, _) in self._limiters.items() if now - last_touch > 3600]
for key in expired:
self._limiters.pop(key, None)
if expired:
logger.info("Cleaned up %d stale rate limiters", len(expired))
rate_limit_manager = RateLimitManager()