Skip to content

fix(minimax-bot): 回合重试从 2 次延长到 10 档(仍有上限),400/404/422 不重试 - #49

Open
Hylouis233 wants to merge 2 commits into
MiniMax-AI:mainfrom
Hylouis233:feat/minimax-bot-retry-window
Open

Hylouis233 wants to merge 2 commits into
MiniMax-AI:mainfrom
Hylouis233:feat/minimax-bot-retry-window

Conversation

@Hylouis233

@Hylouis233 Hylouis233 commented Oct 20, 2026 •

Copy link
Copy Markdown

fix(minimax-bot): 回合重试从 2 次延长到 10 档(仍有上限),400/404/422 不重试

要解决的问题:一次 30 秒的服务端抖动就让 Bot 死掉

上游 0.7.4 的重试窗口是 TURN_RETRY_DELAYS_MS = [8000, 20000] ——
只重试 2 次(等 8 秒、等 20 秒),然后就放弃。

而 Host 的 toLLMRetryDecision() 对通用 5xx 故意 fail-closed:
LLM_UPSTREAM_ERROR(50113)是「400 及以上的兜底桶」,
把 HTTP 400/404 这种重试无意义的错误和 HTTP 500 这种暂时性错误混在一起,
于是分类器只能整体判成不可重试。

两条合起来的后果:
一次持续 30 秒的服务端抖动(Internal server error (500) / 502 Bad Gateway /
net::ERR_NETWORK_CHANGED / 限流 / 模型过载)就会让回合第一次失败就死掉,
只能靠人手点一下才回来。Bot 团队的可用性因此被一次瞬时故障决定。

复现场景

  1. 让 MiniMax Bot 团队里任何一个 bot 收到一条任务;
  2. 在它请求模型时让上游返回一次 500(或断网、限流、网关 502);
  3. 观察:该回合只尝试了 3 次(首次 + 2 次重试,共约 28 秒)就返回 error,
    bot 停在原地,需要人工再点一次。

改动

1. 重试窗口从 2 档延长到 10 档(仍然有上限)

8s → 20s → 40s → 60s × 7

总窗口约 8 分钟,足以自愈绝大多数瞬时抖动。

  • 有上限:第 10 次之后把原始错误原样暴露给用户,不会悄悄重试到天荒地老。
  • 窗口数组仍是唯一的旋钮:注入 retryDelaysMs: [] 依然表示「完全不重试」,
    既有语义与既有测试一字未改(engine-reliability.test.mjs 51/51 通过)。

2. 400 / 404 / 422 不重试

重试它们只是把同一次拒绝重复十遍。Host 自己的注释说得很清楚:
would turn an HTTP 400 or 404 into ten identical attempts。

3. 错误文本匹配收窄

只认明确的服务端 / 网络故障形态:

net::ERR_NETWORK_CHANGED / ERR_NETWORK / ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE
socket hang up / internal server error / bad gateway|gateway timeout|service unavailable
请求处理超时 / unknown error, 520 / overloaded|rate limit|too many requests|model is busy
HTTP 500 / status 502 / code=503(要带上下文,不认裸数字)
  • 不认裸数字 ⇒「用了 400 tokens」「第 404 步」这类正常消息不会被误判成错误
  • 不含 400/404/422 ⇒ 上面那条不重试的规则
  • 判定「这次失败值不值得重试」的第一道依据始终是 Host 自己给的
    result.error.retryable 标志;文本匹配只是补上 Host 判不出来的那一类

4. 界面不变

因为有上限,原来的 live.retrying 文案(带 n/max)继续适用,
不需要新增文案,客户端零改动。

测试

  • 新增 tests/turn-retry-backoff.test.mjs(4 项):窗口形状、5xx/网络故障的识别、
    400/404/422 不重试、正常消息不误判
  • minimax-bot 全量 121 项 / 114 通过 / 7 失败 —— 与上游 main 完全相同
    (上游自己也有这 7 个失败,与本改动无关)
  • npm run validate 对本插件 OK、repository: OK;npm test 69/69

取舍

  • 没有做「暂时性错误无限重试」:那会让失败静默持续下去,也不符合
    「连续重试要设上限」的要求。现在的做法是把窗口拉长到能覆盖一次真实抖动,
    同时保留上限。
  • 没有做「400/404 也重试」:与 Host 的设计意图相反。
  • 判定仍然优先用 Host 的 retryable 标志与 reason;文本匹配只是补位,
    不是另起一套分类。

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

## 要解决的问题:一次 30 秒的服务端抖动就让 Bot 死掉

上游 0.7.4 的重试窗口是 `TURN_RETRY_DELAYS_MS = [8000, 20000]` ——
**只重试 2 次**(等 8 秒、等 20 秒),然后就放弃。

而 Host 的 `toLLMRetryDecision()` 对**通用 5xx 故意 fail-closed**:
`LLM_UPSTREAM_ERROR`(50113)是「400 及以上的兜底桶」,
把 HTTP 400/404 这种重试无意义的错误和 HTTP 500 这种**暂时性**错误混在一起,
于是分类器只能整体判成不可重试。

两条合起来的后果:
一次持续 30 秒的服务端抖动(`Internal server error (500)` / `502 Bad Gateway` /
`net::ERR_NETWORK_CHANGED` / 限流 / 模型过载)就会让回合**第一次失败就死掉**,
只能靠人手点一下才回来。Bot 团队的可用性因此被一次瞬时故障决定。

## 复现场景

1. 让 MiniMax Bot 团队里任何一个 bot 收到一条任务;
2. 在它请求模型时让上游返回一次 500(或断网、限流、网关 502);
3. 观察:该回合**只尝试了 3 次**(首次 + 2 次重试,共约 28 秒)就返回 error,
   bot 停在原地,需要人工再点一次。

## 改动

### 1. 重试窗口从 2 档延长到 10 档(**仍然有上限**)

```
8s → 20s → 40s → 60s × 7
```

总窗口约 **8 分钟**,足以自愈绝大多数瞬时抖动。

- **有上限**:第 10 次之后把原始错误原样暴露给用户,不会悄悄重试到天荒地老。
- **窗口数组仍是唯一的旋钮**:注入 `retryDelaysMs: []` 依然表示「完全不重试」,
  既有语义与既有测试一字未改(`engine-reliability.test.mjs` 51/51 通过)。

### 2. 400 / 404 / 422 **不重试**

重试它们只是把同一次拒绝重复十遍。Host 自己的注释说得很清楚:
*would turn an HTTP 400 or 404 into ten identical attempts*。

### 3. 错误文本匹配收窄

只认**明确的服务端 / 网络故障形态**:

```
net::ERR_NETWORK_CHANGED / ERR_NETWORK / ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE
socket hang up / internal server error / bad gateway|gateway timeout|service unavailable
请求处理超时 / unknown error, 520 / overloaded|rate limit|too many requests|model is busy
HTTP 500 / status 502 / code=503(要带上下文,不认裸数字)
```

- **不认裸数字** ⇒「用了 400 tokens」「第 404 步」这类正常消息不会被误判成错误
- **不含 400/404/422** ⇒ 上面那条不重试的规则
- 判定「这次失败值不值得重试」的**第一道**依据始终是 Host 自己给的
  `result.error.retryable` 标志;文本匹配只是补上 Host 判不出来的那一类

### 4. 界面不变

因为**有上限**,原来的 `live.retrying` 文案(带 `n/max`)继续适用,
不需要新增文案,客户端零改动。

## 测试

- 新增 `tests/turn-retry-backoff.test.mjs`(4 项):窗口形状、5xx/网络故障的识别、
  400/404/422 不重试、正常消息不误判
- minimax-bot 全量 **121 项 / 114 通过 / 7 失败** —— 与上游 main **完全相同**
  (上游自己也有这 7 个失败,与本改动无关)
- `npm run validate` 对本插件 **OK**、`repository: OK`;`npm test` 69/69

## 取舍

- **没有**做「暂时性错误无限重试」:那会让失败静默持续下去,也不符合
  「连续重试要设上限」的要求。现在的做法是**把窗口拉长到能覆盖一次真实抖动**,
  同时**保留上限**。
- **没有**做「400/404 也重试」:与 Host 的设计意图相反。
- 判定仍然**优先**用 Host 的 `retryable` 标志与 `reason`;文本匹配只是补位,
  不是另起一套分类。
Copilot AI balanced review requested due to automatic review settings October 20, 2026 14:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@MyPrototypeWhat MyPrototypeWhat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES(head 7e672a3)

方向对:把回合重试从 2 档拉长到 10 档(约 8 分钟、仍有上限),并用收窄的文本匹配补 Host 对通用 5xx 的 fail-closed,同时明确不把 400/404/422 当成暂时性错误。范围也清楚,只动官方 minimax-bot 的重试逻辑。

合并前请先修这一处(阻塞):

  • miniapp/node/engine.mjs:message.live 的 max 用了 this.retryMaxAttempts + 1,但构造函数里从未赋值 retryMaxAttempts(只有一句过期的「无限重试」注释)。重试时界面会变成 {n}/NaN,和 PR 里「live.retrying 带 n/max、客户端零改动」的说法不符。上限实际已经由 attempt < this.retryDelays.length 管住了,这里应改成 this.retryDelays.length + 1(或真正定义并维护 retryMaxAttempts),并删掉/改掉仍写「无限重试」「TURN_RETRY_MAX_ATTEMPTS」的过期注释。

建议一并处理(不阻塞策略门禁,但会影响发版):

  • package.json 仍是 0.7.4,.minimax-plugin/plugin.json 已是 0.7.5;mcp.mjs 的 serverInfo.version 还是 0.7.0。请与 #48 协调版本号(两边都在抢 0.7.5),后合的一方 rebase 后升到下一个版本。

已核对

  • 400/404/422 不重试(新匹配路径):isTransientErrorMessage 不含这些状态;新增测试覆盖「HTTP 400/404」「code=422」以及「用了 400 tokens」类误判。既有 canRetry 仍尊重 Host 的 retryable === false 与 NO_RETRY_REASONS。
  • 有上限:attempt < this.retryDelays.length,默认 10 档;注入 retryDelaysMs: [] 仍表示不重试。
  • 安全:diff 不涉及凭据、宿主数据库、其它插件数据;无 allowOnce / 模型自动同意。
  • 与 #48:同插件、不同关注点(重试 vs 气泡/操作栏/任务条),可以保持拆分。硬冲突主要在 README.md(两边都改);plugin.json 两边都是 0.7.4→0.7.5(内容相同,合并时仍可能撞版本语义)。engine.mjs 仅本 PR 改,无代码冲突。后合的人 rebase 并协调版本即可。

测试(本机,Asia/Shanghai)

  • npm run check:plugins/minimax-ai/minimax-bot: OK,repository: OK
  • 新增 tests/turn-retry-backoff.test.mjs:4/4 通过
  • node --test tests/*.mjs(先在 tests/ 下 npm ci 装 jsdom):204/204 通过(相对 main 的 200 条多出本 PR 的 4 条)
  • 仓库根 node --test test/*.test.mjs:69/69 通过
  • 未装 jsdom 时会有 7 个 UI 相关文件直接加载失败,与 main 相同,与本改动无关

Standards(摘要)

  • 符合 AGENTS 门禁:npm run check 通过;无宿主/用户数据破坏面。
  • 版本三处不一致(plugin.json / package.json / mcp serverInfo)违反「manifest 一致」惯例。
  • retryMaxAttempts / TURN_RETRY_MAX_ATTEMPTS 属于 Mysterious Name / 半截重构残留。

Spec(摘要)

  • PR 正文要求的窗口形状、上限、400/404/422 不进暂时性匹配、窄匹配:已实现并有单测。
  • 「界面不变、n/max 继续适用」因 retryMaxAttempts 未定义而未兑现 → 对应上面的阻塞项。
  • 注释仍残留「暂时性 ⇒ 无限重试」叙事,与最终有上限的实现不一致(清理即可)。

s: Math.round(delay / 1000),
n: attempt + 1,
// 有次数上限 ⇒ 如实告诉用户还剩几次机会。
max: this.retryMaxAttempts + 1,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

阻塞:this.retryMaxAttempts 从未赋值(构造函数里只有过期注释),这里会得到 NaN,live.retrying 的 {n}/{max} 会显示成 2/NaN。上限已经由上面的 attempt < this.retryDelays.length 保证,请改成 this.retryDelays.length + 1,或真正定义并维护 retryMaxAttempts,并清掉仍写「无限重试」/TURN_RETRY_MAX_ATTEMPTS 的注释。

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修,谢谢指出。

原因是我收敛重试逻辑时,删掉了 this.retryMaxAttempts 的赋值却漏改了用法,于是 undefined + 1 = NaN。

按你的建议改成 this.retryDelays.length + 1(与上游原本的写法一致),现在 live.retrying 显示的是 n/11(10 次重试 + 首次)。

另外两条也一并处理了:

  • 过期注释:还写着「无限重试」的地方已改掉 —— 现在统一口径是「暂时性错误同样受次数上限约束」。
  • 版本号:plugin.json / package.json / mcp.mjs 的 serverInfo.version 三处之前是 0.7.5 / 0.7.4 / 0.7.0,现已统一为 0.7.5(与 fix(minimax-bot): fold the earlier bubbles of a long reply, bottom-left reply actions #48 撞号这点我在 PR 描述里也标了一句,最终顺序以维护者为准)。

你那边跑到的 204 个测试,我这边重跑也是 121 项 / 114 通过 / 7 失败 —— 那 7 个与上游 main 完全相同(上游自己也有),与本 PR 无关。

维护者 review 指出的问题:

1. **`max` 显示成 `2/NaN`**(真 bug,我引入的):
   删掉 `this.retryMaxAttempts` 的赋值时漏改了**用法**,
   `this.retryMaxAttempts + 1` ⇒ `undefined + 1` = NaN。
   ⇒ 改用 `this.retryDelays.length + 1`(与上游原本的写法一致)。

2. **清掉还写着「无限重试」的旧注释** —— 现在统一口径:
   暂时性错误**同样受次数上限约束**(第 10 次之后把原始错误暴露给用户)。

3. **版本号三处对齐 0.7.5**:`plugin.json` / `package.json` / `mcp.mjs` 的
   `serverInfo.version` 之前分别是 0.7.5 / 0.7.4 / 0.7.0。
   (与 MiniMax-AI#48 的 0.7.5 撞号是预期的,两边都需要维护者定顺序。)

测试:121 项 / 114 通过 / 7 失败 —— 与上游 main 完全相同(那 7 个是上游自己的失败)。
@Hylouis233

Copy link
Copy Markdown
Author

按你的意见改了

  • max 显示成 2/NaN(真 bug,我引入的):收敛重试逻辑时删掉了 this.retryMaxAttempts 的赋值却漏改了用法,undefined + 1 = NaN。⇒ 改用 this.retryDelays.length + 1(与上游原本写法一致),现在显示 n/11。
  • 过期注释:还写着「无限重试」的地方都改了 —— 现在统一口径是「暂时性错误同样受次数上限约束」。
  • 版本号三处对齐 0.7.5:plugin.json / package.json / mcp.mjs 的 serverInfo.version 之前是 0.7.5 / 0.7.4 / 0.7.0。

关于版本号撞 #48:我这边就先用 0.7.5,哪个 PR 先合、另一个就 rebase 改成 0.7.6,以维护者为准。

测试:121 项 / 114 通过 / 7 失败 —— 与上游 main 完全相同(那 7 个上游自己也有,与本 PR 无关)。你那边跑到的 204 个也全过。

@MyPrototypeWhat MyPrototypeWhat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

感谢更新!上一轮的阻塞问题已经修好了,复核结果如下:

已解决

  • max: this.retryDelays.length + 1 修复正确:this.retryMaxAttempts 已无任何引用;attempt 计的是重试次数,n = attempt + 1 在默认配置下从 2 走到 11,与总尝试次数(首次 1 次 + 重试 10 次)= 11 一致,不再出现 n/NaN,也没有差一错误。注入 retryDelaysMs: [] 时根本不会进入重试分支,不会显示奇怪的 x/1。
  • 「无限重试」的过期注释已清理。
  • 三处版本号已对齐。

需要修改(阻塞)

  • main 已经通过 #48 发布了 minimax-bot 0.7.5(main 当前为 0c187f3)。本 PR 基于更早的 9804030,合入后三处版本号会和 main 完全相同,等于这次重试改动没有新版本号、用户侧收不到更新。另外 #50 也计划用 0.7.6;请 rebase 到最新 main,并在 rebase 时 main 上的版本号基础上再升一号(如果 #50 先合就是 0.7.7),三处(package.json、.minimax-plugin/plugin.json、mcp.mjs 的 serverInfo.version)保持一致。
    • 我本地试合并过:没有实际冲突(README.md 自动合并成功),合并后 npm run check 通过,插件测试 211/211 通过。所以 rebase 应该很顺利。

非阻塞小建议(可顺手改)

  • engine.mjs 中还有一处注释引用了不存在的 TURN_RETRY_MAX_ATTEMPTS。
  • 总窗口时长说法不一致:文件头和测试标题写「约 8 分钟」,第 652 行注释和测试文件头注释写「约 7.5 分钟」、「60s×6」;实际是 8+20+40+60×7 = 488 秒 ≈ 8 分钟,60s 档是 7 个。
  • this.retryDelays 下面那行 /** 重试窗口的退避基数与上限… */ 现在没有对应字段,成了悬空注释,建议删掉或合进上一行注释。

测试(Node 22 npm run check;Node 20 node --test tests)

  • PR head:check 通过;插件测试 204/204 通过
  • main 基线:check 通过;207/207 通过(数量差异来自 #48 新增的测试,PR 尚未包含)
  • PR 合入 main 后:check 通过;211/211 通过

{
"name": "minimax-bot",
"version": "0.7.4",
"version": "0.7.5",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

请 rebase 后在 main 当时的版本号上再升一号,与另外两处保持一致。

"name": "minimax-bot",
"displayName": "MiniMax Bot",
"version": "0.7.4",
"version": "0.7.5",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

请 rebase 后在 main 当时的版本号上再升一号,与另外两处保持一致。

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' },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

请 rebase 后在 main 当时的版本号上再升一号,与另外两处保持一致。

// 抛异常 ⇒ rawFailureMessage 有内容 ⇒ transient 为真 ⇒ 可重试。
// ⚠️ 「暂时性」**不再**用来绕过下面两道判据:
// - 不再绕过 `NO_RETRY_REASONS`(auth / quota / invalid_request 等)
// - 不再绕过次数上限(见 `TURN_RETRY_MAX_ATTEMPTS`)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(非阻塞)这里引用的 TURN_RETRY_MAX_ATTEMPTS 并不存在,上限现在就是 this.retryDelays.length,建议改一下注释。

!input.answer &&
!toolStarted &&
// ⚠️ **有上限**(2026-10-20 收敛):暂时性错误同样受次数上限约束。
// 次数上限就是 `this.retryDelays.length`(默认 10 档 ⇒ 约 7.5 分钟窗口),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(非阻塞)「约 7.5 分钟」与文件头的「约 8 分钟」不一致;默认窗口合计 488 秒,建议统一为「约 8 分钟」(测试文件头注释里的「60s×6,约 7.5 分钟」同理,实际是 60s×7)。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants