开源贡献与故障复盘
一个 `ValueError` 如何让 Agent Memory API 返回 500:从异常契约到两层回归测试
复盘 Hindsight 开源修复,统一领域异常、HTTP 语义与两层回归测试。
在一个 Agent 记忆系统中,如果调用方传入不存在的记忆类型,接口应该返回什么?
答案显然不是 500 Internal Server Error。
请求格式合法,只是业务参数不被支持,因此更合理的响应是 422 Unprocessable Entity。然而在 Hindsight 的 recall 接口中,一个普通的 ValueError 穿过 Engine 和 HTTP 两层后,最终被错误地包装成了 500。
这篇文章记录我如何定位这条异常传播链、选择正确的修复边界,并用两层回归测试锁定行为。
相关修复已通过 Hindsight PR #3062 合并到上游仓库。
问题是什么?
Hindsight 是一个面向 Agent 的记忆基础设施。调用 recall 接口时,可以通过 types 指定需要召回的记忆类型。
合法类型包括:
experience
observation
world
如果传入一个不存在的类型:
POST /v1/default/banks/error_test/memories/recall
Content-Type: application/json
{
"query": "test",
"types": ["bogus"]
}
修复前,接口返回:
500 Internal Server Error
但这不是服务器自身发生故障,而是调用方传入了无法处理的业务参数。
正确结果应该是:
422 Unprocessable Entity
并返回清晰的错误信息:
Invalid fact type(s): bogus.
Must be one of: experience, observation, world
为什么 500 是个严重的错误语义?
HTTP 状态码不只是给人看的,它还会影响调用方的控制流程。
对于 Agent 或其他自动化客户端来说:
| 状态码 | 通常代表 | 调用方可能采取的行为 |
|---|---|---|
422 | 请求参数在业务上不合法 | 修正参数,不盲目重试 |
500 | 服务端发生未知故障 | 重试、告警或触发降级 |
503 | 服务暂时不可用 | 延迟后重试 |
如果一个参数错误被标记成 500,Agent 可能重复发送同一个错误请求。再完善的重试机制,也无法让非法的 types=["bogus"] 突然变得合法。
因此,这个问题表面上是“状态码错误”,本质上却是异常契约失真。
系统识别出了正确的问题,却用错误的异常类型把它表达了出来。
错误是如何传播的?
问题位于 MemoryEngine.recall_async 的 fact type 校验逻辑。
原来的代码已经能够正确识别非法类型:
invalid_types = set(fact_type) - VALID_RECALL_FACT_TYPES
if invalid_types:
raise ValueError(
f"Invalid fact type(s): {', '.join(sorted(invalid_types))}. "
f"Must be one of: {', '.join(sorted(VALID_RECALL_FACT_TYPES))}"
)
真正的问题在于:
raise ValueError(...)
这是一个普通 Python 异常,只能说明“某个值不对”,却没有携带 HTTP 或业务层需要的状态码信息。
HTTP recall 处理器已经支持项目内的业务验证异常:
OperationValidationError
当它捕获这种异常时,可以读取异常中的 status_code 和 reason,再生成正确的 HTTP 响应。
但普通 ValueError 没有匹配到这个处理分支,于是继续落入通用异常处理:
except Exception:
# 最终返回 500
完整传播过程如下:
flowchart LR
A["请求 types = ['bogus']"] --> B["MemoryEngine 校验 fact type"]
B --> C["抛出普通 ValueError"]
C --> D["HTTP 层没有匹配业务异常"]
D --> E["进入 except Exception"]
E --> F["返回 500"]
这里有一个重要的工程判断:
代码能否发现错误,与系统能否正确表达错误,是两件不同的事。
应该在哪一层修复?
定位原因后,有三个可能的方案。
方案一:在 HTTP 层捕获 ValueError
例如:
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
这种方式修改简单,但问题明显:
ValueError的含义太宽泛,其他编程错误也可能被误判为客户端错误。- 只修复 HTTP 入口,直接调用 Engine 的调用方仍然只能收到普通异常。
- HTTP 层开始推测底层异常的业务含义,边界逐渐模糊。
因此没有采用。
方案二:只在请求模型中限制类型
可以使用 Pydantic 的 Literal 或枚举,在 HTTP 请求进入 Engine 前阻止非法值。
这对 HTTP 很有效,但仍不完整。
Engine 不一定只被 HTTP 调用。内部任务、测试或者其他协议入口都可能直接调用 recall_async。如果验证只存在于请求模型中,那么绕过 HTTP 后,Engine 的异常契约仍然不一致。
方案三:在 Engine 层抛出业务验证异常
最终选择是在发现非法类型的位置,直接抛出项目已有的:
OperationValidationError
修复后的代码:
invalid_types = set(fact_type) - VALID_RECALL_FACT_TYPES
if invalid_types:
from hindsight_api.extensions.operation_validator import (
OperationValidationError,
)
raise OperationValidationError(
f"Invalid fact type(s): {', '.join(sorted(invalid_types))}. "
f"Must be one of: {', '.join(sorted(VALID_RECALL_FACT_TYPES))}",
status_code=422,
)
新的传播过程变成:
flowchart LR
A["请求 types = ['bogus']"] --> B["MemoryEngine 校验 fact type"]
B --> C["抛出 OperationValidationError(422)"]
C --> D["HTTP 层使用已有异常映射"]
D --> E["保留 reason"]
E --> F["返回 422"]
这个方案有几个优点:
- 错误在最了解其业务含义的位置被分类。
- HTTP 层不需要增加新的特殊判断。
- 其他 Engine 调用方也能获得统一的业务异常。
- 没有引入新的异常类型或处理器。
- 合法 recall 路径完全不变。
为什么要先写两层失败测试?
只写一个 HTTP 测试,能够证明最终响应是 422,却不能准确说明 Engine 的异常契约是否正确。
只写一个 Engine 测试,又无法证明 HTTP 层确实会把它映射为 422。
因此,这次修复使用了两层回归测试。
第一层:锁定 Engine 的异常契约
直接调用 recall_async:
@pytest.mark.asyncio
async def test_recall_invalid_fact_type_raises_422(
memory,
request_context,
):
with pytest.raises(OperationValidationError) as exc_info:
await memory.recall_async(
bank_id="invalid-fact-type-test",
query="test",
fact_type=["bogus"],
request_context=request_context,
)
assert exc_info.value.status_code == 422
assert exc_info.value.reason == (
"Invalid fact type(s): bogus. "
"Must be one of: experience, observation, world"
)
它验证了三件事:
- Engine 不再抛出普通
ValueError。 - 业务异常携带
422。 - 错误原因保持明确且稳定。
修复前,这个测试会因为实际抛出 ValueError 而失败。
第二层:锁定 HTTP 响应
通过真实 API 客户端发送请求:
response = await api_client.post(
"/v1/default/banks/error_test/memories/recall",
json={
"query": "test",
"types": ["bogus"],
},
)
assert response.status_code == 422
assert response.json()["detail"] == (
"Invalid fact type(s): bogus. "
"Must be one of: experience, observation, world"
)
修复前,这个测试得到的是:
Expected: 422
Actual: 500
修复后,引擎异常通过已有映射自然变成 422,不需要修改 HTTP handler。
RED → GREEN:确认测试真的覆盖了问题
回归测试不能只在修复后通过。更重要的是确认它在修复前会因为目标问题而失败。
修复前的 RED 结果是:
Engine test:
ValueError: Invalid fact type(s): bogus...
HTTP test:
assert 500 == 422
这两个失败分别对应问题的两端:
错误分类错误 → ValueError
错误响应错误 → HTTP 500
完成最小修改后,两项测试进入 GREEN:
2 passed
随后又运行已有的完整 API workflow,确认合法 recall、retain、consolidation 和其他正常路径没有受到影响。
最终重点回归结果:
3 passed
这比“手动请求一次看到 422”更可靠,因为它同时验证了内部异常契约和外部 API 行为。
这个修复没有做什么?
小型 bug fix 很容易演变成不必要的重构。因此,明确不修改什么同样重要。
这次没有:
- 修改 recall 请求 schema。
- 增加新的异常类。
- 修改 HTTP 通用异常处理器。
- 修改其他调用入口。
- 修改合法 fact type 的处理逻辑。
- 重构整个错误处理体系。
- 引入新的依赖。
最终代码改动集中在三个文件:
MemoryEngine 校验逻辑
Engine 回归测试
HTTP 集成测试
这是一种典型的低侵入修复:沿用项目既有机制,只纠正错误发生位置的异常表达。
总结
问题的根因不是 fact type 校验缺失,而是 Engine 使用普通 ValueError 表达业务验证失败,导致 HTTP 层把客户端错误误判成服务器故障。
最终修复是:
- 在共享 Engine 边界抛出
OperationValidationError。 - 显式携带
status_code=422。 - 复用已有 HTTP 异常映射。
- 通过 Engine 和 HTTP 两层回归测试锁定行为。
- 运行完整 workflow,确认合法路径不受影响。
最终,非法 recall type 从 500 正确变为 422,修复通过 Hindsight PR #3062 合并。