ratelimit.py 3.7 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  1. """限流缓冲队列:单 Worker 匀速消费,保证 < max_qps QPS。
  2. 飞书限制:同一群发送消息上限 5 QPS。本模块用 asyncio.Queue 削峰,
  3. 独立 Worker 协程以 1/max_qps 秒/条的速率匀速消费,避免瞬时并发触发封禁。
  4. """
  5. from __future__ import annotations
  6. import asyncio
  7. import logging
  8. from dataclasses import dataclass
  9. from typing import Awaitable, Callable, Optional
  10. logger = logging.getLogger(__name__)
  11. @dataclass
  12. class ForwardTask:
  13. """待转发的单条任务:源消息 id -> 单个目标群。"""
  14. message_id: str
  15. target_chat_id: str
  16. # 可选:携带源信息用于日志
  17. source_chat_id: str = ""
  18. class RateLimitedWorker:
  19. """单消费协程 + asyncio.Queue,匀速向目标群转发。
  20. - 入队即返回(非阻塞),由 Worker 串行消费
  21. - 每条间隔 1/max_qps 秒,确保 QPS 不超限
  22. - 单条失败按 max_retry 退避重试
  23. """
  24. def __init__(
  25. self,
  26. max_qps: int,
  27. max_retry: int,
  28. forward_fn: Optional[Callable[[ForwardTask], Awaitable[bool]]] = None,
  29. queue: Optional[asyncio.Queue] = None,
  30. ) -> None:
  31. if max_qps <= 0:
  32. raise ValueError("max_qps 必须为正数")
  33. self._interval = 1.0 / max_qps
  34. self._max_retry = max_retry
  35. self._forward_fn = forward_fn
  36. self._queue: asyncio.Queue = queue or asyncio.Queue()
  37. def set_forward_fn(self, fn: Callable[[ForwardTask], Awaitable[bool]]) -> None:
  38. """后置注入转发函数(解决与 Forwarder 的循环依赖)。"""
  39. self._forward_fn = fn
  40. async def enqueue(self, task: ForwardTask) -> None:
  41. await self._queue.put(task)
  42. async def run(self) -> None:
  43. logger.info("转发 Worker 启动,目标速率 %.2f QPS(间隔 %.3fs)",
  44. 1.0 / self._interval, self._interval)
  45. while True:
  46. task: ForwardTask = await self._queue.get()
  47. try:
  48. await self._process(task)
  49. except asyncio.CancelledError:
  50. logger.info("Worker 收到取消信号,退出")
  51. raise
  52. except Exception as e:
  53. logger.exception("Worker 处理异常: %s", e)
  54. finally:
  55. self._queue.task_done()
  56. async def _process(self, task: ForwardTask) -> None:
  57. if self._forward_fn is None:
  58. logger.error("forward_fn 未设置,丢弃任务 message_id=%s", task.message_id)
  59. return
  60. for attempt in range(self._max_retry + 1):
  61. ok = await self._forward_fn(task)
  62. if ok:
  63. if attempt > 0:
  64. logger.info("重试成功 message_id=%s -> %s(第 %d 次)",
  65. task.message_id, task.target_chat_id, attempt)
  66. break
  67. # 失败:退避后重试
  68. backoff = self._interval * (attempt + 1) * 2
  69. logger.warning("转发失败 message_id=%s -> %s,%ds 后重试(%d/%d)",
  70. task.message_id, task.target_chat_id,
  71. int(backoff), attempt + 1, self._max_retry)
  72. await asyncio.sleep(backoff)
  73. else:
  74. logger.error("转发最终失败 message_id=%s -> %s,已放弃",
  75. task.message_id, task.target_chat_id)
  76. # 成功或失败都遵守速率间隔
  77. await asyncio.sleep(self._interval)
  78. async def wait_drained(self, timeout: Optional[float] = None) -> bool:
  79. """等待队列清空,返回是否在 timeout 内清空。"""
  80. try:
  81. await asyncio.wait_for(self._queue.join(), timeout=timeout)
  82. return True
  83. except asyncio.TimeoutError:
  84. return False