首页
/ claude-mem 观察者错误路径设计:网关一次分类、错误信封端到端透传、让用户在一个会话内知道"发生了什么、为什么、该做什么"

claude-mem 观察者错误路径设计:网关一次分类、错误信封端到端透传、让用户在一个会话内知道"发生了什么、为什么、该做什么"

2026-09-06 17:12:01作者:凤尚柏Louis

本文以 claude-mem 仓库中的错误路径改造计划 为核心,讲解 claude-mem 观察者(observer)链路中"错误分类一次完成、结构化错误信封逐跳透传、用户可见文案四段式"的完整设计:从 OpenRouter 上游的实测错误响应,到 cmem.ai 网关的分类与 6 码错误词表,再到 worker 分类器、重试策略、observer-health 台账与会话启动警告的渲染。读完你可以掌握"如何让一条付费用户的推理额度耗尽错误,在日志、会话上下文和网关响应三处呈现同一句话"的工程方案,以及当前仓库中已落地的对应源码。

问题起源:一串"小损失"叠加成的静默 502 重试循环

该计划对应一次真实事故(2026-08-14 → 16 观察):付费 Pro 用户用尽推理额度后,worker 端无限循环出现 OpenRouter upstream error (status 502) ×3 重试,全程静默。6 名付费用户暗停了 3–5 天而不自知。

计划把根因拆成一条"信息损耗链",每一环都在丢东西:

  1. OpenRouter 对额度耗尽的 child key 返回的是 403 Key limit exceeded(而不是文档声称的 402);
  2. cmem.ai 网关只特判 402,其余错误一律 502 包装,丢失上游状态语义;
  3. worker 分类器把 5xx 归为 transient,触发重试,并且丢弃了上游响应体——而响应体里恰好含有用户可自行恢复的补救链接;
  4. observer-health 台账(PR #3538)只记录了一个无用的扁平字符串。

这个"损耗链"正是计划标题的含义——classify once, carry the message, tell the user what to do。计划的最高目标被写成一句可验收的话:额度耗尽的付费用户在一个会话内就会发现,并且用大白话知道发生了什么、以及唯一该做的一件事。 永远不要静默的 502 循环,也永远不要"只有我们自己看得懂"的报错。

核心设计:一次分类 + 6 码错误词表 + 四段式消息

整个方案只有一个设计原则:在源头(网关)分类一次。之后每一跳只"携带"结构化错误 {code, message, action, url, request_id},绝不改写、绝不对不可能成功的错误重试,并且在日志、会话启动上下文(以及未来的邮件)中呈现同一句话。

错误词表只有 6 个 code,这就是全部词汇:

code HTTP 可重试? worker kind
allowance_exhausted 402 quota_exhausted
key_invalid 401 auth_invalid
subscription_inactive 402 auth_invalid
rate_limited 429 是(按 Retry-After) rate_limit
upstream_unavailable 503 transient
bad_request 400 unrecoverable

每一条消息固定 4 个部分:what(发生了什么)· why(为什么)· do this(一个具体动作)· 链接 + request id。

仓库分工:Phase 1 在 claude-mem-pro(网关)仓库,Phase 2–3 在 claude-mem(worker)仓库,Phase 3 以 PR #3538 先合入为前提。计划的硬约束是:只做根因修复;不新增重试层、不加 fallback、不加环境变量逃生门;diff 大小等于缺陷大小;只允许使用 https://cmem.ai/dashboardsupport@cmem.aihttps://github.com/thedotmack/claude-mem/issues 三个链接,不得虚构 URL。

Phase 0:先钉死事实,再动刀

计划专门设了一个"整合发现"阶段,理由很直接:你没法为一次你没亲眼见过的失败写诚实的错误消息。它把三类事实全部钉死,确保后续每个 Phase 修改的是"真实的接缝"而不是猜测。

OpenRouter 上游的实测响应(2026-08-15/16,生产 key 捕获)

场景 HTTP 响应体
child key 触顶(limit_reset: null,即试用期) 403 {"error":{"message":"Key limit exceeded (total limit). Manage it using https://openrouter.ai/workspaces/default/keys/<hash>","code":403}}
child key 触顶(limit_reset: 'monthly' 403(括号措辞不同,如 (monthly limit) 匹配 /key limit exceeded/i,绝不匹配括号里的措辞
key 无效 401 {"error":{"message":"User not found.","code":401}}
文档声称额度耗尽返回 402 生产中未观察到 保留该分支,成本为零

这条实测表直接决定了两处实现:网关的 403 额度判定(Phase 1)和 worker 分类器 body 标记列表里加入 key limit exceeded(Phase 2)。

计划盘点出的网关现状(claude-mem-pro 仓库)

针对 src/app/api/inference/v1/chat/completions/route.ts(432 行),计划逐行盘点了当时的每个错误返回点(401 未授权 → 402 订阅不活跃 → 429 限流 → 400 body 解析 → 500 凭据不可读 → 503 签发失败 → 仅 402 特判的额度文案 → !upstream.ok || !payload 兜底把 403 包成 429/502,"403 今天就从这里漏出去")。盘点还确认了几个关键缺口:

  • 全链路没有任何 request id——不生成、不读 OpenRouter 的、不记录。必须在服务端用 randomUUID() 铸造,并贯穿 JSON body、x-request-id 头与每行 console.*
  • PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store' } 逐路由复制粘贴,src/lib不存在共享的错误响应助手(grep errorResponse|jsonError|ApiError 零命中)——Phase 1 负责创建;
  • paymentStatus 实际取值 'none' | 'pending' | 'active' | 'trialing' | 'past_due' | 'cancelled'
  • "下周期 <日期> 重置" 在不做 Stripe 往返的前提下不可推导current_period_end 不在 proUsers 表上),因此决定:错误消息里不写日期,只说 "at the start of your next billing cycle";
  • 测试约定是独立 tsx 脚本 scripts/test-<name>.ts,模式上"导出纯函数并测试它"(参考路由里为可测试而导出的 bearerFrom),无需 HTTP mock。

计划盘点出的 worker 现状(本仓库,改造前)

  • classifyOpenRouterError 旧版分支顺序:body 标记 quota exceeded|insufficient credits|insufficient_quotaquota_exhausted;429 → rate_limit;401/403 → auth_invalid;400/404 → unrecoverable5xx → transient(事故放大器);无状态 → transient;兜底(含 402)→ unrecoverable 且仅保留 body 前 200 字符。input.requestId 被接收但从未使用
  • ClassifiedProviderError 只有 { kind, retryAfterMs?, cause },kind 是开放字符串联合,没有结构化字段;
  • 每次失败产生 5 行日志:重试 warn ×2、init query failed✗ <Provider> agent errorGenerator failed——用户或 Agent 无法从中挑出"该读哪一行";
  • 服务端有一份不得跨边界 import 的分类器副本 src/server/generation/providers/shared/error-classification.ts(文件头注释明确禁止 import worker 代码),Phase 2 要求只镜像 body 标记列表、不跨边界引用。

Phase 1:网关——一次分类、诚实状态码、结构化信封、request id

这一阶段是消息的"出生地":网关是唯一知道用户套餐、试用状态和额度的跳,所以只有它能说出"你已用尽本周期 $30 额度——下个计费周期重置,急需可邮件 support"。诚实的状态码让 worker 停止对不可重试错误的重试;request id 让"联系 support"成为真实通路而非死胡同。分支 feat/inference-error-taxonomy off mainclaude-mem-pro 仓库)。

1.1 新模块 src/lib/http/gateway-error.ts

计划给出的完整实现形状如下(6 码到状态码的映射表即前面词表的可执行版本):

import { NextResponse } from 'next/server';

export type GatewayErrorCode =
  | 'allowance_exhausted' | 'key_invalid' | 'subscription_inactive'
  | 'rate_limited' | 'upstream_unavailable' | 'bad_request';

export const GATEWAY_ERROR_STATUS: Record<GatewayErrorCode, number> = {
  allowance_exhausted: 402, key_invalid: 401, subscription_inactive: 402,
  rate_limited: 429, upstream_unavailable: 503, bad_request: 400,
};

export interface GatewayError {
  code: GatewayErrorCode;
  message: string;      // what happened + why, one sentence
  action: string;       // what the user should do, one sentence
  url?: string;         // approved links only
  request_id: string;
}

export const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store' } as const;

export function gatewayErrorResponse(err: GatewayError, extraHeaders: Record<string,string> = {}) {
  return NextResponse.json({ error: err }, {
    status: GATEWAY_ERROR_STATUS[err.code],
    headers: { ...PRIVATE_HEADERS, 'x-request-id': err.request_id, ...extraHeaders },
  });
}

同时导出一个纯函数的上游映射器(这是被测试的对象):

export type UpstreamOutcome =
  | { kind: 'allowance_exhausted' }
  | { kind: 'rate_limited'; retryAfterSec: number }
  | { kind: 'upstream_unavailable'; detail: string };

/** Map a non-OK (or unparseable) OpenRouter response to a taxonomy outcome. */
export function mapUpstreamFailure(status: number, payload: unknown): UpstreamOutcome

映射规则(顺序敏感):

  1. status === 402,或(403 || 401 /key limit exceeded|limit exceeded|insufficient credits|negative credit/i 命中 message)→ allowance_exhausted
  2. status === 429rate_limited(retryAfterSec 60——当前代码不读上游 retry-after 头,保持 60);
  3. 其余一切(5xx、401 User not found.、无额度措辞的 403、非 JSON/!payload、未知 4xx)→ upstream_unavailabledetail = payload?.error?.message ?? 'HTTP <status>' 只进服务端日志——绝不发给客户端,因为它可能含有指向"我们自己工作区"的 manage-key URL。

1.2 消息文案(最终版;客户端逐字渲染 message + action

code message action url
allowance_exhausted(active) You've used your $<limit> CMEM Pro inference allowance for this billing cycle. It resets at the start of your next billing cycle. Need more before then? Email support@cmem.ai. https://cmem.ai/dashboard
allowance_exhausted(trialing) You've used your free-week inference allowance ($<limit>). Your full allowance unlocks when your trial converts. Want it sooner? Email support@cmem.ai. https://cmem.ai/dashboard
key_invalid This CMEM Pro key isn't recognized. Run \npx claude-mem pro-setup` to re-link this machine, or copy a fresh key from your dashboard.` https://cmem.ai/dashboard
subscription_inactive(past_due) Your CMEM Pro payment didn't go through, so the observer is paused. Update your card in the dashboard and observations resume immediately. https://cmem.ai/dashboard
subscription_inactive(cancelled) Your CMEM Pro subscription has ended. Resubscribe from the dashboard to turn the observer back on. https://cmem.ai/dashboard
subscription_inactive(其他) Your CMEM Pro subscription isn't active. Check billing in the dashboard, or email support@cmem.ai. https://cmem.ai/dashboard
rate_limited Too many observer requests in the last minute. Retrying automatically in 60s — nothing to do.
upstream_unavailable The observer model is temporarily unavailable. claude-mem retries automatically. If this lasts more than an hour, email support@cmem.ai with the request id.
bad_request The observer sent a request the gateway couldn't parse. This is a claude-mem bug — please open an issue with the request id. https://github.com/thedotmack/claude-mem/issues

<limit> = proUser.openrouterKeyLimitUsd 去掉尾部零格式化($30$2.33);为 null 时省略金额子句。

1.3 路由改动与验证清单

路由层面的替换要点:POST 顶部 const requestId = randomUUID();,所有 console.warn/error 上下文对象加 { requestId }unauthorized()、402 订阅文案、429、400 body 解析、500 解密失败、503 签发失败分别替换为对应 taxonomy code;整个 !upstream.ok 兜底块替换为 mapUpstreamFailure + switch,其中 allowance_exhausted 分支保留原有的 captureServer('pro_inference_cap_exhausted', { limit_usd, trialing }) 事件不变(analytics 规则 A3"props 里不放 token/邮箱"、A7"analytics 永不拖垮推理");上游失败一律 503,永不 502;fetch 抛错/abort 也归 503,超时区分只进服务端日志。成功路径不动。

验证是 grep 级别的硬门槛:

  • grep -n "502" …/chat/completions/route.ts0 命中
  • grep -n "NextResponse.json({ error" … → 0 命中(全部经 gatewayErrorResponse);
  • grep -n "randomUUID" route.ts → POST 顶部 1 命中;
  • 部署后真机冒烟:curl -sD - https://cmem.ai/api/inference/v1/chat/completions -H "Authorization: Bearer cm_pro_bogus" -d '{}' → HTTP 401、JSON error.code === 'key_invalid'x-request-id 头存在。

测试脚本 scripts/test-inference-errors.ts 只测纯函数:mapUpstreamFailure 对 403 total/monthly、402、401 User not found.、429、500/null 等 7 类输入的映射,GATEWAY_ERROR_STATUS 恰好 6 键且状态码与词表一致,gatewayErrorResponse 设置 x-request-idCache-Control: private, no-store。反模式护栏:不加 Stripe 调用、不加新环境变量、上游 detail 永不进响应体、渲染出的消息里断言不含 cm_pro_/sk-or- 片段。

Phase 2:worker——透传信封、额度错误不再重试、日志只打一行

这一阶段让 worker 成为"忠实的信差":网关的话逐字存活、额度错误快速失败而非循环、恰好存在一行用户(或其 Agent)能读懂并据此行动的日志。该阶段独立于 Phase 1(对旧式响应体同样有效),也顺带修复了非 Pro 用户直连 OpenRouter 时 Key limit exceeded 响应体被丢弃的同样损失。

结构化字段:ClassifiedProviderError 的扩展

当前仓库 provider-errors.ts 中可以看到计划 2.1 的落地形态——ProviderErrorDetail { code?, action?, url?, requestId? } 作为构造参数的可选结构化字段,并暴露为只读属性:

export interface ProviderErrorDetail {
  code?: string;
  action?: string;
  url?: string;
  requestId?: string;
}

文件头注释点明了用途:当上游(如 cmem.ai 网关)返回 taxonomy 信封时,worker verbatim 携带这些字段,使日志行与会话启动警告呈现同一句话。同文件的 describeProviderError 是唯一的人工可读渲染:

export function describeProviderError(err: ClassifiedProviderError): string {
  return `${err.message}${err.action ? ' — ' + err.action : ''}${err.url ? ' ' + err.url : ''}${err.requestId ? ` (req ${err.requestId})` : ''}`;
}

message — action url (req id),缺失部分自动省略。日志行用它,台账存结构化字段。

分类器:先解析信封,再走 legacy 兜底

OpenRouterProvider.ts 中的实现与计划 2.2 一一对应。首先是 GATEWAY_CODE_TO_KIND 词表到 kind 的映射:

const GATEWAY_CODE_TO_KIND: Record<string, ProviderErrorClass> = {
  allowance_exhausted: 'quota_exhausted',
  key_invalid: 'auth_invalid',
  subscription_inactive: 'auth_invalid',
  rate_limited: 'rate_limit',
  upstream_unavailable: 'transient',
  bad_request: 'unrecoverable',
};

parseUpstreamErrorEnvelope 做 best-effort 的 { error: {...} } 解析,非 JSON 时静默回落到 legacy 路径。classifyOpenRouterError 的分支顺序是:

  1. 信封命中error.code 是 6 个 taxonomy 字符串之一):message = envelope.message 逐字保留、action/url 透传、requestId = envelope.request_id ?? input.requestId,kind 按词表映射;rate_limit 额外带 retryAfterMs ?? 60_000。直接返回,不再走后续判断;
  2. legacy 兜底:消息必须包含上游响应体——JSON 取 error.message,否则 bodyText.substring(0, 300),形状统一为 `OpenRouter <class> (status N): <upstream message>`。例如 OpenRouter quota exhausted (status 403): Key limit exceeded (total limit). Manage it using https://openrouter.ai/…。额度判定(L118-L133)的标记列表按计划扩为 quota exceeded|insufficient credits|insufficient_quota|key limit exceeded|limit exceeded(429 除外)|negative credit,且 status === 402 直接判 quota_exhausted(旧版 402 落入 unrecoverable 兜底);429 → rate_limit(带 Retry-After)、401/403 → auth_invalid、400/404 → unrecoverable、5xx 与网络错误 → transient,每个分支都带 requestId

一个值得注意的细节写在源码注释里:"Rate limit exceeded" on a 429 is a rate limit, not quota——所以通用的 limit exceeded 标记只在非 429 路径生效,key-limit 标记永远优先。

服务端镜像 error-classification.ts 同步更新了标记列表(同样含 key limit exceededstatus === 402),文件头注释解释了为什么是复制而不是 import:Phase 5 反模式护栏要求 src/server/* 不得 import src/services/worker/*

重试策略与"日志只打一行"

retry.ts 中的 isRetryableKind 是重试决策的唯一入口:

export function isRetryableKind(err: unknown): boolean {
  if (!isClassified(err)) {
    // Unclassified errors are treated as transient (preserve old default).
    return true;
  }
  return err.kind === 'transient' || err.kind === 'rate_limit';
}

改造后 quota_exhausted/auth_invalid/unrecoverable 不再进入重试循环——这正是事故中"502 无限 ×3 重试"的终结点。计划同时明确不得改变未分类错误的可重试性(那是范围外的行为变更),源码注释也把这钉死为"preserve old default"。withRetryrate_limit 尊重 retryAfterMs,其余走指数退避 + 抖动,上限 2 次(这些 POST 不严格幂等)。

日志扇出从 5 行收敛为 1 行:分类错误在 init query failed/handleSessionError 处降为 debug,唯一 error 级行落在 SessionRoutes.ts——

if (isClassified(error)) {
  logger.error('SESSION', 'Observer failed', {
    sessionId: session.sessionDbId,
    provider,
    kind: error.kind,
    ...(error.code ? { code: error.code } : {}),
    ...(error.requestId ? { requestId: error.requestId } : {}),
  }, describeProviderError(error));
}

源码注释解释了两个工程决策:传递渲染后的字符串而非 Error 对象,避免 errorSink/captureException 对同一事件三重触发;以及"分类错误是用户状态(额度/认证/限流)而非 bug,所以不触发异常捕获"。最终效果:每次重试最多一行 WARN(仅 transient/rate-limit),每次失败恰好一行 ERROR,该行同时给出 code、message、action 与 request id。

Phase 2 的测试与验证

计划要求把 tests/worker/provider-classifiers.test.tsdescribe('classifyOpenRouterError') 的用例从"只断言 kind/retryAfterMs"升级为断言 message、code、action、url、requestId,当前仓库中可以看到落地,例如 L206 起的用例allowance_exhausted 信封(402 + {code, message, action, url, request_id: 'abc'})→ kind 'quota_exhausted'code 'allowance_exhausted'、message/action 逐字、requestId 'abc'。legacy 用例则断言 403 Key limit exceeded 的 message 同时包含措辞与 https://openrouter.ai/ URL。计划还要求新增 tests/worker/retry-policy.test.ts 钉住 isRetryableKind 的完整真值表(含"普通 Error 仍重试"这一现状)。

验证清单里最有实战价值的是手动复现步骤:把子 key 的 limit 临时 PATCH 到当前用量,触发一次 observer 回合,确认 worker 日志恰好一行 Observer failed、文本含 Key limit exceeded(Phase 1 之前)或 taxonomy action(之后)、且没有任何 Retrying OpenRouter 行;然后恢复 limit。grep 门槛:grep -rn "key limit exceeded" 在 worker 与 server 两处分类器各 1 命中;grep -n "requestId" OpenRouterProvider.ts 显示它被真正使用而非仅仅接收。

Phase 3:台账 + 会话启动警告渲染结构化错误

这是"一个会话内发现"的另一半:用户不读 worker 日志,他们打开 Claude Code。PR #3538 已经在下一个会话顶部放置了警告,本阶段让警告说出正确的话——网关的消息、"What to do:" 行、链接、request id,而不是一句对 Pro 用户完全错误的通用 "check your settings.json"。没有这个阶段,Phase 1–2 只是生产了一条没人看到的好消息。

当前仓库 observer-health.ts 是这一阶段的完整落地(文件头注释交代了动机:2026-08-09 的 provider 额度故障曾 17 小时无人察觉,所以"observations 断流"必须永不静默)。关键设计点:

状态扩展且向后兼容ObserverHealthStatelastErrorMessage 之外新增 lastErrorCodelastErrorActionlastErrorUrllastErrorRequestId(以及后来追加的 lastErrorKind),readObserverHealth{ ...EMPTY_STATE, ...parsed } 合并,旧版台账文件读出来新字段自动为 null——正是计划 3.1 的"additive fields are backward compatible"。

双形态入账recordObserverFailure 第二参数为 string | ObserverFailureDetail:字符串形态保持旧行为;对象形态填充新字段,其中 message/actionscrubErrorMessage 清洗(正则抹掉 sk-…cm_pro_…、Bearer 令牌与 name=value 形态的凭据赋值,但刻意保留数字——"数字是限额/计数,不是凭据",让 key limit exceeded 这类诊断原文存活)。注释特别强调清洗写入前与渲染时各做一次,防止旧版本写入的台账向会话上下文注入秘密。还有一个计划未提及但同属此模块的稳健性细节:台账的读-改-写被一个 fail-open 的进程间文件锁(withLedgerLock)串行化,因为丢增量会把 consecutiveFailures 压在阈值下、反而压掉了这个模块存在的意义。

hook 侧传结构化对象SessionRoutes.ts 在唯一 error 行之后入账:

recordObserverFailure(provider, isClassified(error)
  ? { message: error.message, kind: error.kind, code: error.code, action: error.action, url: error.url, requestId: error.requestId }
  : errorMsg);

渲染器替换通用补救文案renderObserverHealthWarningLatest error: <message> 之后按需追加 What to do: <action>Link: <url>Request id: <requestId>action 存在时替换那句对 Pro 用户错误的通用 "check the observer provider's API key, spend limit, and base URL in ~/.claude-mem/settings.json"(源码注释原话:A classified error already says what to do; the generic settings.json remedy is wrong for e.g. Pro users whose allowance ran out)。实现中还演化出了计划未覆盖的精细分支:quota_exhausted 的失败有专属警告路径(isQuotaFailure 分支),刻意不提供重启链接——重启清不掉额度耗尽,反而会解除正在保护账户的退避断路器。

注入位置ContextBuilder.tswithObserverHealthWarning 把警告应用到每一条上下文路径(含空库、无记忆欢迎语),且放在上下文下方而非顶部——时间线很长,警告放顶部在上下文打印完之前就已经滚出屏幕了;最后渲染的内容才是模型第一次回复前仍在屏上的内容。

Phase 3 的验收是端到端的:用受限 key 跑一个 observer 回合,然后开新会话,确认启动上下文包含 taxonomy message、What to do: 行、https://cmem.ai/dashboard 与 request id;再跑一个成功回合,确认 recordObserverSuccess 清零后警告消失。对应测试 tests/observer-health.test.ts 覆盖对象形态四字段往返、字符串形态保持 null、有 action 时不含 settings.json 文案、旧台账文件兼容性。

Phase 4:验证与发布

计划坚持用户结果才是证明,绿测试不算:给真实 key 设限、看着恰好一行日志出现且零重试、开新会话读到警告。且三个呈现面(日志、会话警告、网关响应)若出现措辞不一致,阶段即失败。具体门槛:

  1. 两仓库测试全绿、PR 合入(Phase 1 在 claude-mem-pro,合入 main 自动部署;Phase 2–3 在 claude-mem);
  2. grep 护栏:claude-mem-progrep -rn "502" src/app/api/inference = 0;claude-memgrep -rn "OpenRouter upstream error (status" src/ = 0(消息形状改为含 body);
  3. 真机验证:worker 日志一行 Observer failed + 零重试;下一会话启动显示带 action 的警告;网关响应带 x-request-id;PostHog 收到 pro_inference_cap_exhausted
  4. version-bump 流程发 patch 版 claude-mem,不编辑 CHANGELOG。

计划同时明确列出范围外的跟进项(需要邮箱查找 + 去重状态的升级提醒邮件、让 allowance_exhausted 能说日期的反规范化计费周期列、per-plan 额度差异化),避免范围蔓延。

贯穿全文的反模式清单

计划的 "Anti-patterns (do not)" 一节值得完整保留,它是这套设计的负面规格:

  • 不得 502 包装任何用户可行动的错误;不得在 body 里返回 code: <upstream status>
  • PostHog props 与错误消息里不得出现子 key、setup token、邮箱;
  • 不得虚构 cmem.ai/upgrade/billing 链接——它们不存在,只允许三个核准链接;
  • 不得在错误路径上发起 Stripe 调用只为一个日期;
  • 不得新增重试层;不得让未分类错误变为不可重试(范围外的行为变更);
  • src/server/* 不得 import src/services/worker/*

小结:这条错误路径回答了什么问题

回顾整个链路,每个环节现在都有明确的职责:网关在唯一知道套餐状态的地方分类一次并把 detail 留在服务端日志;worker 作为信差逐字透传信封,让不可重试的错误在 isRetryableKind 处短路;describeProviderError 保证日志行是唯一的 error 级呈现;observer-health 台账 以向后兼容的方式结构化存盘、双次清洗防泄密;会话启动警告 把同一句话送到用户真正会看的地方。对设计同类"跨进程错误传播"系统的读者,这套方案的可迁移经验是:错误词汇表要小到能背下来(6 个 code)、消息结构要固定(4 段)、每一跳要么分类要么透传、并且用 grep 级护栏防止 502 包装这类回归悄悄长回来。

登录后查看全文
热门项目推荐
相关项目推荐