跳转至

数据走了四站,失败却只有一个 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() 返回 PlanPlanFailure

注意一个关键事实:原始字典不会直接成为 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 分支里 resultPlan,在 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 BaseModelLearningGoaldataclass。 为什么不直接让 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() 抛出 ValidationErrorweekly_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. 调用方拿到什么?

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

参考资料