refactor: streamline asset service methods by integrating reverse request handlers

This commit is contained in:
Chenyme committed 2026-02-09 14:40:32 +08:00
1 parent cb814a7eff
commit fddb79dd56
9 files changed
+887 -146

No files matched your search

+32 -146
View File
@@ -9,7 +9,6 @@ import os
import re
import time
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse
@@ -26,15 +25,15 @@ from app.core.config import get_config
from app.core.exceptions import AppException, UpstreamException, ValidationException
from app.core.logger import logger
from app.core.storage import DATA_DIR
from app.services.grok.utils.headers import apply_statsig, build_sso_cookie
from app.services.token.service import TokenService
from app.services.reverse import (
AssetsDeleteReverse,
AssetsDownloadReverse,
AssetsListReverse,
AssetsUploadReverse,
)
# ==================== 常量 ====================
UPLOAD_API = "https://grok.com/rest/app-chat/upload-file"
LIST_API = "https://grok.com/rest/assets"
DELETE_API = "https://grok.com/rest/assets-metadata"
DOWNLOAD_API = "https://assets.grok.com"
LOCK_DIR = DATA_DIR / ".locks"
# 全局信号量(运行时动态初始化)
@@ -115,67 +114,15 @@ async def _file_lock(name: str, timeout: int = 10):
fd.close()
@dataclass
class ServiceConfig:
"""服务配置"""
proxy: str
timeout: int
browser: str
user_agent: str
@classmethod
def from_settings(cls, proxy: Optional[str] = None):
return cls(
proxy=proxy
or get_config("network.asset_proxy_url")
or get_config("network.base_proxy_url"),
timeout=get_config("network.timeout"),
browser=get_config("security.browser"),
user_agent=get_config("security.user_agent"),
)
def get_proxies(self) -> Optional[dict]:
return {"http": self.proxy, "https": self.proxy} if self.proxy else None
# ==================== 基础服务 ====================
class BaseService:
"""基础服务类"""
def __init__(self, proxy: Optional[str] = None):
self.config = ServiceConfig.from_settings(proxy)
def __init__(self):
self._session: Optional[AsyncSession] = None
def _build_headers(
self, token: str, referer: str = "https://grok.com/", download: bool = False
) -> dict:
"""构建请求头"""
if download:
headers = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "same-site",
"Sec-Fetch-User": "?1",
"Referer": referer,
"User-Agent": self.config.user_agent,
}
else:
headers = {
"Accept": "*/*",
"Content-Type": "application/json",
"Origin": "https://grok.com",
"Referer": referer,
"User-Agent": self.config.user_agent,
}
apply_statsig(headers)
headers["Cookie"] = build_sso_cookie(token)
return headers
async def _get_session(self) -> AsyncSession:
"""获取复用 Session"""
if self._session is None:
@@ -298,44 +245,19 @@ class UploadService(BaseService):
# 执行上传
session = await self._get_session()
response = await session.post(
UPLOAD_API,
headers=self._build_headers(token),
json={"fileName": filename, "fileMimeType": mime, "content": b64},
impersonate=self.config.browser,
timeout=self.config.timeout,
proxies=self.config.get_proxies(),
response = await AssetsUploadReverse.request(
session,
token,
filename,
mime,
b64,
)
# 处理响应
if response.status_code == 200:
result = response.json()
file_id = result.get("fileMetadataId", "")
file_uri = result.get("fileUri", "")
logger.info(f"Upload success: {filename} -> {file_id}")
return file_id, file_uri
# 认证失败
if response.status_code in (401, 403):
logger.warning(f"Upload auth failed: {response.status_code}")
try:
await TokenService.record_fail(
token, response.status_code, "upload_auth_failed"
)
except Exception as e:
logger.error(f"Failed to record token failure: {e}")
raise UpstreamException(
message=f"Upload authentication failed: {response.status_code}",
details={"status": response.status_code, "token_invalidated": True},
)
# 其他错误
logger.error(f"Upload failed: {filename} - {response.status_code}")
raise UpstreamException(
message=f"Upload failed: {response.status_code}",
details={"status": response.status_code},
)
result = response.json()
file_id = result.get("fileMetadataId", "")
file_uri = result.get("fileUri", "")
logger.info(f"Upload success: {filename} -> {file_id}")
return file_id, file_uri
# ==================== 列表服务 ====================
@@ -346,7 +268,6 @@ class ListService(BaseService):
async def iter_assets(self, token: str):
"""分页迭代资产列表"""
headers = self._build_headers(token, referer="https://grok.com/files")
params = {
"pageSize": 50,
"orderBy": "ORDER_BY_LAST_USE_TIME",
@@ -367,21 +288,12 @@ class ListService(BaseService):
else:
params.pop("pageToken", None)
response = await session.get(
LIST_API,
headers=headers,
params=params,
impersonate=self.config.browser,
timeout=self.config.timeout,
proxies=self.config.get_proxies(),
response = await AssetsListReverse.request(
session,
token,
params,
)
if response.status_code != 200:
raise UpstreamException(
message=f"List failed: {response.status_code}",
details={"status": response.status_code},
)
result = response.json()
page_assets = result.get("assets", [])
yield page_assets
@@ -417,28 +329,19 @@ class DeleteService(BaseService):
"""删除单个文件"""
async with _get_assets_semaphore():
session = await self._get_session()
response = await session.delete(
f"{DELETE_API}/{asset_id}",
headers=self._build_headers(token, referer="https://grok.com/files"),
impersonate=self.config.browser,
timeout=self.config.timeout,
proxies=self.config.get_proxies(),
response = await AssetsDeleteReverse.request(
session,
token,
asset_id,
)
if response.status_code == 200:
logger.debug(f"Deleted: {asset_id}")
return True
logger.error(f"Delete failed: {asset_id} - {response.status_code}")
raise UpstreamException(
message=f"Delete failed: {asset_id}",
details={"status": response.status_code},
)
logger.debug(f"Deleted: {asset_id}")
return True
async def delete_all(self, token: str) -> Dict[str, int]:
"""删除所有文件"""
total = success = failed = 0
list_service = ListService(self.config.proxy)
list_service = ListService()
try:
async for assets in list_service.iter_assets(token):
@@ -496,8 +399,8 @@ class DeleteService(BaseService):
class DownloadService(BaseService):
"""文件下载服务"""
def __init__(self, proxy: Optional[str] = None):
super().__init__(proxy)
def __init__(self):
super().__init__()
self.base_dir = DATA_DIR / "tmp"
self.image_dir = self.base_dir / "image"
self.video_dir = self.base_dir / "video"
@@ -552,25 +455,8 @@ class DownloadService(BaseService):
if not file_path.startswith("/"):
file_path = f"/{file_path}"
url = f"{DOWNLOAD_API}{file_path}"
headers = self._build_headers(token, download=True)
session = await self._get_session()
response = await session.get(
url,
headers=headers,
proxies=self.config.get_proxies(),
timeout=self.config.timeout,
allow_redirects=True,
impersonate=self.config.browser,
stream=True,
)
if response.status_code != 200:
raise UpstreamException(
message=f"Download failed: {response.status_code}",
details={"path": file_path, "status": response.status_code},
)
response = await AssetsDownloadReverse.request(session, token, file_path)
# 保存文件
tmp_path = cache_path.with_suffix(cache_path.suffix + ".tmp")
+17
View File
@@ -0,0 +1,17 @@
"""Reverse interfaces for Grok endpoints."""
from .assets_delete import AssetsDeleteReverse
from .assets_download import AssetsDownloadReverse
from .assets_list import AssetsListReverse
from .assets_upload import AssetsUploadReverse
from .utils.headers import build_headers
from .utils.statsig import StatsigGenerator
__all__ = [
"AssetsDeleteReverse",
"AssetsDownloadReverse",
"AssetsListReverse",
"AssetsUploadReverse",
"StatsigGenerator",
"build_headers",
]
+102
View File
@@ -0,0 +1,102 @@
"""
Reverse interface: delete asset metadata.
"""
from typing import Any
from curl_cffi.requests import AsyncSession
from app.core.logger import logger
from app.core.config import get_config
from app.core.exceptions import UpstreamException
from app.services.token.service import TokenService
from app.services.reverse.utils.headers import build_headers
from app.services.reverse.utils.retry import retry_on_status
DELETE_API = "https://grok.com/rest/assets-metadata"
class AssetsDeleteReverse:
"""/rest/assets-metadata/{file_id} reverse interface."""
@staticmethod
async def request(session: AsyncSession, token: str, asset_id: str) -> Any:
"""Delete asset from Grok.
Args:
session: AsyncSession, the session to use for the request.
token: str, the SSO token.
asset_id: str, the ID of the asset to delete.
Returns:
Any: The response from the request.
"""
try:
# Get proxies
base_proxy = get_config("network.base_proxy_url")
assert_proxy = get_config("network.asset_proxy_url")
if assert_proxy:
proxies = {"http": assert_proxy, "https": assert_proxy}
else:
proxies = {"http": base_proxy, "https": base_proxy}
# Build headers
headers = build_headers(
cookie_token=token,
content_type="application/json",
origin="https://grok.com",
referer="https://grok.com/files",
)
# Curl Config
timeout = get_config("network.timeout")
browser = get_config("security.browser")
async def _do_request():
response = await session.delete(
f"{DELETE_API}/{asset_id}",
headers=headers,
proxies=proxies,
timeout=timeout,
impersonate=browser,
)
if response.status_code != 200:
logger.error(
f"AssetsDeleteReverse: Delete failed, {response.status_code}",
extra={"error_type": "UpstreamException"},
)
raise UpstreamException(
message=f"AssetsDeleteReverse: Delete failed, {response.status_code}",
details={"status": response.status_code},
)
return response
return await retry_on_status(_do_request)
except Exception as e:
# Handle upstream exception
if isinstance(e, UpstreamException):
status = None
if e.details and "status" in e.details:
status = e.details["status"]
else:
status = getattr(e, "status_code", None)
if status == 401:
try:
await TokenService.record_fail(token, status, "assets_delete_auth_failed")
except Exception:
pass
raise
# Handle other non-upstream exceptions
logger.error(
f"AssetsDeleteReverse: Delete failed, {str(e)}",
extra={"error_type": type(e).__name__},
)
raise UpstreamException(
message=f"AssetsDeleteReverse: Delete failed, {str(e)}",
details={"status": 502, "error": str(e)},
)
__all__ = ["AssetsDeleteReverse"]
+132
View File
@@ -0,0 +1,132 @@
"""
Reverse interface: download asset.
"""
import urllib.parse
from typing import Any
from pathlib import Path
from curl_cffi.requests import AsyncSession
from app.core.logger import logger
from app.core.config import get_config
from app.core.exceptions import UpstreamException
from app.services.token.service import TokenService
from app.services.reverse.utils.headers import build_headers
from app.services.reverse.utils.retry import retry_on_status
DOWNLOAD_API = "https://assets.grok.com"
_CONTENT_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".mp4": "video/mp4",
".webm": "video/webm",
}
class AssetsDownloadReverse:
"""assets.grok.com/{path} reverse interface."""
@staticmethod
async def request(session: AsyncSession, token: str, file_path: str) -> Any:
"""Download asset from Grok.
Args:
session: AsyncSession, the session to use for the request.
token: str, the SSO token.
file_path: str, the path of the file to download.
Returns:
Any: The response from the request.
"""
try:
# Normalize path
if not file_path.startswith("/"):
file_path = f"/{file_path}"
url = f"{DOWNLOAD_API}{file_path}"
# Get proxies
base_proxy = get_config("network.base_proxy_url")
assert_proxy = get_config("network.asset_proxy_url")
if assert_proxy:
proxies = {"http": assert_proxy, "https": assert_proxy}
else:
proxies = {"http": base_proxy, "https": base_proxy}
# Guess content type by extension for Accept/Sec-Fetch-Dest
content_type = _CONTENT_TYPES.get(Path(urllib.parse.urlparse(file_path).path).suffix.lower())
# Build headers
headers = build_headers(
cookie_token=token,
content_type=content_type,
origin="https://grok.com",
referer="https://grok.com/",
)
## Align with browser download navigation headers
headers["Cache-Control"] = "no-cache"
headers["Pragma"] = "no-cache"
headers["Priority"] = "u=0, i"
headers["Sec-Fetch-Mode"] = "navigate"
headers["Sec-Fetch-User"] = "?1"
headers["Upgrade-Insecure-Requests"] = "1"
# Curl Config
timeout = get_config("network.timeout")
browser = get_config("security.browser")
async def _do_request():
response = await session.get(
url,
headers=headers,
proxies=proxies,
timeout=timeout,
allow_redirects=True,
impersonate=browser,
stream=True,
)
if response.status_code != 200:
logger.error(
f"AssetsDownloadReverse: Download failed, {response.status_code}",
extra={"error_type": "UpstreamException"},
)
raise UpstreamException(
message=f"AssetsDownloadReverse: Download failed, {response.status_code}",
details={"status": response.status_code},
)
return response
return await retry_on_status(_do_request)
except Exception as e:
# Handle upstream exception
if isinstance(e, UpstreamException):
status = None
if e.details and "status" in e.details:
status = e.details["status"]
else:
status = getattr(e, "status_code", None)
if status == 401:
try:
await TokenService.record_fail(token, status, "assets_download_auth_failed")
except Exception:
pass
raise
# Handle other non-upstream exceptions
logger.error(
f"AssetsDownloadReverse: Download failed, {str(e)}",
extra={"error_type": type(e).__name__},
)
raise UpstreamException(
message=f"AssetsDownloadReverse: Download failed, {str(e)}",
details={"status": 502, "error": str(e)},
)
__all__ = ["AssetsDownloadReverse"]
+104
View File
@@ -0,0 +1,104 @@
"""
Reverse interface: list assets.
"""
from typing import Any, Dict
from curl_cffi.requests import AsyncSession
from app.core.logger import logger
from app.core.config import get_config
from app.core.exceptions import UpstreamException
from app.services.token.service import TokenService
from app.services.reverse.utils.headers import build_headers
from app.services.reverse.utils.retry import retry_on_status
LIST_API = "https://grok.com/rest/assets"
class AssetsListReverse:
"""/rest/assets reverse interface."""
@staticmethod
async def request(session: AsyncSession, token: str, params: Dict[str, Any]) -> Any:
"""List assets from Grok.
Args:
session: AsyncSession, the session to use for the request.
token: str, the SSO token.
params: Dict[str, Any], the parameters for the request.
Returns:
Any: The response from the request.
"""
try:
# Get proxies
base_proxy = get_config("network.base_proxy_url")
assert_proxy = get_config("network.asset_proxy_url")
if assert_proxy:
proxies = {"http": assert_proxy, "https": assert_proxy}
else:
proxies = {"http": base_proxy, "https": base_proxy}
# Build headers
headers = build_headers(
cookie_token=token,
content_type="application/json",
origin="https://grok.com",
referer="https://grok.com/files",
)
# Curl Config
timeout = get_config("network.timeout")
browser = get_config("security.browser")
async def _do_request():
response = await session.get(
LIST_API,
headers=headers,
params=params,
proxies=proxies,
timeout=timeout,
impersonate=browser,
)
if response.status_code != 200:
logger.error(
f"AssetsListReverse: List failed, {response.status_code}",
extra={"error_type": "UpstreamException"},
)
raise UpstreamException(
message=f"AssetsListReverse: List failed, {response.status_code}",
details={"status": response.status_code},
)
return response
return await retry_on_status(_do_request)
except Exception as e:
# Handle upstream exception
if isinstance(e, UpstreamException):
status = None
if e.details and "status" in e.details:
status = e.details["status"]
else:
status = getattr(e, "status_code", None)
if status == 401:
try:
await TokenService.record_fail(token, status, "assets_list_auth_failed")
except Exception:
pass
raise
# Handle other non-upstream exceptions
logger.error(
f"AssetsListReverse: List failed, {str(e)}",
extra={"error_type": type(e).__name__},
)
raise UpstreamException(
message=f"AssetsListReverse: List failed, {str(e)}",
details={"status": 502, "error": str(e)},
)
__all__ = ["AssetsListReverse"]
+111
View File
@@ -0,0 +1,111 @@
"""
Reverse interface: upload asset.
"""
from typing import Any
from curl_cffi.requests import AsyncSession
from app.core.logger import logger
from app.core.exceptions import UpstreamException
from app.services.token.service import TokenService
from app.core.config import get_config
from app.services.reverse.utils.headers import build_headers
from app.services.reverse.utils.retry import retry_on_status
UPLOAD_API = "https://grok.com/rest/app-chat/upload-file"
class AssetsUploadReverse:
"""/rest/app-chat/upload-file reverse interface."""
@staticmethod
async def request(session: AsyncSession, token: str, fileName: str, fileMimeType: str, content: str) -> Any:
"""Upload asset to Grok.
Args:
session: AsyncSession, the session to use for the request.
token: str, the SSO token.
fileName: str, the name of the file.
fileMimeType: str, the MIME type of the file.
content: str, the content of the file.
Returns:
Any: The response from the request.
"""
try:
# Get proxies
base_proxy = get_config("network.base_proxy_url")
assert_proxy = get_config("network.asset_proxy_url")
if assert_proxy:
proxies = {"http": assert_proxy, "https": assert_proxy}
else:
proxies = {"http": base_proxy, "https": base_proxy}
# Build headers
headers = build_headers(
cookie_token=token,
content_type="application/json",
origin="https://grok.com",
referer="https://grok.com/",
)
# Build payload
payload = {
"fileName": fileName,
"fileMimeType": fileMimeType,
"content": content,
}
# Curl Config
timeout = get_config("network.timeout")
browser = get_config("security.browser")
async def _do_request():
response = await session.post(
UPLOAD_API,
headers=headers,
json=payload,
proxies=proxies,
timeout=timeout,
impersonate=browser,
)
if response.status_code != 200:
logger.error(
f"AssetsUploadReverse: Upload failed, {response.status_code}",
extra={"error_type": "UpstreamException"},
)
raise UpstreamException(
message=f"AssetsUploadReverse: Upload failed, {response.status_code}",
details={"status": response.status_code},
)
return response
return await retry_on_status(_do_request)
except Exception as e:
# Handle upstream exception
if isinstance(e, UpstreamException):
status = None
if e.details and "status" in e.details:
status = e.details["status"]
else:
status = getattr(e, "status_code", None)
if status == 401:
try:
await TokenService.record_fail(token, status, "assets_upload_auth_failed")
except Exception:
pass
raise
# Handle other non-upstream exceptions
logger.error(
f"AssetsUploadReverse: Upload failed, {str(e)}",
extra={"error_type": type(e).__name__},
)
raise UpstreamException(
message=f"AssetsUploadReverse: Upload failed, {str(e)}",
details={"status": 502, "error": str(e)},
)
__all__ = ["AssetsUploadReverse"]
+101
View File
@@ -0,0 +1,101 @@
"""Shared header builders for reverse interfaces."""
import uuid
import orjson
from typing import Dict, Optional
from app.core.config import get_config
from app.core.logger import logger
from app.services.reverse.utils.statsig import StatsigGenerator
def _build_sso_cookie(sso_token: str) -> str:
"""
Build SSO Cookie string.
"""
# Remove "sso=" prefix if present
sso_token = sso_token[4:] if sso_token.startswith("sso=") else sso_token
# SSO Cookie
cookie = f"sso={sso_token}; sso-rw={sso_token}"
# CF Clearance
cf_clearance = get_config("security.cf_clearance")
if cf_clearance:
cookie += f";cf_clearance={cf_clearance}"
return cookie
def build_headers(
cookie_token: str,
content_type: Optional[str] = None,
origin: Optional[str] = None,
referer: Optional[str] = None,
) -> Dict[str, str]:
"""
Build headers for reverse interfaces.
Args:
cookie_token: The SSO token.
content_type: Optional Content-Type value.
origin: Optional Origin value. Defaults to "https://grok.com" if not provided.
referer: Optional Referer value. Defaults to "https://grok.com/" if not provided.
Returns:
Dict[str, str]: The headers dictionary.
"""
headers = {
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Baggage": "sentry-environment=production,sentry-release=d6add6fb0460641fd482d767a335ef72b9b6abb8,sentry-public_key=b311e0f2690c81f25e2c4cf6d4f7ce1c",
"Origin": origin or "https://grok.com",
"Priority": "u=1, i",
"Referer": referer or "https://grok.com/",
"Sec-Ch-Ua": '"Google Chrome";v="136", "Chromium";v="136", "Not(A:Brand";v="24"',
"Sec-Ch-Ua-Arch": "arm",
"Sec-Ch-Ua-Bitness": "64",
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Model": "",
"Sec-Ch-Ua-Platform": '"macOS"',
"Sec-Fetch-Mode": "cors",
"User-Agent": get_config("security.user_agent"),
}
# Cookie
headers["Cookie"] = _build_sso_cookie(cookie_token)
# Content-Type and Accept/Sec-Fetch-Dest
if content_type and content_type == "application/json":
headers["Content-Type"] = "application/json"
headers["Accept"] = "*/*"
headers["Sec-Fetch-Dest"] = "empty"
elif content_type in ["image/jpeg", "image/png", "video/mp4", "video/webm"]:
headers["Content-Type"] = content_type
headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
headers["Sec-Fetch-Dest"] = "document"
else:
headers["Content-Type"] = "application/json"
headers["Accept"] = "*/*"
headers["Sec-Fetch-Dest"] = "empty"
# Sec-Fetch-Site
if headers["Origin"] == headers["Referer"]:
headers["Sec-Fetch-Site"] = "same-origin"
else:
headers["Sec-Fetch-Site"] = "same-site"
# X-Statsig-ID and X-XAI-Request-ID
headers["x-statsig-id"] = StatsigGenerator.gen_id()
headers["x-xai-request-id"] = str(uuid.uuid4())
# Print headers without Cookie
safe_headers = dict(headers)
if "Cookie" in safe_headers:
safe_headers["Cookie"] = "<redacted>"
logger.debug(f"Built headers: {orjson.dumps(safe_headers, indent=2)}")
return headers
__all__ = ["build_headers"]
+229
View File
@@ -0,0 +1,229 @@
"""
Reverse retry utilities.
"""
import asyncio
import random
from typing import Callable, Any, Optional
from app.core.logger import logger
from app.core.config import get_config
from app.core.exceptions import UpstreamException
class RetryContext:
"""Retry context."""
def __init__(self):
self.attempt = 0
self.max_retry = int(get_config("retry.max_retry"))
self.retry_codes = get_config("retry.retry_status_codes")
self.last_error = None
self.last_status = None
self.total_delay = 0.0
self.retry_budget = float(get_config("retry.retry_budget"))
# Backoff parameters
self.backoff_base = float(get_config("retry.retry_backoff_base"))
self.backoff_factor = float(get_config("retry.retry_backoff_factor"))
self.backoff_max = float(get_config("retry.retry_backoff_max"))
# Decorrelated jitter state
self._last_delay = self.backoff_base
def should_retry(self, status_code: int) -> bool:
"""Check if should retry."""
if self.attempt >= self.max_retry:
return False
if status_code not in self.retry_codes:
return False
if self.total_delay >= self.retry_budget:
return False
return True
def record_error(self, status_code: int, error: Exception):
"""Record error information."""
self.last_status = status_code
self.last_error = error
self.attempt += 1
def calculate_delay(self, status_code: int, retry_after: Optional[float] = None) -> float:
"""
Calculate backoff delay time.
Args:
status_code: HTTP status code
retry_after: Retry-After header value (seconds)
Returns:
Delay time (seconds)
"""
# Use Retry-After if available
if retry_after is not None and retry_after > 0:
delay = min(retry_after, self.backoff_max)
self._last_delay = delay
return delay
# Use decorrelated jitter for 429
if status_code == 429:
# decorrelated jitter: delay = random(base, last_delay * 3)
delay = random.uniform(self.backoff_base, self._last_delay * 3)
delay = min(delay, self.backoff_max)
self._last_delay = delay
return delay
# Use exponential backoff + full jitter for other status codes
exp_delay = self.backoff_base * (self.backoff_factor**self.attempt)
delay = random.uniform(0, min(exp_delay, self.backoff_max))
return delay
def record_delay(self, delay: float):
"""Record delay time."""
self.total_delay += delay
def extract_retry_after(error: Exception) -> Optional[float]:
"""
Extract Retry-After value from exception.
Args:
error: Exception object
Returns:
Retry-After value (seconds), or None
"""
if not isinstance(error, UpstreamException):
return None
details = error.details or {}
# Try to get Retry-After from details
retry_after = details.get("retry_after")
if retry_after is not None:
try:
return float(retry_after)
except (ValueError, TypeError):
pass
# Try to get Retry-After from headers
headers = details.get("headers", {})
if isinstance(headers, dict):
retry_after = headers.get("Retry-After") or headers.get("retry-after")
if retry_after is not None:
try:
return float(retry_after)
except (ValueError, TypeError):
pass
return None
async def retry_on_status(
func: Callable,
*args,
extract_status: Callable[[Exception], Optional[int]] = None,
on_retry: Callable[[int, int, Exception, float], None] = None,
**kwargs,
) -> Any:
"""
Generic retry function.
Args:
func: Retry function
*args: Function arguments
extract_status: Function to extract status code from exception
on_retry: Callback function for retry (attempt, status_code, error, delay)
**kwargs: Function keyword arguments
Returns:
Function execution result
Raises:
Last failed exception
"""
ctx = RetryContext()
# Status code extractor
if extract_status is None:
def extract_status(e: Exception) -> Optional[int]:
if isinstance(e, UpstreamException):
# Try to get status code from details, fallback to status_code attribute
if e.details and "status" in e.details:
return e.details["status"]
return getattr(e, "status_code", None)
return None
while ctx.attempt <= ctx.max_retry:
try:
result = await func(*args, **kwargs)
# Record log
if ctx.attempt > 0:
logger.info(
f"Retry succeeded after {ctx.attempt} attempts, "
f"total delay: {ctx.total_delay:.2f}s"
)
return result
except Exception as e:
# Extract status code
status_code = extract_status(e)
if status_code is None:
# Error cannot be identified as retryable
logger.error(f"Non-retryable error: {e}")
raise
# Record error
ctx.record_error(status_code, e)
# Check if should retry
if ctx.should_retry(status_code):
# Extract Retry-After
retry_after = extract_retry_after(e)
# Calculate delay
delay = ctx.calculate_delay(status_code, retry_after)
# Check if exceeds budget
if ctx.total_delay + delay > ctx.retry_budget:
logger.warning(
f"Retry budget exhausted: {ctx.total_delay:.2f}s + {delay:.2f}s > {ctx.retry_budget}s"
)
raise
ctx.record_delay(delay)
logger.warning(
f"Retry {ctx.attempt}/{ctx.max_retry} for status {status_code}, "
f"waiting {delay:.2f}s (total: {ctx.total_delay:.2f}s)"
+ (f", Retry-After: {retry_after}s" if retry_after else "")
)
# Callback
if on_retry:
on_retry(ctx.attempt, status_code, e, delay)
await asyncio.sleep(delay)
continue
else:
# Not retryable or retry budget exhausted
if status_code in ctx.retry_codes:
logger.error(
f"Retry exhausted after {ctx.attempt} attempts, "
f"last status: {status_code}, total delay: {ctx.total_delay:.2f}s"
)
else:
logger.error(f"Non-retryable status code: {status_code}")
# Raise last failed exception
raise
__all__ = [
"RetryContext",
"retry_on_status",
"extract_retry_after",
]
+59
View File
@@ -0,0 +1,59 @@
"""
Statsig ID generator for reverse interfaces.
"""
import base64
import random
import string
from app.core.config import get_config
from app.core.logger import logger
STATIC_STATSIG_ID = "ZTpUeXBlRXJyb3I6IENhbm5vdCByZWFkIHByb3BlcnRpZXMgb2YgdW5kZWZpbmVkIChyZWFkaW5nICdjaGlsZE5vZGVzJyk="
class StatsigGenerator:
"""Statsig ID generator for reverse interfaces."""
@staticmethod
def _rand(length: int, alphanumeric: bool = False) -> str:
"""Generate random string."""
chars = (
string.ascii_lowercase + string.digits
if alphanumeric
else string.ascii_lowercase
)
return "".join(random.choices(chars, k=length))
@staticmethod
def gen_id() -> str:
"""
Generate Statsig ID.
Returns:
Base64 encoded ID.
"""
dynamic = get_config("chat.dynamic_statsig")
# Dynamic Statsig ID
if dynamic:
logger.debug("Generating dynamic Statsig ID")
if random.choice([True, False]):
rand = StatsigGenerator._rand(5, alphanumeric=True)
message = f"e:TypeError: Cannot read properties of null (reading 'children['{rand}']')"
else:
rand = StatsigGenerator._rand(10)
message = (
f"e:TypeError: Cannot read properties of undefined (reading '{rand}')"
)
return base64.b64encode(message.encode()).decode()
# Static Statsig ID
logger.debug("Generating static Statsig ID")
return STATIC_STATSIG_ID
__all__ = ["StatsigGenerator"]