失败也是接口的一部分
自动化并不是在成功路径运行一次后就算完成。真正完成的标准是:未来接手的人能够知道发生了什么、哪些状态被改变、下一步怎样做才安全。定时任务经常发生在没有人注视的时候,因此它的失败路径必须比一个交互按钮携带更多上下文。
我倾向于使用明确的结果类型,把可以预期的运行故障与程序错误分开。调用者不需要解析任意文本,就可以决定应该重试、告警还是立即停止。
type RunResult =
| { ok: true; processed: number; cursor?: string }
| {
ok: false
code: 'UPSTREAM_TIMEOUT' | 'INVALID_INPUT' | 'PARTIAL_WRITE'
retryable: boolean
message: string
}
async function runJob(): Promise<RunResult> {
const input = await loadValidatedInput()
try {
const processed = await processBatch(input)
return { ok: true, processed }
} catch (error) {
return classifyOperationalFailure(error)
}
}
类型只是契约的一部分。任务还需要稳定的写入幂等键、短于调度器最终期限的超时、带 run identifier 的结构化日志、有上限的重试策略,以及处理部分进度的明确规则。
| 故障 | 危险反应 | 礼貌处理 |
|---|---|---|
| 上游超时 | 无限重试 | 达到上限后停止并保存 cursor |
| 输入无效 | 悄悄跳过 | 写入前拒绝并指出具体字段 |
| 部分写入 | 重新执行完整批次 | 只对账最小的已确认单元 |
| 未知错误 | 一律标记可重试 | 停止、保留证据并要求人工检查 |
恢复顺序
- 如果幂等性不确定,先停止重复写入。
- 保存本次运行标识和最后确认的 cursor。
- 检查故障发生后,上游状态是否已经改变。
- 对比计划写入与实际确认写入。
- 只重试最小的安全单元。
- 把最终解决方法记录在原始告警旁边。 有用的告警应该具体,但不能暴露 secret。“任务失败”几乎不包含信息。“导入在第 12 批因为内容 API 超时而停止;cursor 8f2 之后没有写入;可以安全重试”,则为下一位处理者提供了明确起点。
- 同一批次执行两次不会产生重复数据。
- 日志不出现 secret 或完整请求正文。
- 第一次写入之前已经完成校验。
- 超时返回可识别的错误码。
- 部分写入有记录清楚的对账路径。
- 重试延迟有最大次数。
- dry-run 可以说明将要发生的变化。
什么时候应该直接 throw?
违反程序员假设,或者出现调用方无法安全解释的状态时,应当 throw。对可以预期的外部故障,则返回类型明确的运行结果。这个区分应当保持小而清楚;过于庞大的错误层级,常常会重新制造它试图消除的模糊。
最好的自动化不需要英雄式操作。它会从故障现场留下一条狭窄但照明清楚的路,带人回到已知状态。