Spaces:
Running
Running
File size: 12,266 Bytes
8a03d2c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 | """
错误快照模块
保存请求错误的完整上下文,便于调试和问题排查。
特性:
- 分层目录结构
- 完整的请求/响应链路记录
- 自动清理旧快照
- 与主日志系统集成
"""
import json
import shutil
from datetime import datetime, timedelta
from typing import Any, cast
from pathlib import Path
class ErrorSnapshotManager:
"""错误快照管理器"""
def __init__(
self,
base_dir: str = "errors",
max_snapshots: int = 100,
max_age_days: int = 7,
compress_old: bool = True
):
"""
初始化错误快照管理器
Args:
base_dir: 快照存储基础目录
max_snapshots: 最大保留快照数量
max_age_days: 快照最大保留天数
compress_old: 是否压缩旧快照
"""
self.base_dir = Path(base_dir)
self.max_snapshots = max_snapshots
self.max_age_days = max_age_days
self.compress_old = compress_old
# 确保目录存在
self.base_dir.mkdir(parents=True, exist_ok=True)
def save_snapshot(
self,
downstream_payload: dict[str, Any],
upstream_payload: dict[str, Any],
upstream_response: str,
error_type: str = "request_error",
metadata: dict[str, Any] | None = None
) -> str | None:
"""
保存错误快照
Args:
downstream_payload: 接收到的原始请求
upstream_payload: 发送给上游的请求
upstream_response: 上游返回的响应
error_type: 错误类型
metadata: 额外元数据
Returns:
快照目录路径,失败返回 None
"""
try:
# 创建时间戳目录
timestamp = datetime.now()
date_dir = timestamp.strftime("%Y-%m-%d")
time_str = timestamp.strftime("%H%M%S_%f")
# 目录结构: errors/2024-01-21/http_400_143052_123456/
snapshot_name = f"{error_type}_{time_str}"
snapshot_dir = self.base_dir / date_dir / snapshot_name
snapshot_dir.mkdir(parents=True, exist_ok=True)
# 1. 保存下游请求
self._save_json(
snapshot_dir / "1_downstream_request.json",
downstream_payload
)
# 2. 保存上游请求
self._save_json(
snapshot_dir / "2_upstream_request.json",
upstream_payload
)
# 3. 保存上游响应
resp_ext = self._save_response(
snapshot_dir / "3_upstream_response",
upstream_response
)
# 4. 保存摘要
summary: dict[str, Any] = {
"timestamp": timestamp.isoformat(),
"error_type": error_type,
"snapshot_id": snapshot_name,
"files": [
"1_downstream_request.json",
"2_upstream_request.json",
f"3_upstream_response{resp_ext}"
],
"metadata": metadata or {}
}
self._save_json(snapshot_dir / "summary.json", summary)
# 清理旧快照
self._cleanup_old_snapshots()
return str(snapshot_dir)
except Exception:
return None
def _save_json(self, path: Path, data: Any):
"""保存 JSON 文件"""
with open(path, 'w', encoding='utf-8') as f:
if isinstance(data, (dict, list)):
json.dump(data, f, ensure_ascii=False, indent=2)
else:
f.write(str(data))
def _save_response(self, path_base: Path, response: str) -> str:
"""保存响应内容,自动检测格式,返回文件后缀"""
if self._is_json(response):
try:
data = json.loads(response)
self._save_json(path_base.with_suffix('.json'), data)
return ".json"
except json.JSONDecodeError:
pass
# 保存为文本
with open(path_base.with_suffix('.txt'), 'w', encoding='utf-8') as f:
f.write(str(response))
return ".txt"
def _is_json(self, text: str) -> bool:
"""检查是否为 JSON 格式"""
# Pylance: text 已经是 str,不需要 isinstance 检查
# 但为了运行时健壮性,如果 text 可能为 None 或其他类型,可以保留,
# 但既然类型提示是 str,我们假设它就是 str。
# 如果调用者可能传 None,类型提示应该是 Optional[str]
# 兼容性处理:如果 text 为空
if not text:
return False
# 安全转换,防止传入了非字符串
text_str = str(text).strip()
return text_str.startswith('{') or text_str.startswith('[')
def _cleanup_old_snapshots(self):
"""清理旧快照"""
try:
# 获取所有日期目录
date_dirs = sorted(
[d for d in self.base_dir.iterdir() if d.is_dir()],
key=lambda x: x.name,
reverse=True
)
# 按日期清理
cutoff_date = datetime.now() - timedelta(days=self.max_age_days)
cutoff_str = cutoff_date.strftime("%Y-%m-%d")
for date_dir in date_dirs:
if date_dir.name < cutoff_str:
# 删除过期目录
shutil.rmtree(date_dir)
# 按数量清理
all_snapshots: list[dict[str, Any]] = []
for date_dir in self.base_dir.iterdir():
if date_dir.is_dir():
for snapshot_dir in date_dir.iterdir():
if snapshot_dir.is_dir():
summary_file = snapshot_dir / "summary.json"
if summary_file.exists():
try:
with open(summary_file, 'r') as f:
summary = json.load(f)
all_snapshots.append({
'path': snapshot_dir,
'timestamp': str(summary.get('timestamp', ''))
})
except Exception:
pass
# 按时间排序,删除超出数量限制的
# 显式指定 key 函数的返回类型
def sort_key(x: dict[str, Any]) -> str:
return str(x['timestamp'])
all_snapshots.sort(key=sort_key, reverse=True)
for snapshot in all_snapshots[self.max_snapshots:]:
snapshot_path = cast(Path, snapshot['path'])
shutil.rmtree(snapshot_path)
# 清理空的日期目录
for date_dir in self.base_dir.iterdir():
if date_dir.is_dir() and not any(date_dir.iterdir()):
date_dir.rmdir()
except Exception:
pass
def list_snapshots(
self,
error_type: str | None = None,
limit: int = 20
) -> list[dict[str, Any]]:
"""
列出快照
Args:
error_type: 过滤错误类型
limit: 返回数量限制
Returns:
快照摘要列表
"""
snapshots: list[dict[str, Any]] = []
try:
for date_dir in sorted(self.base_dir.iterdir(), reverse=True):
if not date_dir.is_dir():
continue
for snapshot_dir in sorted(date_dir.iterdir(), reverse=True):
if not snapshot_dir.is_dir():
continue
summary_file = snapshot_dir / "summary.json"
if not summary_file.exists():
continue
try:
with open(summary_file, 'r') as f:
summary = json.load(f)
if error_type and summary.get('error_type') != error_type:
continue
summary['path'] = str(snapshot_dir)
snapshots.append(summary)
if len(snapshots) >= limit:
return snapshots
except Exception:
continue
except Exception:
pass
return snapshots
def get_snapshot(self, snapshot_path: str) -> dict[str, Any] | None:
"""
获取完整快照内容
Args:
snapshot_path: 快照目录路径
Returns:
快照完整内容
"""
try:
snapshot_dir = Path(snapshot_path)
if not snapshot_dir.exists():
return None
result: dict[str, Any] = {}
for file in snapshot_dir.iterdir():
if file.suffix == '.json':
with open(file, 'r', encoding='utf-8') as f:
result[file.stem] = json.load(f)
elif file.suffix == '.txt':
with open(file, 'r', encoding='utf-8') as f:
result[file.stem] = f.read()
return result
except Exception:
return None
# ==================== 全局实例 ====================
_snapshot_manager: ErrorSnapshotManager | None = None
def _get_manager() -> ErrorSnapshotManager:
"""获取或创建快照管理器"""
global _snapshot_manager
if _snapshot_manager is None:
try:
from src.core.config import load_config
config = load_config()
base_dir = config.get("error_dir", "errors")
except Exception:
base_dir = "errors"
_snapshot_manager = ErrorSnapshotManager(base_dir=base_dir)
return _snapshot_manager
# ==================== 便捷函数 ====================
def save_error_snapshot(
downstream_payload: dict[str, Any],
upstream_payload: dict[str, Any],
upstream_response: str,
error_type: str = "request_error"
) -> str | None:
"""
保存请求错误快照
这是主要的对外接口,保持与原有 API 兼容。
Args:
downstream_payload: 接收到的原始请求 (Gemini 格式)
upstream_payload: 最终发送给 Google 的 Payload
upstream_response: 上游返回的原始错误信息
error_type: 错误类别 (如 http_400, transform_error 等)
Returns:
快照目录路径,失败返回 None
"""
manager = _get_manager()
return manager.save_snapshot(
downstream_payload=downstream_payload,
upstream_payload=upstream_payload,
upstream_response=upstream_response,
error_type=error_type
)
def list_error_snapshots(
error_type: str | None = None,
limit: int = 20
) -> list[dict[str, Any]]:
"""
列出错误快照
Args:
error_type: 过滤错误类型
limit: 返回数量限制
Returns:
快照摘要列表
"""
manager = _get_manager()
return manager.list_snapshots(error_type=error_type, limit=limit)
def get_error_snapshot(snapshot_path: str) -> dict[str, Any] | None:
"""
获取完整错误快照
Args:
snapshot_path: 快照目录路径
Returns:
快照完整内容
"""
manager = _get_manager()
return manager.get_snapshot(snapshot_path)
|