跳转至

数据走了四站,失败却只有一个 None

前三章分别建立了三件事:type hints 和运行时边界各管什么(P01)、业务状态如何 决定公开类型(P02)、模块依赖方向从哪里指向哪里(P03)。但到目前为止,这些 概念还没有同时出现在一条完整的执行路径上。

现在让我们回到 AI 学习助手。一条正常请求的旅程大致如此:

  1. 浏览器提交一段 JSON;
  2. HTTP adapter 用 Pydantic 模型校验并转换为业务对象;
  3. 业务对象进入 build_plan();
  4. 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}

现在排列数据经过的四个节点:

浏览器 JSON → GoalRequest(运行时边界) → LearningGoal(业务对象) → build_plan → Plan | PlanFailure

暂停预测:每条箭头传递的是什么?

  • 第一条箭头:原始字典 {"topic": "...", "weekly_hours": 6}
  • 第二条箭头:经过校验的 GoalRequest 实例转换为 LearningGoal dataclass
  • 第三条箭头:LearningGoal 作为参数传入 build_plan()
  • 第四条箭头:build_plan() 返回 Plan 或 PlanFailure

注意一个关键事实:原始字典不会直接成为 build_plan() 的输入。在 GoalRequest.model_validate() 这一步,数据从"外部不可信"变成"已校验且 有类型"。这就是转换位置——边界模型完成自己的职责后,业务对象才能假设数据 已经合法。

2. 三种失败,三个位置

正常路径只有一条;失败路径至少有三条。让我们逐个放进数据流。

失败 A:缺少字段

用户提交的 JSON 少了 weekly_hours:

{"topic": "Python typing"}

这条数据走到哪里就会停下?

预测一下,然后看行为:

>>> 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 完全合法:

{"topic": "Python typing", "weekly_hours": 1}

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! 键名拼错
    ...
>>> build_plan(goal)
# KeyError: 'min_daily'

这不是边界拒绝数据,也不是业务规则说"不行"。这是程序内部的实现缺陷—— 代码写错了。它发生在 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() 的签名改成这样:

def build_plan(goal: LearningGoal) -> Plan | None:
    ...

成功时返回 Plan,"出问题"时返回 None。调用方代码大概是:

result = build_plan(goal)
if result is None:
    # 出问题了……但到底是什么问题?
    ...

暂停想一下:调用方拿到 None 后,它能回答以下哪些问题?

  • 是用户给的数据不对吗?——不知道,None 没说。
  • 是业务规则拒绝了吗?差了多少分钟?——不知道。
  • 是程序 bug 吗?——不知道。
  • 应该告诉用户"请修改输入"还是"系统出错请稍后重试"?——猜不出来。

None 把三种完全不同的情况压成了同一个值。调用方无法根据它做出正确的后续 动作。这就是信息丢失。

那么换成返回一个通用字典呢?

def build_plan(goal: LearningGoal) -> Plan | dict:
    ...
    return {"error": "something went wrong", "code": 42}

字典没有固定形状。调用方不知道 "error" 和 "code" 是不是一定存在,也不知道 "code" 的值域。它可以包含任何东西,也就等于什么都没承诺。静态类型检查器 在这里帮不上忙——dict 的键和值都是运行时才能确定的。

满足调用方分支的最小语义

调用方至少需要做三件不同的事:

  1. 成功——使用 Plan 里的数据;
  2. 业务失败——告诉用户原因和差额;
  3. 实现缺陷——不伪装成正常结果,让错误可追踪。

要满足前两件,build_plan() 的公开签名至少需要:

def build_plan(goal: LearningGoal) -> Plan | PlanFailure:
    ...

调用方可以用 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 箭头:

core → http_adapter  (core 反向依赖了 adapter!)

这正是 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 箭头:

http_adapter → core → contracts
cli_adapter  → core → contracts

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" 的目标,应该拒绝。

暂停回答三个问题:

  1. 这个失败属于哪一类——边界拒绝(A)、业务失败(B)、还是实现缺陷(C)?
  2. 在当前的数据流中,它应该在哪个节点停下?
  3. 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} 看起来灵活,但 它放弃了类型系统能提供的所有协助。调用方不知道字典里有什么键,类型检查器 也帮不上忙。

本章小结

一条外部请求从浏览器到最终结果,经过四个节点:

浏览器 JSON → 运行时边界(Pydantic 模型) → 业务对象 → core 函数 → 公开结果

在这条路径上,三种失败各有位置和责任:

  1. 边界拒绝——数据形状不对,在运行时边界停下,由 adapter 负责;
  2. 业务失败——数据形状合法但业务规则不满足,在 core 内部停下,作为公开 契约的一部分返回;
  3. 实现缺陷——代码有 bug,在 core 内部抛出未声明的异常,不伪装成正常 结果。

公开签名表达成功和可预期失败(Plan | PlanFailure);实现缺陷作为异常暴露。 None 和通用字典都会丢失调用方需要的区分信息。

边界模型与业务对象的复用或转换是耦合与成本的条件性取舍,不是绝对规则。

except Exception: return None 把边界、业务和缺陷三种失败压成同一个结果, 破坏了模块职责、公开契约和缺陷可见性。

判断一种新失败属于哪一类时,先检查它依赖的事实是否已经存在于当前系统中。

练习

练习一:为新失败选择停止位置

AI 学习助手收到新需求:weekly_hours 不能超过 40(没人一周学 40 小时以上 还能坚持下去)。

请回答:

  1. 这个约束应该放在运行时边界还是 core 的业务规则里?
  2. 如果放在错误的位置,会导致什么后果?
  3. 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。

请回答:

  1. 调用方拿到什么?
  2. 它能区分"用户输入有问题"和"程序有 bug"吗?
  3. 开发者如何发现这个 bug 的存在?
  4. 给出最小修正:保持三类失败各自可见,但不要求设计完整的异常层次。

练习解析

练习一解析

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 状态码映射等。但它 保住了最基本的一点:三种失败在调用方眼中是可区分的。

参考资料