ForcePilot/backend/package/yuxi/channel/gateway/polling.py
Kris ecd3c90e80 feat(channel/gateway): 新增完整网关通道模块
新增设备身份管理、认证限流、并发通道、Webhook路由、RBAC权限控制、SSE/轮询降级等全套网关通道功能,包含:
1. 设备身份生成与签名验证
2. 设备令牌认证与速率限制
3. 内存+数据库双重设备注册表
4. 并发通道限流管理
5. Webhook安全处理与路由
6. RBAC权限校验系统
7. OpenAI API兼容适配层
8. Tailscale认证支持
9. HTTP轮询降级机制
2026-05-21 10:26:33 +08:00

103 lines
3.4 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.

"""HTTP Polling 降级机制 — SSE/WS 不可用时的备选路径。
按 session_id 维护响应队列,前端通过 HTTP GET 定时轮询获取回复。
与 SSE 端点协同使用形成三级降级链SSE → Polling → 单次 fetch。
Usage:
polling = PollingFallback()
await polling.push("session_abc", {"type": "delta", "data": {"content": "hello"}})
events = await polling.poll("session_abc")
"""
from __future__ import annotations
import asyncio
import logging
import time as _time
logger = logging.getLogger(__name__)
DEFAULT_POLL_TTL = 600
class PollingFallback:
def __init__(self, max_queue_size: int = 100, ttl_seconds: int = DEFAULT_POLL_TTL):
self._queues: dict[str, asyncio.Queue[dict]] = {}
self._ttl = ttl_seconds
self._last_active: dict[str, float] = {}
self._max_queue_size = max_queue_size
self._lock = asyncio.Lock()
async def ensure(self, session_id: str) -> None:
async with self._lock:
if session_id not in self._queues:
self._queues[session_id] = asyncio.Queue(maxsize=self._max_queue_size)
self._last_active[session_id] = _time.monotonic()
async def push(self, session_id: str, event: dict) -> None:
async with self._lock:
self._last_active[session_id] = _time.monotonic()
q = self._queues.get(session_id)
if q is None:
q = asyncio.Queue(maxsize=self._max_queue_size)
self._queues[session_id] = q
try:
q.put_nowait(event)
except asyncio.QueueFull:
logger.warning("Polling queue full for session %s, dropping event", session_id)
async def poll(self, session_id: str) -> list[dict]:
async with self._lock:
self._last_active[session_id] = _time.monotonic()
q = self._queues.get(session_id)
if q is None:
return []
events: list[dict] = []
while not q.empty():
try:
events.append(q.get_nowait())
except asyncio.QueueEmpty:
break
return events
async def cleanup_stale(self) -> int:
async with self._lock:
now = _time.monotonic()
stale = [sid for sid, ts in self._last_active.items() if now - ts > self._ttl]
for sid in stale:
self._queues.pop(sid, None)
self._last_active.pop(sid, None)
if stale:
logger.info("PollingFallback cleaned up %d stale sessions", len(stale))
return len(stale)
def active_sessions(self) -> int:
return len(self._queues)
polling_fallback = PollingFallback()
async def _polling_cleanup_loop(interval: int = 300) -> None:
while True:
await asyncio.sleep(interval)
await polling_fallback.cleanup_stale()
_cleanup_task: asyncio.Task | None = None
def start_polling_cleanup(interval: int = 300) -> None:
global _cleanup_task
if _cleanup_task is None or _cleanup_task.done():
_cleanup_task = asyncio.ensure_future(_polling_cleanup_loop(interval))
logger.info("PollingFallback cleanup loop started (interval=%ds)", interval)
def stop_polling_cleanup() -> None:
global _cleanup_task
if _cleanup_task and not _cleanup_task.done():
_cleanup_task.cancel()
_cleanup_task = None