OpenProgram Docs

Error Handling — 纪律与门禁#

什么可以被 catch,catch 之后必须说明什么,以及 linter 在哪些路径上强制执行。

1. 规则#

只 catch 你能处理的,并为你吞掉的东西留下记录。

一个不产生日志、不带注释、也没有 fallback 的 catch,与一个尚未被发现的 bug 无法区分。 静默 handler 的大部分代价是后来才付的,由那个正在琢磨"这次写入为什么没落盘"的人来付。

三条具体义务:

  1. 精确 catch。 文件 IO 抛 OSError;JSON 抛 ValueError(以及 json.JSONDecodeError);ContextVar.reset 用外部 token 抛 ValueError、用已消费的 token 抛 RuntimeError。写出这个操作实际会抛的东西,全部。
  2. 绝不吞掉程序员错误。 AttributeErrorTypeErrorNameError 这类是我们自己 代码的缺陷,不是需要容忍的状况。宽到足以掩盖它们的 handler,要么收窄到掩盖不了, 要么打日志让缺陷可见。
  3. 说明为什么。 剩下的每一处吞掉,都要带一条含上下文的日志,或者一条说明为什么忽 略它是正确的注释。理由不显然时两者都写。

2. 边界#

宽泛的 except Exception 在边界上是正当的——在那些地方,让异常继续向上传播会拖垮比 失败操作本身大得多的东西:

  • WebSocket handler 与 HTTP 路由,异常会断连接而不是返回一个错误。
  • 线程与 worker 入口,异常在那里无人观测地消失。
  • 回调调用,调用方提供的代码运行在我们的循环里。
  • 可选子系统(memory、项目 git、用量计量),缺席只是功能降级,而不是这一轮失败。
  • 尽力而为的簿记,下一轮反正会重新推导出来。

边界上的 catch 仍然要记录足够的上下文,能定位到会话或操作。"边界"是继续运行的理由, 绝不是保持沉默的理由。

3. 选日志级别#

级别 何时
warning 丢了用户可能察觉的东西:一次没落盘的写入、一个留在过期状态的 status、一条将会缺失的记录。
debug 一条可选路径按设计降级了:缺席的子系统、尽力而为的缓存、生效了的 fallback。

exc_info=True 保住 traceback,并带上 session id 或等价标识——多个 agent 同时运行 时,一条无法关联到会话的日志几乎没用。

4. 好的 handler 长什么样#

让持久写入的静默丢失变得可见:

except OSError:
    _log.warning(
        "index.json not saved for session %s", session_id, exc_info=True)

把原先掩盖缺陷的宽 catch 收窄:

except (ValueError, RuntimeError):
    # ContextVar.reset raises ValueError for a token minted in another
    # context and RuntimeError for one already spent. Narrowing to only
    # the first lets a cancelled turn's spent token escape and replace
    # the CancelledError.
    _log.debug("context var token already spent or foreign", exc_info=True)

只有先弄清全集,收窄才是对的。去查这个调用实际会抛什么:上面的 ContextVar.reset 抛 两种毫不相关的类型,只写其中一种的 handler,会把本来被吞掉的失败变成跑出去的失败。

一个可选子系统,写明理由:

except Exception:
    # Memory is optional: an unavailable provider degrades to no memory
    # block, never a failed turn.
    _log.debug("memory system prompt block unavailable", exc_info=True)

如果被保护的操作本身已经吞掉自己的错误,或者 catch 的是代码根本抛不出来的东西,这类 handler 直接删掉,而不是加注释。

5. 门禁#

ruff 强制两条规则,配置在 pyproject.toml

规则 抓什么
E722 except:,它连 KeyboardInterruptSystemExit 一起吞掉。
S110 try / except / pass——异常被丢弃,没有日志、没有注释、没有 fallback。

运行方式:

.venv/bin/ruff check .

覆盖范围#

门禁在三条核心路径上强制执行,这三条目前是干净的:

  • openprogram/store/ — 持久记录
  • openprogram/context/ — 模型看到的东西
  • openprogram/agent/dispatcher/ — 一轮执行本身

它们承载的状态一旦被静默损坏,事后最难诊断。其他位置通过 per-file-ignores 静音,因 为几百处存量点会让门禁根本过不了,也就等于没人理它。静音列表就是待办清单:清理干净一 个目录,删掉它那一行,门禁就覆盖它。

per-file-ignores 的 pattern 匹配的是整条路径,其中的 * 会跨 /,所以 openprogram/*.py 静音的是整个包而不是顶层那几个模块——顶层模块因此改成逐个列名。新 加 pattern 之前先拿一个嵌套文件验一下:静音过头的 pattern 会把门禁关掉,而且不会让任 何检查失败。

BLE001(盲目 except Exception)刻意不启用。会打日志并降级的边界 handler 是正确设 计,把它们全标出来产出的是噪声而非信号。第 2 节改由评审来约束这类代码。

ruff 的作用域仅限这道门禁。格式化与 import 顺序刻意不管——这是一条正确性规则,不是一 套风格制度。

附录:实现状态#

已实现。三条核心路径通过 E722S110ruffdev extra 中,配置在 pyproject.toml[tool.ruff] 下。

尚未完成:per-file-ignores 中被静音的那些目录。openprogram/webui/ 与 dispatcher 之 外的 openprogram/agent/ 是剩余存量最集中的两处。

相关文件#

Last updated · 2026-08-13