fischer-agentkit/docs/solutions/conventions/any-and-except-exception-go...

164 lines
6.1 KiB
Markdown
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.

---
title: "Any 类型与 except Exception 系统性治理策略"
date: 2026-07-01
category: conventions/
module: 全局(跨模块)
problem_type: convention
component: tooling
severity: low
applies_when:
- 消除 Any 类型残留
- 窄化 except Exception
- Pydantic v2 模型字段类型注解
- 异常捕获分类
tags: [type-safety, any-governance, except-classification, pydantic-v2, tech-debt]
---
# Any 类型与 except Exception 系统性治理策略
## Context
项目禁止使用 `any` 类型AGENTS.md但历史代码中积累了大量 `Any` 类型残留(~1000 处)和宽泛的 `except Exception`~440 处)。这些残留导致:
- 类型安全缺失IDE 无法提供有效提示
- 异常被静默吞没,线上问题难以诊断
- 代码审查时类型信息不足
本约定记录了在 PR #8-#11 中治理 1214+ 处残留时建立的策略,覆盖 `Any` 替代和 `except Exception` 分类两个维度。
## Guidance
### 1. Any 类型替代优先级(按优先级降序)
```python
# 1. TypeAlias — 用于有明确语义的 dict非 Pydantic 模型字段)
from typing import TypeAlias
MetadataValue: TypeAlias = str | int | float | bool | list["MetadataValue"] | dict[str, "MetadataValue"] | None
MetadataDict: TypeAlias = dict[str, MetadataValue]
# 2. object — 最严格任意类型,适用于 dict/list/kwargs/返回值
def process(data: dict[str, object], **kwargs: object) -> object: ...
# 3. TYPE_CHECKING Protocol — 用于外部依赖对象Redis/LLM gateway 等)
from typing import TYPE_CHECKING, Protocol
if TYPE_CHECKING:
class _RedisLike(Protocol):
async def get(self, key: str) -> bytes | None: ...
async def set(self, key: str, value: str, ex: int | None = None) -> None: ...
# 4. TYPE_CHECKING import + 字符串注解 — 避免循环引用
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from agentkit.core.base import BaseAgent
# 5. Coroutine[Any, Any, Any] → Coroutine[object, object, object]
# 6. Callable[..., Awaitable[Any]] → Callable[..., Awaitable[object]]
```
### 2. except Exception 分类策略
按调用场景分类异常元组,而非一刀切:
```python
# WebSocket/网络 ops
except (ConnectionError, RuntimeError, asyncio.TimeoutError) as e:
# DB/Store/Redis
except (ConnectionError, OSError, asyncio.TimeoutError, ValueError, KeyError, RuntimeError) as e:
# SQLAlchemy DB 操作 — 用 DBAPIError不继承 ConnectionError
from sqlalchemy.exc import DBAPIError
except (DBAPIError, ValueError, KeyError, RuntimeError) as e:
# HTTP 请求 — httpx.HTTPError 涵盖 HTTPStatusError + RequestError
except (httpx.HTTPError, ValueError, KeyError, TypeError) as e:
# JSON 解析
except (json.JSONDecodeError, ValueError, TypeError) as e:
# SQLite
except (aiosqlite.Error, ValueError, KeyError, TypeError, RuntimeError) as e:
```
**框架边界保留**FastAPI 请求处理器、WebSocket 主循环、CLI 主入口仍需 `except Exception` 兜底,但**必须加 CancelledError 守卫**
```python
try:
await handle_websocket(ws)
except asyncio.CancelledError:
raise # 必须传播取消信号
except Exception: # noqa: BLE001 — 框架边界兜底
logger.exception("WebSocket handler failed")
```
### 3. 设计意图保留
某些方法的设计意图就是"任何异常都回退到默认值",不应窄化:
- `health_check()` — 任何异常返回 False
- `list_sources()` — 任何异常回退到默认信息源
- 文档解析器PDF/Docx/Xlsx— 任何解析失败回退到文本
这些方法保留 `except Exception` + `asyncio.CancelledError: raise` 守卫 + `# noqa: BLE001` 注释。
## Why This Matters
- **类型安全**`object` 强制调用方显式类型断言,避免 `Any` 导致的静默类型逃逸
- **可观测性**:窄化 except 后,未预期的异常会向上传播而非被静默吞没,便于线上问题诊断
- **Pydantic v2 兼容**:递归 TypeAlias 在 Pydantic v2 中会导致 RecursionError见下文陷阱需用 `dict[str, object]` 替代
- **CancelledError 安全**`except Exception` 会捕获 `asyncio.CancelledError`,导致 async 任务无法正确取消;加守卫确保取消信号传播
## When to Apply
- 新代码禁止使用 `Any`,用 `object` 或更具体的类型
- 新代码的 `except Exception` 必须评估是否可窄化;框架边界必须加 CancelledError 守卫
- 重构现有代码时,按本策略治理 `Any``except Exception`
- `health_check`/`list_sources`/解析器方法保留 `except Exception`(设计意图)
## Examples
### 陷阱Pydantic v2 递归 TypeAlias
```python
# ❌ 错误 — Pydantic v2 无法为递归命名别名构建 schema
FieldValue: TypeAlias = str | int | list["FieldValue"] | dict[str, "FieldValue"]
class Record(BaseModel):
values: dict[str, FieldValue] # RecursionError: maximum recursion depth exceeded
# ✅ 正确 — 用 objectPydantic v2 运行时正确序列化 dict/list/primitive
class Record(BaseModel):
values: dict[str, object] = PydanticField(default_factory=dict)
# ponytail: JSONB columns hold arbitrary JSON; object is the most permissive
# type and Pydantic v2 serializes dict/list/primitive values fine at runtime.
```
### 陷阱SQLAlchemy 异常不继承 ConnectionError
```python
# ❌ 错误 — SQLAlchemy 的 DBAPIError 不继承自 ConnectionError
except (ConnectionError, OSError) as e:
# 不会捕获 SQLAlchemy 的数据库错误
# ✅ 正确 — 显式导入 DBAPIError
from sqlalchemy.exc import DBAPIError
except (DBAPIError, ValueError, KeyError, RuntimeError) as e:
```
### 陷阱:测试用 side_effect=Exception(...) 模拟异常
```python
# 测试代码中常见:
mock.side_effect = Exception("Connection refused")
# 如果生产代码窄化为 except (httpx.HTTPError, ...):
# 测试会失败,因为裸 Exception 不在元组中
# 解决方案health_check 等方法保留 except Exception设计意图
# 或修改测试用 httpx.ConnectError("...") 替代
```
## Related
- AGENTS.md — 项目规则(禁止 any 类型、API Key 恒定时间比较等)
- .trae/rules/ponytail.md — Lazy senior dev 模式,最小修改原则
- PR #8 (U1-U5)、#9、#10、#11 — 治理实施的 4 个 PR