Repository navigation
fix(minimax-bot): 回合重试从 2 次延长到 10 档(仍有上限),400/404/422 不重试 #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,10 +33,67 @@ const HELPER_ACTIVE = new Set(['queued', 'running']); | |
| * A turn that failed before running any tool is retried this many more times, after these waits. | ||
| * Model capacity errors (429/529) and timeouts usually clear within a minute. | ||
| */ | ||
| const TURN_RETRY_DELAYS_MS = [8000, 20000]; | ||
| /** | ||
| * 重试窗口:**延迟序列 + 次数上限**(一个旋钮,两个含义 —— 与上游一致)。 | ||
| * | ||
| * ⚠️ 为什么改(2026-10-20):上游 0.7.4 只有 `[8000, 20000]` ⇒ **只重试 2 次** | ||
| * (等 8 秒、等 20 秒)⇒ 一次持续 30 秒的服务端抖动就让 bot **直接死掉**, | ||
| * 只能靠人手点一下才回来。 | ||
| * ⇒ 延长到 10 档:8s → 20s → 40s → 60s ×7(指数退避、封顶 60 秒), | ||
| * 总窗口约 **8 分钟**,足以自愈绝大多数瞬时抖动。 | ||
| * | ||
| * ⚠️ **有上限**:第 10 次之后就把原始错误暴露给用户,不让它一直转下去。 | ||
| * ⚠️ **这个数组就是唯一的旋钮**:注入空数组 = 完全不重试(既有语义不变)。 | ||
| */ | ||
| const TURN_RETRY_DELAYS_MS = [8000, 20000, 40000, 60000, 60000, 60000, 60000, 60000, 60000, 60000]; | ||
| /** Failure reasons a retry cannot fix; anything else (or no reason from an older Host) may be retried. */ | ||
| const NO_RETRY_REASONS = new Set(['auth', 'quota', 'context_too_long', 'blocked', 'invalid_request']); | ||
|
|
||
| /** | ||
| * 暂时性错误判定:**只匹配明确的服务端 / 网络故障形态**。 | ||
| * | ||
| * 为什么需要它:Host 的 `toLLMRetryDecision()` 对**通用 5xx** 故意 fail-closed —— | ||
| * `LLM_UPSTREAM_ERROR`(50113)是「400 及以上的兜底桶」,把 HTTP 400/404 这种 | ||
| * 重试无意义的错误和 HTTP 500 这种**暂时性**错误混在一起,于是分类器只能整体 | ||
| * 判成不可重试 ⇒ 一次 30 秒的服务端抖动就会让 bot **直接死掉**。 | ||
| * | ||
| * ⚠️ **这里只认「明确是服务端/网络故障」的形态**(2026-10-20 收敛): | ||
| * - **不含 400 / 404 / 422**:Host 的注释说得很清楚 —— | ||
| * *would turn an HTTP 400 or 404 into ten identical attempts*。 | ||
| * 重试它们只是把同一次拒绝重复十遍。`invalid_request` 也在 `NO_RETRY_REASONS` 里。 | ||
| * - **不含裸数字**:否则「用了 400 tokens」「第 404 步」这类正常消息会被误判成错误。 | ||
| * 判定「这次失败值不值得重试」的**第一道**依据始终是 Host 自己给的 | ||
| * `result.error.retryable` 标志;这里只是补上 Host 判不出来的那一类。 | ||
| */ | ||
| const TRANSIENT_ERROR_PATTERNS = [ | ||
| /net::ERR_NETWORK_CHANGED/i, | ||
| /ERR_NETWORK/i, | ||
| /ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE/i, | ||
| /socket hang up/i, | ||
| /internal server error/i, | ||
| /bad gateway|gateway timeout|service unavailable/i, | ||
| /open platform service unavailable/i, | ||
| /请求处理超时/, | ||
| /unknown error, *520/, | ||
| /overloaded|rate limit|too many requests|model is busy/i, | ||
| // 5xx 只认带上下文的形态(`HTTP 500` / `status 502` / `code=503`), | ||
| // 不认裸数字 —— 「用了 500 tokens」不是错误。 | ||
| /\b(?:http|status(?:\s+code)?|code)\s*[:=]?\s*50[0234]\b/i, | ||
| /\b50[0234]\s+(bad gateway|gateway timeout|service unavailable|internal server error)/i, | ||
| ]; | ||
|
|
||
| /** | ||
| * 这条错误消息是不是「暂时性」的(重试有意义)。 | ||
| * | ||
| * @param {string} message | ||
| * @returns {boolean} | ||
| */ | ||
| export function isTransientErrorMessage(message) { | ||
| const text = String(message ?? ''); | ||
| if (!text) return false; | ||
| return TRANSIENT_ERROR_PATTERNS.some((re) => re.test(text)); | ||
| } | ||
|
|
||
| /** Resolves true after `ms`, or false at once if `signal` aborts first. */ | ||
| function sleepUnlessAborted(ms, signal) { | ||
| return new Promise((resolve) => { | ||
|
|
@@ -130,6 +187,7 @@ export class Engine { | |
| this.logger = deps.logger; | ||
| /** Waits before each automatic retry of a failed turn (tests shorten them). */ | ||
| this.retryDelays = deps.retryDelaysMs ?? TURN_RETRY_DELAYS_MS; | ||
| /** 重试窗口的退避基数与上限(测试会把它调小)。 */ | ||
| /** @type {{ at: number, value: Promise<any> } | undefined} */ | ||
| this.catalogCache = undefined; | ||
| /** @type {Map<string, { controller: AbortController, stoppedBy?: string }>} helper id -> run */ | ||
|
|
@@ -505,6 +563,10 @@ export class Engine { | |
| let failure = ''; | ||
| let failureCode = ''; | ||
| let failureRetryable = false; | ||
| // 原始错误消息(未翻译):暂时性错误的判定必须看**它**—— | ||
| // `failure` 是 `describeAgentError()` 翻译后的文案(可能是「模型不可用」这类 | ||
| // 泛化说法),里面已经没有 500 / 网络切换这些关键信息了。 | ||
| let rawFailureMessage = ''; | ||
| let attempt = 0; | ||
| try { | ||
| for (;;) { | ||
|
|
@@ -536,31 +598,80 @@ export class Engine { | |
| failure = describeAgentError(retryError); | ||
| failureCode = errorCode(retryError); | ||
| failureRetryable = Boolean(/** @type {any} */ (retryError)?.retryable); | ||
| rawFailureMessage = errorText(retryError); | ||
| } | ||
| } else { | ||
| failure = describeAgentError(error); | ||
| failureCode = errorCode(error); | ||
| failureRetryable = Boolean(/** @type {any} */ (error)?.retryable); | ||
| rawFailureMessage = errorText(error); | ||
| } | ||
| } | ||
| const failed = Boolean(failure) ? failureRetryable : result?.status === 'error'; | ||
| const reason = result?.error?.reason; | ||
| // ── 两条重试路径(2026-10-19 实测补)── | ||
| // | ||
| // A) **暂时性错误 ⇒ 值得重试**(但仍然受次数上限约束)。 | ||
| // Host 把通用 5xx 归进 50113 这个「兜底桶」并 fail-closed, | ||
| // 但本插件看得到错误消息文本,能把「服务端抖了一下」与 | ||
| // 「服务端明确拒绝」分开。实测这批 50113 全是暂时性故障 | ||
| // (网络切换 / 500 / 超时 / 520 / 502 / 服务不可用)。 | ||
| // ⇒ 一直重试到成功,退避封顶 60 秒。 | ||
| // | ||
| // B) **其它可重试错误 ⇒ 有限重试**(原有行为,2 次 / 28 秒窗口)。 | ||
| // | ||
| // 两条路都排除:额度类(quota)、鉴权类(auth)、上下文过长、被拒、 | ||
| // 参数非法 —— 这些重试一万次也没用。 | ||
| // ⚠️ 成功后必须**立刻停止**重试。 | ||
| // `rawFailureMessage` 保留着上一次失败的内容,而 `transient` 只看它 —— | ||
| // 于是第 4 次已经 `result.status === 'finished'` 了,却因为「上一次是 | ||
| // 暂时性错误」而继续重试(实测 attempts 从 4 一路涨到 7,永远停不下来)。 | ||
| // ⇒ 判据必须先看**本次**的结果是否成功。 | ||
| const succeeded = result?.status === 'finished'; | ||
| const transient = | ||
| !succeeded && | ||
| isTransientErrorMessage(result?.error?.message ?? rawFailureMessage ?? ''); | ||
| // ⚠️ 抛异常的路径里 `result` 是 undefined,所以不能用 `result?.status`。 | ||
| // 那种情况下「暂时性」由**原始错误消息**判定: | ||
| // 抛异常 ⇒ rawFailureMessage 有内容 ⇒ transient 为真 ⇒ 可重试。 | ||
| // ⚠️ 「暂时性」**不再**用来绕过下面两道判据: | ||
| // - 不再绕过 `NO_RETRY_REASONS`(auth / quota / invalid_request 等) | ||
| // - 不再绕过次数上限(见 `TURN_RETRY_MAX_ATTEMPTS`) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (非阻塞)这里引用的 |
||
| // 它只用来决定**退避用多长**。 | ||
| const retryableFailure = | ||
| failed || | ||
| (transient && | ||
| !NO_RETRY_REASONS.has(reason) && | ||
| reason !== 'auth' && | ||
| reason !== 'quota'); | ||
| const canRetry = | ||
| failed && | ||
| retryableFailure && | ||
| !input.answer && | ||
| !toolStarted && | ||
| // ⚠️ **有上限**(2026-10-20 收敛):暂时性错误同样受次数上限约束。 | ||
| // 次数上限就是 `this.retryDelays.length`(默认 10 档 ⇒ 约 7.5 分钟窗口), | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (非阻塞)「约 7.5 分钟」与文件头的「约 8 分钟」不一致;默认窗口合计 488 秒,建议统一为「约 8 分钟」(测试文件头注释里的「60s×6,约 7.5 分钟」同理,实际是 60s×7)。 |
||
| // 注入空数组仍然表示「完全不重试」(既有语义一字未改)。 | ||
| attempt < this.retryDelays.length && | ||
| !controller.signal.aborted && | ||
| !this.disposed && | ||
| result?.error?.retryable !== false && | ||
| !NO_RETRY_REASONS.has(reason); | ||
| if (!canRetry) break; | ||
| const delay = this.retryDelays[attempt]; | ||
| // 延迟 = 窗口里的那一档(可注入 ⇒ 既有语义与既有测试都不受影响)。 | ||
| const delay = this.retryDelays[attempt] ?? 0; | ||
| attempt += 1; | ||
| // Words the failed attempt streamed are not the reply; the retry writes its own. | ||
| message.parts = message.parts.filter((part) => part.type !== 'text'); | ||
| liveText = undefined; | ||
| message.live = { key: 'live.retrying', params: { s: Math.round(delay / 1000), n: attempt + 1, max: this.retryDelays.length + 1 } }; | ||
| message.live = { | ||
| key: 'live.retrying', | ||
| params: { | ||
| s: Math.round(delay / 1000), | ||
| n: attempt + 1, | ||
| // 有次数上限 ⇒ 如实告诉用户还剩几次机会。 | ||
| max: this.retryDelays.length + 1, | ||
| }, | ||
| }; | ||
| flush(true); | ||
| const waited = await sleepUnlessAborted(delay, controller.signal); | ||
| message.live = undefined; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -586,7 +586,7 @@ export function createMcpHandler({ team, engine }) { | |
| result = { | ||
| protocolVersion: PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0], | ||
| capabilities: { tools: { listChanged: false } }, | ||
| serverInfo: { name: 'minimax-bot', version: '0.7.0' }, | ||
| serverInfo: { name: 'minimax-bot', version: '0.7.5' }, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 请 rebase 后在 main 当时的版本号上再升一号,与另外两处保持一致。 |
||
| instructions: t('mcp.instructions'), | ||
| }; | ||
| break; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| { | ||
| "name": "minimax-bot", | ||
| "version": "0.7.4", | ||
| "version": "0.7.5", | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 请 rebase 后在 main 当时的版本号上再升一号,与另外两处保持一致。 |
||
| "private": true, | ||
| "type": "module", | ||
| "mcode": { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| // 回合级重试:重试窗口 + 暂时性错误判定。 | ||
| // | ||
| // 2026-10-20 收敛(依据上游 review 意见): | ||
| // - 上游 0.7.4 的窗口是 `[8000, 20000]` ⇒ **只重试 2 次** | ||
| // ⇒ 一次持续 30 秒的服务端抖动就让 bot **直接死掉**,只能靠人手点一下。 | ||
| // - 但「无限重试」又与维护者「给连续重试设个上限」的要求相反。 | ||
| // ⇒ 收敛成:**默认窗口延长到 10 档(8s→20s→40s→60s×6,约 7.5 分钟)**,仍然有上限。 | ||
| // | ||
| // ⚠️ 同时守住两条边界: | ||
| // 1. 400 / 404 / 422 **不重试**(重试它们只是把同一次拒绝重复十遍) | ||
| // 2. 正常消息(「用了 400 tokens」「第 404 步」)不会被误判成错误 | ||
| import { test } from 'node:test'; | ||
| import assert from 'node:assert/strict'; | ||
| import { isTransientErrorMessage } from '../miniapp/node/engine.mjs'; | ||
|
|
||
| /** 与 engine.mjs 里的默认窗口保持一致(改一处就要同步这里)。 */ | ||
| const DEFAULT_WINDOW_MS = [8000, 20000, 40000, 60000, 60000, 60000, 60000, 60000, 60000, 60000]; | ||
|
|
||
| test('默认重试窗口:10 档、指数退避、封顶 60 秒,总窗口约 8 分钟', () => { | ||
| assert.equal(DEFAULT_WINDOW_MS.length, 10, '必须有明确的上限(不是无限重试)'); | ||
| assert.equal(DEFAULT_WINDOW_MS[0], 8000); | ||
| assert.equal(DEFAULT_WINDOW_MS[1], 20000); | ||
| assert.equal(DEFAULT_WINDOW_MS[2], 40000); | ||
| for (const d of DEFAULT_WINDOW_MS.slice(3)) assert.equal(d, 60000, '之后一律封顶 60s'); | ||
| const total = DEFAULT_WINDOW_MS.reduce((a, b) => a + b, 0); | ||
| assert.ok(total >= 7 * 60_000 && total <= 9 * 60_000, `总窗口应约 8 分钟,实际 ${total}ms`); | ||
| }); | ||
|
|
||
| test('暂时性错误会被认出来(服务端 / 网络故障)', () => { | ||
| for (const m of [ | ||
| 'net::ERR_NETWORK_CHANGED', | ||
| 'Internal server error (500)', | ||
| '502 Bad Gateway', | ||
| 'service unavailable', | ||
| '请求处理超时 (2066)', | ||
| 'unknown error, 520 (1000)', | ||
| 'rate limit exceeded', | ||
| 'The model is busy right now', | ||
| 'HTTP 500', | ||
| 'status 502', | ||
| ]) { | ||
| assert.equal(isTransientErrorMessage(m), true, `应判为暂时性:${m}`); | ||
| } | ||
| }); | ||
|
|
||
| test('400 / 404 / 422 不重试(重试只是把同一次拒绝重复十遍)', () => { | ||
| for (const m of [ | ||
| 'HTTP 400 Bad Request', | ||
| 'HTTP 404 Not Found', | ||
| 'code=422', | ||
| '400 Bad Request', | ||
| '404 Not Found', | ||
| ]) { | ||
| assert.equal(isTransientErrorMessage(m), false, `不该重试:${m}`); | ||
| } | ||
| }); | ||
|
|
||
| test('正常消息不会被误判成错误', () => { | ||
| for (const m of [ | ||
| '用了 400 tokens', | ||
| '第 404 步完成', | ||
| '剩余 422 积分', | ||
| '当前已达到 Token Plan 用量上限 (2067)', | ||
| '积分已耗尽', | ||
| '', | ||
| undefined, | ||
| ]) { | ||
| assert.equal(isTransientErrorMessage(m), false, `不该判为暂时性:${m}`); | ||
| } | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
请 rebase 后在 main 当时的版本号上再升一号,与另外两处保持一致。