数据走了四站,失败却只有一个 None¶
前三章分别建立了三件事:type hints 和运行时边界各管什么(P01)、业务状态如何 决定公开类型(P02)、模块依赖方向从哪里指向哪里(P03)。但到目前为止,这些 概念还没有同时出现在一条完整的执行路径上。
现在让我们回到 AI 学习助手。一条正常请求的旅程大致如此:
- 浏览器提交一段 JSON;
- HTTP adapter 用 Pydantic 模型校验并转换为业务对象;
- 业务对象进入
build_plan(); build_plan()返回一个Plan。
看起来很清楚。但再想一步:如果第 2 步校验失败了呢?如果第 3 步里业务规则 判定时间不够呢?如果第 3 步里有一行代码写错了,访问了不存在的键呢?
三种情况都会让请求没有正常结果,但它们发生在不同位置、属于不同责任、需要
调用方做不同的事。如果函数统一返回 None,调用方看着这个 None,能知道
数据根本没通过校验、还是业务规则拒绝了它、还是程序出了 bug?
答案是不能。而这正是本章要解决的问题:成功和失败如何沿着同一条数据流保持 可区分的语义。
跨章复习检查点¶
在开始之前,暂停回忆一下前三章的核心判断。试着不看正文回答:
- 一条外部输入从浏览器到 core,经过了哪两道不同性质的契约?(P01:静态类型 契约在开发期由工具检查;运行时边界在程序执行时拦截不可信数据。)
build_plan()的返回类型为什么写成Plan | PlanFailure而不是裸Plan? (P02:调用方需要区分成功和时间不足两种业务状态,签名必须保留这个分支。)- HTTP adapter 依赖 core,还是 core 依赖 adapter?(P03:adapter 依赖 core 和 contracts;core 不反向依赖任何 adapter。)
如果你能流利回答这三个问题,下面的数据流追踪会顺畅许多。如果某个回忆模糊, 可以回到对应章节浏览小结后再继续。
运行环境与材料说明¶
本章代码使用 Python 3.12 和 Pydantic v2。示例只涉及数据校验、函数签名和
返回值,不绑定任何 Web 框架。你可以在任意安装了 pydantic>=2.0 的环境中
运行本章代码片段。
贯穿案例沿用前三章的 AI 学习助手:用户提交学习目标,系统生成学习计划。
1. 一条请求的完整旅程¶
让我们先把正常路径画清楚。以下是当前 AI 学习助手的文件结构(延续 P03):
learning_planner/
├── contracts.py # LearningGoal, Plan, PlanFailure
├── core.py # build_plan()
└── http_adapter.py # handle_http()
各文件的关键片段:
# contracts.py
from dataclasses import dataclass
@dataclass(frozen=True)
class LearningGoal:
topic: str
weekly_hours: int
@dataclass(frozen=True)
class Plan:
topic: str
daily_minutes: int
@dataclass(frozen=True)
class PlanFailure:
reason: str
shortfall_minutes: int
# core.py
from learning_planner.contracts import LearningGoal, Plan, PlanFailure
MINIMUM_DAILY_MINUTES = 15
def build_plan(goal: LearningGoal) -> Plan | PlanFailure:
daily_minutes = goal.weekly_hours * 60 // 7
if daily_minutes < MINIMUM_DAILY_MINUTES:
return PlanFailure(
reason="insufficient_time",
shortfall_minutes=MINIMUM_DAILY_MINUTES - daily_minutes,
)
return Plan(topic=goal.topic, daily_minutes=daily_minutes)
# http_adapter.py
from pydantic import BaseModel
from learning_planner.contracts import LearningGoal
from learning_planner.core import build_plan
class GoalRequest(BaseModel):
topic: str
weekly_hours: int
def handle_http(payload: dict) -> dict:
request = GoalRequest.model_validate(payload) # 运行时边界
goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
result = build_plan(goal)
if isinstance(result, Plan):
return {"status": "ok", "topic": result.topic, "daily_minutes": result.daily_minutes}
return {"status": "failed", "reason": result.reason}
现在排列数据经过的四个节点:
暂停预测:每条箭头传递的是什么?
- 第一条箭头:原始字典
{"topic": "...", "weekly_hours": 6} - 第二条箭头:经过校验的
GoalRequest实例转换为LearningGoaldataclass - 第三条箭头:
LearningGoal作为参数传入build_plan() - 第四条箭头:
build_plan()返回Plan或PlanFailure
注意一个关键事实:原始字典不会直接成为 build_plan() 的输入。在
GoalRequest.model_validate() 这一步,数据从"外部不可信"变成"已校验且
有类型"。这就是转换位置——边界模型完成自己的职责后,业务对象才能假设数据
已经合法。
2. 三种失败,三个位置¶
正常路径只有一条;失败路径至少有三条。让我们逐个放进数据流。
失败 A:缺少字段¶
用户提交的 JSON 少了 weekly_hours:
这条数据走到哪里就会停下?
预测一下,然后看行为:
>>> GoalRequest.model_validate({"topic": "Python typing"})
# ValidationError: 1 validation error for GoalRequest
# weekly_hours
# Field required [type=missing, ...]
停在第一条箭头和第二条箭头之间——运行时边界。数据还没进入 LearningGoal,
更没进入 build_plan()。
这种失败的特征是:数据形状不满足边界模型的要求。负责拦截的是 adapter 中 的 Pydantic 模型,不是 core。
失败 B:数据有效但时间不足¶
用户提交的 JSON 完全合法:
GoalRequest 校验通过,LearningGoal 正常创建。但 build_plan() 发现
每天只能分到 8 分钟——不够最低 15 分钟的门槛。
>>> goal = LearningGoal(topic="Python typing", weekly_hours=1)
>>> build_plan(goal)
PlanFailure(reason='insufficient_time', shortfall_minutes=7)
停在第三条箭头之后—— build_plan() 内部。数据形状没问题,是业务规则
判定当前输入无法满足计划要求。
这种失败的负责方是 core,不是 adapter。它是一种可预期的业务结果,和成功
一样属于 build_plan() 的公开契约。
失败 C:KeyError¶
假设某个开发者在 build_plan() 内部犯了错——用字典访问了一个不存在的键:
def build_plan(goal: LearningGoal) -> Plan | PlanFailure:
config = {"minimum": 15}
threshold = config["min_daily"] # KeyError! 键名拼错
...
这不是边界拒绝数据,也不是业务规则说"不行"。这是程序内部的实现缺陷——
代码写错了。它发生在 core 内部,但不属于 build_plan() 声明的公开结果。
现在把三种失败放到数据流图上:
浏览器 JSON ──→ GoalRequest ──→ LearningGoal ──→ build_plan ──→ Plan | PlanFailure
│ │
▼ ▼
失败 A(边界拒绝) 失败 B(业务规则拒绝)
│
失败 C(实现缺陷,未声明)
三种失败的区分不是按"严重程度",而是按发生阶段和负责模块:
| 失败 | 发生位置 | 负责模块 | 是否属于公开契约 |
|---|---|---|---|
| A 缺少字段 | 运行时边界 | adapter | 是(校验错误) |
| B 时间不足 | 业务逻辑内部 | core | 是(PlanFailure) |
| C KeyError | 业务逻辑内部 | core(但非设计意图) | 否(实现缺陷) |
如果你有 ETL 经验,可以这样类比:失败 A 像数据源格式不对,清洗阶段就拒绝了; 失败 B 像数据格式正确但不满足业务校验规则,被正常标记为"不合格记录";失败 C 像 pipeline 代码本身有 bug,某一步抛了意外异常。
3. None 丢掉了什么¶
分类存在了,但调用方如何区分?
假设有人把 build_plan() 的签名改成这样:
成功时返回 Plan,"出问题"时返回 None。调用方代码大概是:
暂停想一下:调用方拿到 None 后,它能回答以下哪些问题?
- 是用户给的数据不对吗?——不知道,
None没说。 - 是业务规则拒绝了吗?差了多少分钟?——不知道。
- 是程序 bug 吗?——不知道。
- 应该告诉用户"请修改输入"还是"系统出错请稍后重试"?——猜不出来。
None 把三种完全不同的情况压成了同一个值。调用方无法根据它做出正确的后续
动作。这就是信息丢失。
那么换成返回一个通用字典呢?
def build_plan(goal: LearningGoal) -> Plan | dict:
...
return {"error": "something went wrong", "code": 42}
字典没有固定形状。调用方不知道 "error" 和 "code" 是不是一定存在,也不知道
"code" 的值域。它可以包含任何东西,也就等于什么都没承诺。静态类型检查器
在这里帮不上忙——dict 的键和值都是运行时才能确定的。
满足调用方分支的最小语义¶
调用方至少需要做三件不同的事:
- 成功——使用
Plan里的数据; - 业务失败——告诉用户原因和差额;
- 实现缺陷——不伪装成正常结果,让错误可追踪。
要满足前两件,build_plan() 的公开签名至少需要:
调用方可以用 isinstance 分支:
result = build_plan(goal)
if isinstance(result, Plan):
# 成功路径
show_plan(result)
elif isinstance(result, PlanFailure):
# 业务失败路径
show_failure(result.reason, result.shortfall_minutes)
类型检查器知道在 if 分支里 result 是 Plan,在 elif 分支里是
PlanFailure。调用方不需要猜。
至于失败 C——实现缺陷不应该被 build_plan() 捕获并伪装成某种业务结果。
KeyError 直接作为异常向上传播,调用方的最外层或框架级别的错误处理能
看到它、记录它、报告它。它不是 build_plan() 承诺的公开结果,也不应该是。
这里形成了一个判断原则:公开签名表达正常结果和可预期的业务失败;实现缺陷 不进入公开签名,而是作为未捕获异常暴露出来。
4. 边界模型要不要直接传给 core?¶
到目前为止,http_adapter.py 里有一步显式转换:
request = GoalRequest.model_validate(payload)
goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
result = build_plan(goal)
GoalRequest 是 Pydantic BaseModel,LearningGoal 是 dataclass。
为什么不直接让 build_plan() 接收 GoalRequest?
方案 A:core 直接使用边界模型¶
# core.py(方案 A)
from learning_planner.http_adapter import GoalRequest # 注意这行
from learning_planner.contracts import Plan, PlanFailure
def build_plan(request: GoalRequest) -> Plan | PlanFailure:
daily_minutes = request.weekly_hours * 60 // 7
...
这样 adapter 不需要显式转换——少写两行代码。但看看 import 箭头:
这正是 P03 里诊断过的反向依赖。如果将来加一个 CLI adapter,它没有 Pydantic 模型,core 怎么办?
还有另一个问题:GoalRequest 是 Pydantic BaseModel,带有
.model_validate()、.model_dump() 等所有 Pydantic 方法。core 的单元测试
需要构造一个 GoalRequest 实例来调用 build_plan()——这意味着测试 core
也必须安装 pydantic 并了解其 API。
方案 B:adapter 转换为独立业务对象¶
# core.py(方案 B)
from learning_planner.contracts import LearningGoal, Plan, PlanFailure
def build_plan(goal: LearningGoal) -> Plan | PlanFailure:
daily_minutes = goal.weekly_hours * 60 // 7
...
import 箭头:
core 只依赖 contracts,不知道外面用的是 Pydantic、msgspec 还是手写解析。
代价是 adapter 必须写一步显式转换。
在最小服务约束下怎么选?¶
当前 AI 学习助手只有一个 adapter、两个字段、一个业务函数。在这个规模下, 两种方案的实际差异很小。但判断依据不应该只看当前行数:
| 维度 | 方案 A(直接复用) | 方案 B(独立对象) |
|---|---|---|
| 转换成本 | 无 | adapter 多两行赋值 |
| 框架耦合 | core 依赖 Pydantic | core 无框架依赖 |
| 新 adapter | core 签名需改 | core 不变 |
| 测试 core | 需要 Pydantic | 只需 dataclass |
什么时候方案 A 也可以接受?如果项目确定只有一种入口、不会更换校验框架、 并且团队对此有共识——那多一层转换确实是纯成本。
但注意:这是一个条件性判断,不是"永远用方案 B"的绝对规则。本章的目标 不是规定所有项目必须用独立 domain object,而是让你能说清楚:选择的代价是什么、 什么变化会让当前选择不再合理。
5. catch-all:一行代码抹掉所有区分¶
有人看到前面的三类失败觉得麻烦,想用一种"安全"的写法统一处理:
# http_adapter.py(危险版本)
def handle_http(payload: dict) -> dict:
try:
request = GoalRequest.model_validate(payload)
goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
result = build_plan(goal)
if isinstance(result, Plan):
return {"status": "ok", "topic": result.topic, "daily_minutes": result.daily_minutes}
return {"status": "failed", "reason": result.reason}
except Exception:
return None
except Exception: return None 把 try 块里所有异常——无论是 Pydantic 的
ValidationError、core 返回的正常 PlanFailure(虽然这不会抛异常),还是
内部 KeyError——全部压成同一个 None。
让我们逐条检查它破坏了什么:
对失败 A(边界校验):ValidationError 被 catch,调用方拿到 None。
用户不知道是哪个字段缺了、格式错了还是系统出了问题。adapter 的校验职责
被抹掉了。
对失败 C(实现缺陷):KeyError 被 catch,调用方同样拿到 None。
开发者不知道程序有 bug——没有 traceback、没有日志、没有报警。缺陷被掩盖
了,可能持续影响所有用户直到有人凑巧查看数据异常。
对模块职责:adapter 本应负责报告校验失败的细节,core 本应让实现缺陷
暴露出来。except Exception 把这两种完全不同的责任统一"解决"为沉默。
对公开契约:函数签名本来承诺返回有结构的响应字典。现在它可能返回
None,而 None 不出现在任何公开类型声明里。静态契约在这一刻失效了。
最小修正方向不是写更复杂的异常层次结构(那属于后续课程),而是不要把边界 和 core 包在同一个 try/except 里。让边界失败由 adapter 在调用 core 之前 处理,让 core 内部的实现缺陷自然传播。
def handle_http(payload: dict) -> dict:
try:
request = GoalRequest.model_validate(payload)
except ValidationError as e:
return {"status": "validation_error", "detail": str(e)}
goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
result = build_plan(goal)
# build_plan 内部的 KeyError 不被捕获——它应该暴露为未处理异常
if isinstance(result, Plan):
return {"status": "ok", "topic": result.topic, "daily_minutes": result.daily_minutes}
return {"status": "failed", "reason": result.reason}
这不是"最终的错误架构"——完整的错误处理、HTTP 状态码映射和日志策略不在 本课范围内。但仅仅把边界校验和业务调用分开,就已经保住了三种失败各自的 可见性。
6. 迁移:一个合法但重复的目标名称¶
到这里你已经能追踪一条数据流、放置三类失败、为公开语义做选择。现在换一个 新情境测试这些判断。
需求变更:产品要求"同一个用户不能创建同名的学习目标"。当用户提交
{"topic": "Python typing", "weekly_hours": 6} 时,如果他已经有一个叫
"Python typing" 的目标,应该拒绝。
暂停回答三个问题:
- 这个失败属于哪一类——边界拒绝(A)、业务失败(B)、还是实现缺陷(C)?
- 在当前的数据流中,它应该在哪个节点停下?
build_plan()的公开签名需要变化吗?
现在逐步分析。
"Python typing"作为字符串完全合法——它不缺字段,不是空白,格式正确。
GoalRequest.model_validate() 会通过。所以它不是边界拒绝。
它也不是 bug——代码没写错,逻辑也没有运行时错误。所以它不是实现缺陷。
它更像是一个业务规则:"同一用户不能重复创建同名目标"。这听起来像失败 B。
但再想一步:判断"是否重复"需要什么信息?需要知道用户已有哪些目标。 这些信息在哪里?在数据库里。
而当前 AI 学习助手没有数据库。build_plan() 只接收一个 LearningGoal,
它无法查询历史数据。
这意味着"重复目标"这个失败在当前的数据流里没有位置。它依赖的事实 (历史记录)还不存在于系统中。
正确的判断是:这是一个未来的业务失败,它需要持久化层(数据库)提供事实 支撑。在当前最小服务中,它属于范围外。如果要实现它,需要先引入数据库—— 而数据库不在本课范围内。
这个练习的要点不是"记住重复属于 B 类",而是:分类失败需要检查它依赖的 事实是否已经存在于当前系统中。如果事实不存在,功能就不在当前范围内, 不应该勉强塞进现有数据流。
边界与常见误区¶
本章建立的是数据流追踪和失败归属的判断框架。以下是它不意味着的东西:
-
不是完整的错误架构。能把三个示例放到正确位置,不等于已经设计好了所有 可能的错误类型、异常层次和日志策略。完整的错误设计需要考虑多层调用、 外部服务依赖和监控——这些属于后续课程。
-
不是"永远用独立 domain object"。方案 B 在最小服务中有明确的好处, 但如果一个项目确实只有单入口且不会更换框架,方案 A 的成本可能完全可以 接受。判断依据是条件,不是教条。
-
不要把 Pydantic 模型传遍所有模块。即使你选择方案 A(core 接收边界 模型),也应该意识到这是一个有条件的取舍。把
BaseModel作为所有模块 间的通用数据容器会让每个模块都耦合到 Pydantic——框架升级时所有模块 都要动。 -
不要返回未声明形状的字典。
return {"error": msg}看起来灵活,但 它放弃了类型系统能提供的所有协助。调用方不知道字典里有什么键,类型检查器 也帮不上忙。
本章小结¶
一条外部请求从浏览器到最终结果,经过四个节点:
在这条路径上,三种失败各有位置和责任:
- 边界拒绝——数据形状不对,在运行时边界停下,由 adapter 负责;
- 业务失败——数据形状合法但业务规则不满足,在 core 内部停下,作为公开 契约的一部分返回;
- 实现缺陷——代码有 bug,在 core 内部抛出未声明的异常,不伪装成正常 结果。
公开签名表达成功和可预期失败(Plan | PlanFailure);实现缺陷作为异常暴露。
None 和通用字典都会丢失调用方需要的区分信息。
边界模型与业务对象的复用或转换是耦合与成本的条件性取舍,不是绝对规则。
except Exception: return None 把边界、业务和缺陷三种失败压成同一个结果,
破坏了模块职责、公开契约和缺陷可见性。
判断一种新失败属于哪一类时,先检查它依赖的事实是否已经存在于当前系统中。
练习¶
练习一:为新失败选择停止位置¶
AI 学习助手收到新需求:weekly_hours 不能超过 40(没人一周学 40 小时以上
还能坚持下去)。
请回答:
- 这个约束应该放在运行时边界还是 core 的业务规则里?
- 如果放在错误的位置,会导致什么后果?
build_plan()的公开签名需要变化吗?
练习二:验证四条路径¶
以下是一段 adapter 代码,请判断四种输入各自走到哪里停下,调用方拿到什么:
from pydantic import BaseModel, field_validator
from learning_planner.contracts import LearningGoal, Plan, PlanFailure
from learning_planner.core import build_plan
class GoalRequest(BaseModel):
topic: str
weekly_hours: int
@field_validator("weekly_hours")
@classmethod
def check_range(cls, v: int) -> int:
if v > 40:
raise ValueError("weekly_hours must be <= 40")
return v
def handle_http(payload: dict) -> dict:
request = GoalRequest.model_validate(payload)
goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
result = build_plan(goal)
if isinstance(result, Plan):
return {"status": "ok", "daily_minutes": result.daily_minutes}
return {"status": "failed", "reason": result.reason}
四种输入:
- A:
{"topic": "AI", "weekly_hours": 10}——正常输入 - B:
{"topic": "AI"}——缺少字段 - C:
{"topic": "AI", "weekly_hours": 50}——超出范围 - D:
{"topic": "AI", "weekly_hours": 1}——时间不足
请为每种输入回答:停在哪个节点?调用方拿到什么?属于哪类失败?
练习三:诊断 catch-all¶
以下代码试图"安全地"处理所有情况:
def handle_http(payload: dict) -> dict | None:
try:
request = GoalRequest.model_validate(payload)
goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
result = build_plan(goal)
if isinstance(result, Plan):
return {"status": "ok", "daily_minutes": result.daily_minutes}
return {"status": "failed", "reason": result.reason}
except Exception:
return None
假设 build_plan() 内部有一个拼写错误导致 KeyError。
请回答:
- 调用方拿到什么?
- 它能区分"用户输入有问题"和"程序有 bug"吗?
- 开发者如何发现这个 bug 的存在?
- 给出最小修正:保持三类失败各自可见,但不要求设计完整的异常层次。
练习解析¶
练习一解析¶
1. 放在哪里?
weekly_hours > 40 是一个数据有效性约束——它说的是"这个数字的取值范围
不合理"。和"缺少字段"一样,它属于输入形状的一部分:合法的 weekly_hours
是 1-40 之间的整数。
因此它应该放在运行时边界(Pydantic 模型的 validator 中),而不是 core。
为什么不放在 core?因为 core 的 build_plan() 接收的是已校验的
LearningGoal。如果 weekly_hours = 50 能通过边界进入 core,那 core
就不能假设"数据已经合法"——P01 建立的"边界后的可信前提"就被打破了。
2. 放错了会怎样?
如果放在 core 里:
- 边界放行了 weekly_hours = 50,core 必须自己检查范围,相当于在 core 里
重复做输入校验。
- 调用方拿到的 PlanFailure 可能是"时间不足"也可能是"超出范围",但这两种
失败的性质完全不同:前者是业务判断,后者是输入不合法。把它们混在一起会让
调用方困惑。
如果放在边界:
- core 可以继续假设 weekly_hours 在合理范围内。
- 校验失败作为 ValidationError 在 adapter 层报告给调用方,和"缺少字段"
同类处理。
3. 签名需要变化吗?
不需要。weekly_hours > 40 的检查在边界完成,不会到达 build_plan()。
build_plan() 的签名 LearningGoal -> Plan | PlanFailure 不受影响。
练习二解析¶
输入 A {"topic": "AI", "weekly_hours": 10}:
- GoalRequest.model_validate() 通过(10 <= 40)。
- LearningGoal(topic="AI", weekly_hours=10) 创建成功。
- build_plan() 计算:10 * 60 // 7 = 85,大于 15。
- 返回 Plan(topic="AI", daily_minutes=85)。
- 调用方拿到 {"status": "ok", "daily_minutes": 85}。
- 这是成功路径。
输入 B {"topic": "AI"}:
- GoalRequest.model_validate() 抛出 ValidationError:weekly_hours 缺失。
- 数据停在运行时边界。
- 调用方拿到未捕获的 ValidationError(当前代码没有 try/except)。
- 这是失败 A(边界拒绝)。
输入 C {"topic": "AI", "weekly_hours": 50}:
- GoalRequest.model_validate() 触发 check_range validator。
- 50 > 40,抛出 ValidationError。
- 数据停在运行时边界。
- 调用方拿到未捕获的 ValidationError。
- 这是失败 A(边界拒绝)——超出范围和缺少字段一样,都是数据不满足
边界模型要求。
输入 D {"topic": "AI", "weekly_hours": 1}:
- GoalRequest.model_validate() 通过(1 <= 40)。
- LearningGoal(topic="AI", weekly_hours=1) 创建成功。
- build_plan() 计算:1 * 60 // 7 = 8,小于 15。
- 返回 PlanFailure(reason="insufficient_time", shortfall_minutes=7)。
- 调用方拿到 {"status": "failed", "reason": "insufficient_time"}。
- 这是失败 B(业务规则拒绝)。
四条路径清楚地展示了边界和 core 各自的职责:边界检查形状和范围,core 检查 业务规则。
练习三解析¶
1. 调用方拿到什么?
None。KeyError 被 except Exception 捕获,返回 None。
2. 能区分吗?
不能。无论是 ValidationError(用户输入问题)还是 KeyError(程序 bug),
调用方都拿到同一个 None。它无法判断应该提示用户修改输入,还是通知开发者
修复代码。
3. 开发者如何发现?
很难发现。KeyError 被静默吞掉了,没有 traceback、没有日志。如果没人手动
检查为什么某些请求返回了 None,这个 bug 可能长期存在。在 ETL 场景中,
这相当于 pipeline 静默丢弃了记录但没有任何告警——数据看起来少了,但没有人
知道为什么。
4. 最小修正:
把边界校验和业务调用分开处理:
from pydantic import ValidationError
def handle_http(payload: dict) -> dict:
try:
request = GoalRequest.model_validate(payload)
except ValidationError as e:
return {"status": "validation_error", "detail": str(e)}
goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
result = build_plan(goal)
# KeyError 等实现缺陷不被捕获,自然传播为未处理异常
if isinstance(result, Plan):
return {"status": "ok", "daily_minutes": result.daily_minutes}
return {"status": "failed", "reason": result.reason}
修正后的三类失败各自可见:
- 边界校验失败:ValidationError 被捕获,返回有结构的错误响应。
- 业务失败:PlanFailure 作为正常返回值,调用方通过 isinstance 分支处理。
- 实现缺陷:KeyError 不被捕获,向上传播为未处理异常,框架或监控能看到它。
这不是最终方案——完整的错误处理还需要日志、监控、HTTP 状态码映射等。但它 保住了最基本的一点:三种失败在调用方眼中是可区分的。