一个可控的错误返回,不应该依赖上游报错的句子。本文实现一个固定目录驱动的本地错误适配器:只输出已约定字段,不序列化异常正文;同时给出一个按稳定code路由的独立客户端。
示例仅做离线控制流和JSON合同验证。它没有启动Web服务器或接入SDK,不代表某个供应商的错误码定义,也不是整套生产错误处理中间件。
环境、文件与输入输出
Python 3.11及以上,全部标准库,无需API Key或安装依赖。建立error-lab目录,保存error_contract.py、client_router.py、test_error_contract.py。运行python error_contract.py、python client_router.py和python -m unittest -v。
execute_local接收同步回调,要求成功返回str;成功时返回状态200和结果字典。适配层可以抛AppFault(reason, private_detail),reason只能从应用自己的可信分类产生。普通异常映射为固定内部错误,不回传异常字符串;BaseException不被吞掉。
render_error返回状态整数和自定义schema_version=1的字典。request_id在入口由本应用生成;函数也验证可选的内部关联ID格式。格式检查不证明真实性,不能把外部请求头直接当成可信关联记录。
完整实现:error_contract.py
"""Custom JSON contract for a synchronous local adapter, not RFC 9457."""
import json
import re
import uuid
POLICY = {
"invalid_input": (400, "APP_INPUT_INVALID", "请检查输入后再提交。", "edit_input"),
"upstream_timeout": (504, "DEPENDENCY_TIMEOUT", "暂未确认处理结果,请先查看任务状态。", "inspect_status"),
"upstream_unavailable": (503, "DEPENDENCY_UNAVAILABLE", "服务暂时不可用,请稍后查看。", "inspect_status"),
"upstream_rejected": (502, "DEPENDENCY_REJECTED", "服务依赖未能完成请求,请联系维护人员。", "contact_support"),
"invalid_response": (502, "DEPENDENCY_INVALID_RESPONSE", "暂未取得可用结果,请联系维护人员。", "contact_support"),
"internal": (500, "APP_INTERNAL", "请求未能完成,请保留编号联系维护人员。", "contact_support"),
}
class AppFault(Exception):
def __init__(self, reason, private_detail=""):
super().__init__(private_detail)
self.reason = reason
def render_error(reason, request_id=None):
# reason must be classified locally, never taken from an upstream message.
if type(reason) is not str or reason not in POLICY:
reason = "internal"
if (type(request_id) is not str or len(request_id) != 32
or re.fullmatch(r"[0-9a-f]{32}", request_id) is None):
request_id = uuid.uuid4().hex
status, code, message, next_step = POLICY[reason]
return status, {"schema_version": 1, "error": {
"code": code, "message": message, "next_step": next_step,
"request_id": request_id}}
def execute_local(work):
request_id = uuid.uuid4().hex # Server-owned ID, not a copied client header.
try:
result = work()
if type(result) is not str:
raise AppFault("invalid_response")
return 200, {"schema_version": 1, "result": result}
except AppFault as exc:
return render_error(exc.reason, request_id)
except Exception:
return render_error("internal", request_id)
# BaseException (including cancellation/exit signals) is not swallowed.
def encode_json(body):
return json.dumps(body, ensure_ascii=False, allow_nan=False,
separators=(",", ":"))
if __name__ == "__main__":
def local_failure():
raise AppFault("upstream_rejected", "SYNTHETIC_SECRET: fake provider detail")
status, body = execute_local(local_failure)
print(status, body["error"]["code"], body["error"]["next_step"])
print("private_detail_visible=", "SYNTHETIC_SECRET" in encode_json(body))
公开字段始终只有版本和error对象;error只有code、message、next_step和request_id。未知reason(包括非字符串)回退为APP_INTERNAL,避免错误处理过程中再次因为字典键类型出错。每次返回新字典,调用者修改它不会改到固定文案目录。
示例映射是应用政策:本地输入问题400、依赖超时504、依赖暂不可用503、依赖拒绝/无效结果502、通用内部错误500。真实HTTP集成必须由框架把返回状态写进响应,而不是只把状态值塞进JSON。供应商401不应未经核对就转成用户401;本应用自己的登录鉴权要在相应入口单独处理。
next_step没有触发任何动作,也没有保证重试安全。超时提示先查状态,不声称远端没有处理;即使需要下一次尝试,仍要经过任务预算、幂等和授权检查。
独立客户端:client_router.py
"""Routes a parsed, bounded custom error envelope; executes no action."""
import re
ACTIONS = {
"APP_INPUT_INVALID": "edit_input",
"DEPENDENCY_TIMEOUT": "inspect_status",
"DEPENDENCY_UNAVAILABLE": "inspect_status",
"DEPENDENCY_REJECTED": "contact_support",
"DEPENDENCY_INVALID_RESPONSE": "contact_support",
"APP_INTERNAL": "contact_support",
}
def client_action(body):
fallback = "contact_support"
if type(body) is not dict or set(body) != {"schema_version", "error"}:
return fallback
if type(body["schema_version"]) is not int or body["schema_version"] != 1:
return fallback
error = body["error"]
if type(error) is not dict or set(error) != {"code", "message", "next_step", "request_id"}:
return fallback
if any(type(v) is not str or len(v) > 200 for v in error.values()):
return fallback
if re.fullmatch(r"[0-9a-f]{32}", error["request_id"]) is None:
return fallback
# Do not interpret message text or execute next_step from the server.
return ACTIONS.get(error["code"], fallback)
if __name__ == "__main__":
for message in ("暂未确认处理结果。", "Request outcome is not confirmed."):
body = {"schema_version": 1, "error": {
"code": "DEPENDENCY_TIMEOUT", "message": message,
"next_step": "inspect_status", "request_id": "a" * 32}}
print(message, "->", client_action(body))
客户端按code路由,忽略message的措辞和服务器next_step中的动作文本。结构、版本、字段类型和关联ID格式不符时保守回退。此检查发生在JSON解析之后,不能代替读取响应前的大小限制或真正的鉴权。
完整回归:test_error_contract.py
import json
import unittest
from error_contract import AppFault, POLICY, render_error, execute_local, encode_json
from client_router import client_action
class ServerTests(unittest.TestCase):
def test_policy_status_codes(self):
expected = {"invalid_input": 400, "upstream_timeout": 504,
"upstream_unavailable": 503, "upstream_rejected": 502,
"invalid_response": 502, "internal": 500}
self.assertEqual({k: render_error(k)[0] for k in POLICY}, expected)
def test_unknown_reason_falls_back(self):
for reason in ("unrecognized", [], None, True):
self.assertEqual(render_error(reason)[1]["error"]["code"], "APP_INTERNAL")
def test_fixed_field_set(self):
for reason in POLICY:
_, body = render_error(reason)
self.assertEqual(set(body), {"schema_version", "error"})
self.assertEqual(set(body["error"]), {"code", "message", "next_step", "request_id"})
def test_domain_exception_detail_never_serialized(self):
def work(): raise AppFault("upstream_timeout", "SYNTHETIC_SECRET\nhttps://private.invalid")
_, body = execute_local(work)
self.assertNotIn("SYNTHETIC_SECRET", encode_json(body))
self.assertNotIn("private.invalid", encode_json(body))
def test_unexpected_exception_is_generic(self):
def work(): raise RuntimeError("SYNTHETIC_PRIVATE_STACK")
status, body = execute_local(work)
self.assertEqual(status, 500)
self.assertNotIn("SYNTHETIC_PRIVATE_STACK", encode_json(body))
def test_dependency_rejection_is_not_user_auth_failure(self):
self.assertEqual(render_error("upstream_rejected")[0], 502)
def test_invalid_work_result(self):
self.assertEqual(execute_local(lambda: {"raw": "x"})[1]["error"]["code"],
"DEPENDENCY_INVALID_RESPONSE")
def test_success(self):
self.assertEqual(execute_local(lambda: "ok"), (200, {"schema_version": 1, "result": "ok"}))
def test_base_exception_propagates(self):
class Stop(BaseException): pass
def work(): raise Stop()
with self.assertRaises(Stop): execute_local(work)
def test_server_id_preserved(self):
self.assertEqual(render_error("internal", "a" * 32)[1]["error"]["request_id"], "a" * 32)
def test_invalid_id_not_echoed(self):
for value in ("bad\nheader", "x" * 33, None, 9):
rid = render_error("internal", value)[1]["error"]["request_id"]
self.assertRegex(rid, r"^[0-9a-f]{32}$")
self.assertNotEqual(rid, value)
def test_json_round_trip(self):
_, body = render_error("invalid_input")
self.assertEqual(json.loads(encode_json(body)), body)
def test_return_mutation_does_not_change_policy(self):
_, body = render_error("internal")
body["error"]["message"] = "changed"
self.assertNotEqual(render_error("internal")[1]["error"]["message"], "changed")
class ClientTests(unittest.TestCase):
def test_message_changes_do_not_change_route(self):
_, body = render_error("upstream_timeout")
for message in ("中文提示", "English text", "new wording"):
body["error"]["message"] = message
self.assertEqual(client_action(body), "inspect_status")
def test_unknown_code_and_untrusted_next_step(self):
_, body = render_error("internal")
body["error"].update(code="NEW_CODE", next_step="send_payment")
self.assertEqual(client_action(body), "contact_support")
def test_bad_shapes_and_schema(self):
for body in (None, [], {}, {"schema_version": True, "error": {}},
{"schema_version": 1, "error": {"message": "x"}}):
self.assertEqual(client_action(body), "contact_support")
if __name__ == "__main__":
unittest.main()
实际运行结果
本次Python 3.11.9环境16项离线测试通过。error_contract.py实际输出:
502 DEPENDENCY_REJECTED contact_support
private_detail_visible= False
client_router.py的中文、英文两条提示均路由为inspect_status。私密标记为人工合成,未使用真实密钥、地址或用户数据;这里证明的是样本与固定构造路径,不是全系统信息泄露审计。
接入真实应用前的边界
在SDK适配层依据当前版本的异常类型、HTTP状态和已核对字段分类,不分析message里的关键词猜故障。上游body、headers、堆栈和请求原文不进入公开结构;诊断日志走另一条已审核入口,引用同一应用request_id并实施脱敏和访问控制。此脚本没有写日志,不能声称编号可在某个平台查询。
HTTP框架要另外处理响应状态、内容类型、JSON编码、缓存、认证与响应体上限;异步任务要另外保证取消传播、资源清理和后台任务的错误收集。成功返回str也不等于模型结果已通过业务校验,输出安全是另一层任务。
本例是自定义application/json合同,未实现RFC 9457。若采用标准问题详情,应按标准字段和媒体类型设计,并为现有消费者安排兼容测试,不能仅改一个内容类型名称就宣称兼容。
常见问题
用户为什么只看到通用错误?公开提示与内部证据不同,可补充经过审核的可行动提示,但不能把堆栈塞回去。未知错误为什么500?它是本例保守回退,真实业务应继续分类并审查,不应把所有输入错误永久藏成500。
为什么维护人员的问题没有让用户登录?依赖凭证/配置与用户身份是不同层;先查适配器分类。为什么改中文提示导致前端失效?检查消费者是否按message分支,改成版本和code合同。
为什么request_id没有阻止伪造?它只是关联标识,不是授权凭据;外部可见ID不能授予日志或任务访问权限。为什么测试没抛网络异常?这是固定分类后的离线测试,不验证具体SDK的异常映射,接入时需补版本固定的适配器样本。
练习与下一篇
新增一个人工未知reason样本,确认仍只返回固定字段和APP_INTERNAL;再把异常里的合成标记换一种格式,确认不进入公开JSON。不要用真实密钥做测试。
下一篇把成功、边界和失败样本整理为可重复使用的脱敏fixture。欢迎评论给出已经去掉账户、密钥和用户原文的合同需求。
原始依据与边界
OWASP错误处理指南讨论对外通用响应与内部异常处理的区分;RFC 9457定义HTTP问题详情,并提醒错误详情不应成为底层实现的调试转储。
本文采用自定义schema_version=1错误结构,便于演示既有应用的固定合同,不是RFC 9457实现;没有声称application/problem+json兼容。实际HTTP适配还要设置对应状态、内容类型及日志/鉴权/限长策略。
近期官方公告已检索并打开,但没有与本课错误返回实现直接对应的能力上新,采用常青教学。内容由AI辅助整理,示例仅在本地离线运行,没有模型、付费请求、真实生产日志或攻击测试。
387



被折叠的 条评论
为什么被折叠?



