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)