ForcePilot/backend/package/yuxi/channels/adapters/feishu/api.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

186 lines
6.7 KiB
Python

from __future__ import annotations
import asyncio
import time
from typing import Any
import httpx
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
FEISHU_BUSINESS_ERROR_CODES: dict[int, str] = {
99991663: "app_access_token 无效",
99991672: "tenant_access_token 已过期",
99991664: "请求频繁,触发限流",
99991668: "app_ticket 无效",
99991669: "app_access_token 已过期",
230001: "频率限制",
230002: "请求过于频繁",
}
MAX_RETRIES = 3
RETRY_BACKOFF_BASE = 1.0
TOKEN_CACHE_TTL_S = 3600
RATE_LIMIT_PER_MINUTE = 100
RATE_LIMIT_BURST = 10
class TokenBucket:
def __init__(self, rate_per_minute: int = RATE_LIMIT_PER_MINUTE, burst: int = RATE_LIMIT_BURST):
self._rate = rate_per_minute / 60.0
self._burst = burst
self._tokens = float(burst)
self._last_refill = time.monotonic()
self._lock = asyncio.Lock()
async def acquire(self) -> bool:
async with self._lock:
now = time.monotonic()
elapsed = now - self._last_refill
self._tokens = min(self._burst, self._tokens + elapsed * self._rate)
self._last_refill = now
if self._tokens >= 1.0:
self._tokens -= 1.0
return True
return False
async def wait_and_acquire(self) -> None:
while not await self.acquire():
await asyncio.sleep(0.1)
class FeishuApiClient:
DEFAULT_TIMEOUT_S = 30.0
def __init__(
self,
lark_client,
http_client: httpx.AsyncClient | None = None,
timeout: float | None = None,
max_retries: int = MAX_RETRIES,
):
self._lark = lark_client
self._timeout = timeout or self.DEFAULT_TIMEOUT_S
self._max_retries = max_retries
self._token_bucket = TokenBucket()
self._http: httpx.AsyncClient | None = http_client
self._owns_http = http_client is None
self._token_cache: tuple[str, float] | None = None
self._token_lock = asyncio.Lock()
@property
def domain(self) -> str:
return getattr(self._lark, "domain", "https://open.feishu.cn")
async def _ensure_http(self) -> httpx.AsyncClient:
if self._http is None:
self._http = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
self._owns_http = True
return self._http
async def close(self) -> None:
if self._owns_http and self._http is not None:
await self._http.aclose()
self._http = None
async def _get_token(self) -> str:
async with self._token_lock:
if self._token_cache is not None:
token, cached_at = self._token_cache
if time.monotonic() - cached_at < TOKEN_CACHE_TTL_S:
return token
resp = await asyncio.to_thread(self._lark.auth.tenant_access_token_internal)
if not resp.success():
raise RuntimeError(f"Token acquisition failed: {resp.msg}")
self._token_cache = (resp.token, time.monotonic())
return resp.token
def _parse_business_error(self, resp_data: dict) -> str | None:
code = resp_data.get("code", 0)
if code != 0:
base_msg = FEISHU_BUSINESS_ERROR_CODES.get(int(code), f"飞书业务错误码: {code}")
detail = resp_data.get("msg", "")
return f"{base_msg} ({detail})" if detail else base_msg
return None
async def _request_with_retry(
self, method: str, path: str, body: dict | None = None, **params: Any
) -> dict:
last_error: Exception | None = None
for attempt in range(self._max_retries + 1):
await self._token_bucket.wait_and_acquire()
try:
token = await self._get_token()
http = await self._ensure_http()
headers = {"Authorization": f"Bearer {token}"}
url = f"{self.domain}{path}"
request_args: dict[str, Any] = {"headers": headers}
if params:
request_args["params"] = params
if body is not None and method.lower() in ("post", "patch", "put"):
request_args["json"] = body
resp = await http.request(method, url, **request_args)
if resp.status_code == 429 and attempt < self._max_retries:
retry_after = float(resp.headers.get("Retry-After", "1"))
await asyncio.sleep(retry_after)
continue
if resp.status_code in RETRYABLE_STATUS_CODES and attempt < self._max_retries:
wait = RETRY_BACKOFF_BASE * (2 ** attempt)
await asyncio.sleep(wait)
continue
resp_data = resp.json() if resp.text else {}
if resp.status_code == 401 and attempt < self._max_retries:
self._token_cache = None
continue
if resp.status_code != 200:
business_err = self._parse_business_error(resp_data) if isinstance(resp_data, dict) else None
err_msg = business_err or f"HTTP {resp.status_code}: {resp.text[:300]}"
raise RuntimeError(err_msg)
if isinstance(resp_data, dict):
business_err = self._parse_business_error(resp_data)
if business_err:
raise RuntimeError(business_err)
return resp_data
except (httpx.TimeoutException, httpx.NetworkError) as e:
last_error = e
if attempt < self._max_retries:
wait = RETRY_BACKOFF_BASE * (2 ** attempt)
await asyncio.sleep(wait)
continue
raise RuntimeError(f"Network error: {e}") from e
except RuntimeError:
raise
except Exception as e:
last_error = e
if attempt < self._max_retries:
await asyncio.sleep(RETRY_BACKOFF_BASE * (2 ** attempt))
continue
raise
raise last_error or RuntimeError("Request failed after retries")
async def get(self, path: str, **params: Any) -> dict:
return await self._request_with_retry("GET", path, **params)
async def post(self, path: str, body: dict | None = None) -> dict:
return await self._request_with_retry("POST", path, body=body)
async def patch(self, path: str, body: dict) -> dict:
return await self._request_with_retry("PATCH", path, body=body)
async def delete(self, path: str) -> dict:
return await self._request_with_retry("DELETE", path)