pi-error-retry 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,130 @@
1
- # Temporary Holding Version
1
+ # pi-error-retry
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 独立 Pi 扩展,移植 `@local/dsh-error-retry` 的规则重试行为,并增加输出长度截断后的自动续写。当前在本机 Pi **1.4.0** 上验证。
4
+
5
+ ## 必须区分的两种行为
6
+
7
+ | 情况 | 行为 | 模型上下文 |
8
+ | --- | --- | --- |
9
+ | `stopReason: "error"` 且规则命中 | 无限重试当前模型请求 | 追加 `context_edit`,省略失败回复;不插入“继续”或新的用户输入 |
10
+ | `stopReason: "length"` | 自动继续完成原任务 | 保留截断回复,追加明确续写提示;不从头重试 |
11
+ | 未匹配错误 | 本扩展不接管 | Pi 原有重试/恢复策略不变 |
12
+ | 用户停止、取消或卸载 | 停止 | 不在后台定时唤醒会话 |
13
+
14
+ Pi 中的 `Response was truncated before completion.` 对应第二种情况。续写不增加错误重试计数,也不使用错误退避。再次截断会再次续写,直至正常完成或用户停止。
15
+
16
+ Pi 自带的有限错误重试先行;它耗尽后,本扩展接管匹配错误。扩展重试发起的新请求仍可能经过 Pi 原有有限重试。不会全局关闭内置重试,也不会把所有错误都设为无限重试。
17
+
18
+ ### 为什么重试不会重放工具
19
+
20
+ 例如:原任务 → 工具 A 已执行 → 请求下一步失败。本扩展仅省略这个失败回复,下一次请求仍包含工具 A 的结果,不重新提交原任务,也不重新执行工具 A。
21
+
22
+ 截断时,Pi 本身会将截断回复中的工具调用标记失败,不执行可能残缺的参数。本扩展保留这些结果,要求模型重新生成尚未执行的调用的完整参数。它与自然工具后续共用一次 continuation,不额外叠加第二次请求。
23
+
24
+ ## 安装
25
+
26
+ 从 npm 安装:
27
+
28
+ ```sh
29
+ pi install npm:pi-error-retry
30
+ ```
31
+
32
+ 也可直接从 GitHub 安装:
33
+
34
+ ```sh
35
+ pi install git:github.com/librola/pi-error-retry
36
+ ```
37
+
38
+ 在现有 Pi 会话中执行 `/reload`;新会话自动加载。克隆后的本地源码无需编译,也可临时加载:
39
+
40
+ ```sh
41
+ pi -e ./pi-error-retry/index.ts
42
+ ```
43
+
44
+ 安装声明位于 `~/.pi/agent/settings.json` 的 `packages` 数组。本扩展只注册事件和用户命令,没有新增模型可调用的配置工具。
45
+
46
+ 不要同时启用其他错误恢复/长度续写扩展,避免竞争触发。
47
+
48
+ ## 配置和编辑
49
+
50
+ 用户配置文件:
51
+
52
+ ```text
53
+ ~/.pi/agent/error-retry.json
54
+ ```
55
+
56
+ 设置了 `PI_CODING_AGENT_DIR` 时使用该目录。规则只从用户目录读取,不读取项目里的规则覆盖。
57
+
58
+ ```text
59
+ /error-retry 查看规则数量、配置位置和两种恢复行为
60
+ /error-retry edit 用 Pi 原生 JSON 编辑器修改规则
61
+ /error-retry stop 停止当前请求
62
+ ```
63
+
64
+ Pi 原生编辑器适用于 TUI 和支持该对话协议的 RPC 客户端;不提供 DSH 的插件详情页。JSON/print 模式请直接编辑文件。
65
+
66
+ - 规则每次由扩展接管错误前重新读取,不需要重新加载或卸载扩展。
67
+ - 保存时校验配置并原子写入;文件原文充当 revision,跨窗口保存使用排他锁。版本冲突不会覆盖他人修改。
68
+ - 非法草稿或保存失败不显示成功,并可以保留草稿继续编辑、主动读取最新配置,或取消。
69
+ - 保存不取消已经排定的等待;已排定的重试可能完成一次,下一次错误判定读取新规则。立即停止请使用标准停止按钮或 `/error-retry stop`。
70
+ - 非法配置、读取失败或正则匹配超时不被当成命中,也不静默回退为宽泛规则。
71
+
72
+ ### 规则格式
73
+
74
+ ```json
75
+ {
76
+ "rules": [
77
+ { "mode": "contains", "pattern": "Quota service is temporarily unavailable." },
78
+ { "mode": "regex", "pattern": "^temporary overload(?:\\s|$)", "flags": "i" }
79
+ ]
80
+ }
81
+ ```
82
+
83
+ - 规则之间为 **OR**,任意一条命中就无限重试。
84
+ - `contains` 区分大小写,不接受 `flags`。
85
+ - `regex` 使用 JavaScript 正则,不写外围斜线;`flags` 只允许不重复的 `i/m/s/u`,不允许有状态的 `g/y`。
86
+ - `pattern` 不能为空或纯空白;有意义的前后空白会保留。
87
+ - 整组匹配在 `node:vm` 中共享 **25ms** 预算,超时不接管。这里只用 VM 限制正则回溯,不把它当成恶意代码安全沙箱。
88
+ - 显式提供 `rules` 完全替换默认规则,不追加。`{"rules":[]}` 仅关闭本扩展的错误重试,**不会关闭长度续写或 Pi 内置重试**。
89
+
90
+ 不提供文件或省略 `rules` 时,默认与 DSH 源码一致,使用两条完整句子:
91
+
92
+ 1. `Requests have exceeded the throughput limit on your Provisioned-Managed deployment. If you continue to exceed your limit, consider increasing the number of provisioned throughput units deployed.`
93
+ 2. `Your request exceeds the maximum size allowed by your deployment's quota.`
94
+
95
+ `examples/error-retry.json` 提供从 DSH 配置迁移的 10 条示例规则;按需复制到用户配置文件使用。安装不会自动用示例覆盖你的配置,扩展默认列表仍保持上述两条。
96
+
97
+ ## 等待、日志和范围
98
+
99
+ - 匹配错误的重试次数和累计等待时间均不设上限。
100
+ - 本地退避约为 1、2、4、8、16、30 秒,带 10% 抖动,最大 30 秒。
101
+ - 有效 `Retry-After-Ms`/`Retry-After` 响应头优先,支持毫秒、秒和 HTTP 日期;服务端长等待不截短,定时器分段避免溢出。
102
+ - 最终结束边界没有运行取消信号。因此边界只准备重试,实际等待在下一次 `agent_start`、发送模型请求之前进行,使用运行信号与扩展生命周期信号。标准停止可以立即取消长等待。
103
+ - 重试等待显示状态栏;`pi-error-retry.recovery` 非消息条目记录 `retry`、`retry-started` 和 `continue`。计数从当前分支日志恢复,按提供方、模型及规范化策略隔离;成功步骤、新用户输入或规则变化重置。
104
+ - 失败回复及其用量仍保留在原始会话历史和统计中,只从后续模型上下文省略,不删除历史。
105
+ - 用户已排队的新输入或其他扩展已选定的 continuation 优先,不追加竞争请求。
106
+ - 作用范围为加载了此扩展的 Pi Agent 主循环,包括同样加载此包的 Pi 子会话;不包装工具内部 HTTP、辅助总结调用或外部 CLI。外部子代理不会因为父会话加载而自动获得此扩展。
107
+ - 加载扩展不会自动唤醒已经结束的旧错误回合,需要用户手动继续。
108
+
109
+ **风险:无限重试可能产生无限请求和费用。** 配额/请求大小问题未必能靠等待修复,过宽正则也可能永远不成功。请保留明确规则,遇到永久错误及时停止并修正输入或部署。
110
+
111
+ ## 验证
112
+
113
+ 开发依赖安装不会安装另一份 Pi:
114
+
115
+ ```sh
116
+ npm install --ignore-scripts --legacy-peer-deps
117
+ export PI_TEST_PACKAGE=/path/to/pi-coding-agent/package.json
118
+ npm run check
119
+ npm test
120
+ ```
121
+
122
+ `PI_TEST_PACKAGE` 指向已安装 Pi 的包清单。不设置时从项目依赖中寻找宿主 Pi。测试使用真实 SDK、扩展 TypeScript 加载器、会话上下文投影及模拟 provider;不调用真实模型,也不读取用户模型凭据。临时文件在系统临时目录中创建和清理。
123
+
124
+ 覆盖:101 次连续错误、内置重试串联、请求输入不变、截断前文保留、截断后错误重试、已执行工具不重放、截断工具不执行、取消长等待、规则热更新/冲突/校验、正则超时、退避、服务端等待及日志计数隔离。
125
+
126
+ 当前 **32 项测试通过**,TypeScript 严格类型检查通过。
127
+
128
+ 另有一个已独立对照复现的宿主限制:SDK 直接对活动会话调用 `session.dispose()` 时,Pi 1.4.0 会先解绑持久化订阅,随后可能报告 `turn_end could not resolve the persisted assistant entry ID`;仅注册空 `turn_end` 处理器、不加载本扩展也能复现。本扩展不会因此继续请求,也不会访问已失效的上下文。该宿主问题未修改;正常标准停止已验证,SDK 集成建议先 `await session.abort()` 再 `session.dispose()`。
129
+
130
+ 真实提供方故障恢复和客户端 UI 交互尚未人工验收;SDK 与加载器验证不等同于实际付费请求验收。
@@ -0,0 +1,45 @@
1
+ {
2
+ "rules": [
3
+ {
4
+ "mode": "contains",
5
+ "pattern": "Requests have exceeded the throughput limit on your Provisioned-Managed deployment. If you continue to exceed your limit, consider increasing the number of provisioned throughput units deployed."
6
+ },
7
+ {
8
+ "mode": "contains",
9
+ "pattern": "Your request exceeds the maximum size allowed by your deployment's quota."
10
+ },
11
+ {
12
+ "mode": "contains",
13
+ "pattern": "No available PLB channels. Is the PLB down"
14
+ },
15
+ {
16
+ "mode": "contains",
17
+ "pattern": "has been reached: 600000 tokens per 60 seconds."
18
+ },
19
+ {
20
+ "mode": "contains",
21
+ "pattern": "The model was unable to complete inference due to an internal error."
22
+ },
23
+ {
24
+ "mode": "regex",
25
+ "pattern": "Models Proxy:\\s*The\\s+(?:[A-Za-z][A-Za-z-]*\\s+){0,4}limit for model\\s+\\\\?\"[^\"\\r\\n]+\\\\?\"\\s+has been reached:\\s*\\d+(?:\\.\\d+)?\\s+tokens per\\s+\\d+(?:\\.\\d+)?\\s+seconds?\\.\\s*Please retry after\\s+\\d+(?:\\.\\d+)?\\s+seconds?\\.",
26
+ "flags": "i"
27
+ },
28
+ {
29
+ "mode": "contains",
30
+ "pattern": "Models Proxy: 502 Bad Gateway"
31
+ },
32
+ {
33
+ "mode": "contains",
34
+ "pattern": "pi-ai stream idle timeout after 300000ms"
35
+ },
36
+ {
37
+ "mode": "contains",
38
+ "pattern": "Quota service is temporarily unavailable."
39
+ },
40
+ {
41
+ "mode": "contains",
42
+ "pattern": "Upstream service unavailable"
43
+ }
44
+ ]
45
+ }
package/index.ts ADDED
@@ -0,0 +1,203 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { ExtensionAPI, SessionEntry, TurnEndEvent } from "@earendil-works/pi-coding-agent";
5
+ import { readConfig, saveConfig } from "./src/config.ts";
6
+ import { matchesRules, policyKey, providerRetryAfter, retryDelay, waitForRetry } from "./src/rules.ts";
7
+
8
+ export const RECOVERY_ENTRY = "pi-error-retry.recovery";
9
+ export const CONTINUE_MESSAGE =
10
+ "上一条回复因单次输出长度限制被截断。请从停止处继续完成原任务,不要重述已输出内容,不要重复已完成的工具操作。如果工具调用被截断,请重新生成完整参数,仅重发尚未执行的调用。";
11
+
12
+ interface RetryRecord {
13
+ kind: "retry";
14
+ retryId: string;
15
+ retry: number;
16
+ policyKey: string;
17
+ provider: string;
18
+ model: string;
19
+ messageEntryId: string;
20
+ delayMs: number;
21
+ errorMessage: string;
22
+ }
23
+
24
+ export function previousRetry(branch: SessionEntry[], key: string, provider: string, model: string): RetryRecord | undefined {
25
+ // 仅重建当前分支的当前请求步骤;成功、用户新输入或续写消息均开启新步骤。
26
+ for (const entry of [...branch].reverse()) {
27
+ if (entry.type === "message") {
28
+ const message = entry.message;
29
+ if (message.role === "user" || (message.role === "assistant" && message.stopReason !== "error")) return;
30
+ }
31
+ if (entry.type === "custom_message") return;
32
+ if (entry.type !== "custom" || entry.customType !== RECOVERY_ENTRY) continue;
33
+ const record = entry.data as Partial<RetryRecord> | undefined;
34
+ if (record?.kind !== "retry" || record.provider !== provider || record.model !== model) continue;
35
+ if (record.policyKey !== key) return;
36
+ if (typeof record.retry === "number" && Number.isSafeInteger(record.retry) && record.retry > 0 &&
37
+ typeof record.retryId === "string") return record as RetryRecord;
38
+ return;
39
+ }
40
+ }
41
+
42
+ export function registerErrorRetry(pi: ExtensionAPI, configPath: string): void {
43
+ let lifetime = new AbortController();
44
+ let lastTurn: { event: TurnEndEvent; responseDelay: number | undefined } | undefined;
45
+ let responseDelay: number | undefined;
46
+ let pendingRetry: RetryRecord | undefined;
47
+
48
+ pi.on("session_start", () => {
49
+ lifetime = new AbortController();
50
+ lastTurn = undefined;
51
+ pendingRetry = undefined;
52
+ });
53
+ pi.on("session_shutdown", () => lifetime.abort());
54
+ pi.on("before_agent_start", () => {
55
+ // 加载扩展不会唤醒旧错误;新的用户请求也不能继承旧的排队等待。
56
+ lastTurn = undefined;
57
+ pendingRetry = undefined;
58
+ });
59
+ pi.on("turn_start", () => { responseDelay = undefined; });
60
+ pi.on("after_provider_response", (event) => { responseDelay = providerRetryAfter(event.headers); });
61
+
62
+ pi.on("agent_start", async (_event, ctx) => {
63
+ const record = pendingRetry;
64
+ pendingRetry = undefined;
65
+ if (!record) return;
66
+ const signal = ctx.signal;
67
+ if (!signal || signal.aborted || lifetime.signal.aborted) return;
68
+ const cancellation = AbortSignal.any([signal, lifetime.signal]);
69
+ const cancel = () => {
70
+ // 在取消事件的同步阶段清理,dispose 随后会使 ctx 失效,不能在 await 后再访问它。
71
+ lastTurn = undefined;
72
+ pendingRetry = undefined;
73
+ ctx.ui.setStatus("error-retry", undefined);
74
+ if (!signal.aborted) ctx.abort();
75
+ };
76
+ cancellation.addEventListener("abort", cancel, { once: true });
77
+ ctx.ui.setStatus("error-retry", `无限重试 #${record.retry},等待 ${(record.delayMs / 1000).toFixed(1)} 秒`);
78
+ try {
79
+ // 最终边界没有运行信号:把等待放到下一次 agent_start,才能被标准停止按钮取消。
80
+ if (!await waitForRetry(record.delayMs, cancellation)) return;
81
+ pi.appendEntry(RECOVERY_ENTRY, {
82
+ kind: "retry-started", retryId: record.retryId, retry: record.retry,
83
+ messageEntryId: record.messageEntryId,
84
+ });
85
+ } finally {
86
+ cancellation.removeEventListener("abort", cancel);
87
+ if (!cancellation.aborted) ctx.ui.setStatus("error-retry", undefined);
88
+ }
89
+ });
90
+
91
+ pi.on("turn_end", (event, ctx) => {
92
+ lastTurn = { event, responseDelay };
93
+ const message = event.message;
94
+ if (message.role !== "assistant" || message.stopReason !== "length" || event.outcome === "aborted" ||
95
+ ctx.signal?.aborted || lifetime.signal.aborted || event.continue || ctx.hasPendingMessages() ||
96
+ event.context.pendingMessages.length > 0) return;
97
+ // length 不是错误重试:保留回复和 Pi 的截断工具失败结果,追加明确续写提示。
98
+ // turn_end 的一次 continuation 与自然工具后续共用调度,不会额外请求两次。
99
+ return {
100
+ continue: true,
101
+ entries: [
102
+ {
103
+ type: "custom" as const, customType: RECOVERY_ENTRY,
104
+ data: { kind: "continue", messageEntryId: event.messageEntryId },
105
+ },
106
+ {
107
+ type: "custom_message" as const, customType: "pi-error-retry.continue",
108
+ content: CONTINUE_MESSAGE, display: true,
109
+ },
110
+ ],
111
+ };
112
+ });
113
+
114
+ pi.on("agent_before_settle", (event, ctx) => {
115
+ const turn = lastTurn?.event;
116
+ const message = turn?.message;
117
+ if (!turn || message?.role !== "assistant" || message.stopReason !== "error" || event.outcome !== "error" ||
118
+ lifetime.signal.aborted || event.continue || ctx.hasPendingMessages() ||
119
+ event.context.pendingMessages.length > 0) return;
120
+ // 内置有限重试与溢出恢复先行;已被其他恢复省略的失败不再接管。
121
+ if (!event.context.contextEntries.some((entry) => entry.sourceEntry.id === turn.messageEntryId)) return;
122
+ let key: string;
123
+ try {
124
+ const { config } = readConfig(configPath);
125
+ if (!config.rules.length || !matchesRules(config.rules, message.errorMessage ?? "")) return;
126
+ key = policyKey(config.rules);
127
+ } catch (error) {
128
+ ctx.ui.notify(`错误重试未接管:${error instanceof Error ? error.message : String(error)}`, "warning");
129
+ return;
130
+ }
131
+ const previous = previousRetry(ctx.sessionManager.getBranch(), key, message.provider, message.model);
132
+ const retry = (previous?.retry ?? 0) + 1;
133
+ const record: RetryRecord = {
134
+ kind: "retry", retryId: previous?.retryId ?? randomUUID(), retry, policyKey: key,
135
+ provider: message.provider, model: message.model, messageEntryId: turn.messageEntryId,
136
+ delayMs: retryDelay(retry, lastTurn?.responseDelay), errorMessage: message.errorMessage ?? "",
137
+ };
138
+ pendingRetry = record;
139
+ // 追加省略记录而非删除历史;原失败及用量保留,模型上下文回到失败前的请求。
140
+ // 没有“继续”或伪用户消息,因此不会从头重放已经完成的工具。
141
+ return {
142
+ continue: true,
143
+ entries: [
144
+ { type: "context_edit" as const, targetId: turn.messageEntryId, replacement: null },
145
+ { type: "custom" as const, customType: RECOVERY_ENTRY, data: record },
146
+ ],
147
+ };
148
+ });
149
+
150
+ pi.registerCommand("error-retry", {
151
+ description: "查看错误重试规则;/error-retry edit 编辑规则,/error-retry stop 停止当前请求",
152
+ handler: async (args, ctx) => {
153
+ const action = args.trim();
154
+ if (action === "stop") {
155
+ pendingRetry = undefined;
156
+ ctx.abort();
157
+ return;
158
+ }
159
+ if (action && action !== "edit") {
160
+ ctx.ui.notify("用法:/error-retry [edit|stop]", "warning");
161
+ return;
162
+ }
163
+ try {
164
+ const snapshot = readConfig(configPath);
165
+ if (action !== "edit") {
166
+ ctx.ui.notify(`错误规则:${snapshot.config.rules.length} 条(命中后无限重试);长度截断:自动继续。配置:${configPath}`, "info");
167
+ return;
168
+ }
169
+ if (!ctx.hasUI) {
170
+ ctx.ui.notify(`当前模式没有编辑器,请手动编辑 ${configPath}`, "warning");
171
+ return;
172
+ }
173
+ let draft = JSON.stringify(snapshot.config, null, 2);
174
+ while (true) {
175
+ const edited = await ctx.ui.editor("错误重试规则(JSON);保存不会取消已排定的等待", draft);
176
+ if (edited === undefined) return;
177
+ draft = edited;
178
+ try {
179
+ const config = saveConfig(configPath, JSON.parse(draft), snapshot.revision);
180
+ ctx.ui.notify(`已保存 ${config.rules.length} 条规则;下次错误判定自动生效`, "info");
181
+ return;
182
+ } catch (error) {
183
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
184
+ // 保存失败保留草稿;发生版本冲突时不更新旧 revision,禁止覆盖他人的修改。
185
+ const choice = await ctx.ui.select("规则尚未保存", ["继续修改草稿", "放弃草稿并读取最新配置", "取消"]);
186
+ if (choice === "继续修改草稿") continue;
187
+ if (choice !== "放弃草稿并读取最新配置") return;
188
+ const latest = readConfig(configPath);
189
+ snapshot.revision = latest.revision;
190
+ draft = JSON.stringify(latest.config, null, 2);
191
+ }
192
+ }
193
+ } catch (error) {
194
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
195
+ }
196
+ },
197
+ });
198
+ }
199
+
200
+ export default function (pi: ExtensionAPI): void {
201
+ const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
202
+ registerErrorRetry(pi, join(agentDir, "error-retry.json"));
203
+ }
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "pi-error-retry",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "按规则无限重试 Pi 模型错误,并在长度截断时自动继续",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/librola/pi-error-retry.git"
9
+ },
10
+ "homepage": "https://github.com/librola/pi-error-retry#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/librola/pi-error-retry/issues"
13
+ },
14
+ "files": [
15
+ "index.ts",
16
+ "src/",
17
+ "examples/"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public",
21
+ "registry": "https://registry.npmjs.org"
22
+ },
23
+ "keywords": [
24
+ "pi-package"
25
+ ],
26
+ "pi": {
27
+ "extensions": [
28
+ "./index.ts"
29
+ ]
30
+ },
31
+ "engines": {
32
+ "node": ">=22.19.0"
33
+ },
34
+ "scripts": {
35
+ "test": "node --test tests/*.test.mjs",
36
+ "check": "node scripts/check.mjs"
37
+ },
38
+ "peerDependencies": {
39
+ "@earendil-works/pi-coding-agent": "*"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "24.10.1",
43
+ "typescript": "5.9.3"
44
+ }
45
+ }
package/src/config.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { type RetryConfig, validateConfig } from "./rules.ts";
5
+
6
+ export interface ConfigSnapshot {
7
+ config: RetryConfig;
8
+ // 使用文件原文检测其他窗口的保存,缺失文件与空文件必须区分。
9
+ revision: string | null;
10
+ }
11
+
12
+ export function readConfig(path: string): ConfigSnapshot {
13
+ let revision: string | null;
14
+ try {
15
+ revision = readFileSync(path, "utf8");
16
+ } catch (error) {
17
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
18
+ revision = null;
19
+ }
20
+ return { config: validateConfig(revision === null ? {} : JSON.parse(revision)), revision };
21
+ }
22
+
23
+ export function saveConfig(path: string, value: unknown, expectedRevision: string | null): RetryConfig {
24
+ const config = validateConfig(value);
25
+ mkdirSync(dirname(path), { recursive: true });
26
+ const lockPath = `${path}.lock`;
27
+ let lock: number;
28
+ try {
29
+ lock = openSync(lockPath, "wx", 0o600);
30
+ } catch (error) {
31
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error("其他窗口正在保存,请稍后重试");
32
+ throw error;
33
+ }
34
+ const temporary = `${path}.${randomUUID()}.tmp`;
35
+ try {
36
+ if (readConfig(path).revision !== expectedRevision) {
37
+ throw new Error("规则已被其他窗口修改;草稿未保存,请读取最新配置后重新编辑");
38
+ }
39
+ writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, { flag: "wx", mode: 0o600 });
40
+ renameSync(temporary, path);
41
+ return config;
42
+ } finally {
43
+ try {
44
+ unlinkSync(temporary);
45
+ } catch (error) {
46
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
47
+ } finally {
48
+ closeSync(lock);
49
+ unlinkSync(lockPath);
50
+ }
51
+ }
52
+ }
package/src/rules.ts ADDED
@@ -0,0 +1,116 @@
1
+ import { Script, createContext } from "node:vm";
2
+
3
+ export type RetryRule =
4
+ | { mode: "contains"; pattern: string }
5
+ | { mode: "regex"; pattern: string; flags?: string };
6
+
7
+ export interface RetryConfig {
8
+ rules: RetryRule[];
9
+ }
10
+
11
+ export const DEFAULT_RULES: readonly RetryRule[] = [
12
+ {
13
+ mode: "contains",
14
+ pattern:
15
+ "Requests have exceeded the throughput limit on your Provisioned-Managed deployment. If you continue to exceed your limit, consider increasing the number of provisioned throughput units deployed.",
16
+ },
17
+ {
18
+ mode: "contains",
19
+ pattern: "Your request exceeds the maximum size allowed by your deployment's quota.",
20
+ },
21
+ ];
22
+
23
+ export function validateConfig(value: unknown): RetryConfig {
24
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
25
+ throw new Error("配置必须是 JSON 对象");
26
+ }
27
+ const object = value as Record<string, unknown>;
28
+ if (Object.keys(object).some((key) => key !== "rules")) throw new Error("配置仅接受 rules 字段");
29
+ if (object.rules === undefined) return { rules: DEFAULT_RULES.map((rule) => ({ ...rule })) };
30
+ if (!Array.isArray(object.rules)) throw new Error("rules 必须是数组");
31
+ const rules = object.rules.map((value: unknown, index): RetryRule => {
32
+ const prefix = `规则 ${index + 1}:`;
33
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${prefix}必须是对象`);
34
+ const rule = value as Record<string, unknown>;
35
+ if (Object.keys(rule).some((key) => !["mode", "pattern", "flags"].includes(key))) {
36
+ throw new Error(`${prefix}仅接受 mode、pattern、flags`);
37
+ }
38
+ if (typeof rule.pattern !== "string" || !/\S/.test(rule.pattern)) {
39
+ throw new Error(`${prefix}pattern 不能为空`);
40
+ }
41
+ if (rule.mode === "contains") {
42
+ if (rule.flags !== undefined) throw new Error(`${prefix}contains 不接受 flags`);
43
+ return { mode: "contains", pattern: rule.pattern };
44
+ }
45
+ if (rule.mode !== "regex") throw new Error(`${prefix}mode 必须是 contains 或 regex`);
46
+ if (rule.flags !== undefined && (typeof rule.flags !== "string" || !/^[imsu]*$/.test(rule.flags))) {
47
+ throw new Error(`${prefix}flags 仅支持 i/m/s/u`);
48
+ }
49
+ const flags = rule.flags as string | undefined;
50
+ try {
51
+ new RegExp(rule.pattern, flags);
52
+ } catch (error) {
53
+ throw new Error(`${prefix}${error instanceof Error ? error.message : String(error)}`);
54
+ }
55
+ return { mode: "regex", pattern: rule.pattern, ...(flags === undefined ? {} : { flags }) };
56
+ });
57
+ return { rules };
58
+ }
59
+
60
+ export function policyKey(rules: RetryRule[]): string {
61
+ // OR 规则的排序、重复项和正则标志顺序不改变策略;有效空白不能丢失。
62
+ const normalized = rules.map((rule) => JSON.stringify(rule.mode === "contains"
63
+ ? rule
64
+ : { mode: rule.mode, pattern: rule.pattern, flags: new RegExp(rule.pattern, rule.flags).flags }));
65
+ return JSON.stringify(["pi-error-retry", 1, [...new Set(normalized)].sort()]);
66
+ }
67
+
68
+ const matchScript = new Script(`rules.some(rule => rule.mode === "contains"
69
+ ? message.includes(rule.pattern)
70
+ : new RegExp(rule.pattern, rule.flags).test(message))`);
71
+
72
+ export function matchesRules(rules: RetryRule[], message: string): boolean {
73
+ // 整组规则共享 25ms 预算;异常由调用方交回 Pi,不把超时当成命中。
74
+ return matchScript.runInContext(createContext({ rules, message }), { timeout: 25 }) === true;
75
+ }
76
+
77
+ export function retryDelay(retry: number, providerDelay?: number, random = Math.random): number {
78
+ if (providerDelay !== undefined && Number.isFinite(providerDelay) && providerDelay > 0) return providerDelay;
79
+ const base = Math.min(1000 * 2 ** Math.min(retry - 1, 5), 30000);
80
+ return Math.min(30000, Math.round(base * (0.9 + random() * 0.2)));
81
+ }
82
+
83
+ export function providerRetryAfter(headers: Record<string, string>, now = Date.now()): number | undefined {
84
+ const normalized = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
85
+ const millis = normalized["retry-after-ms"];
86
+ if (millis !== undefined && millis.trim() && Number.isFinite(Number(millis)) && Number(millis) > 0) {
87
+ return Number(millis);
88
+ }
89
+ const value = normalized["retry-after"];
90
+ if (!value?.trim()) return undefined;
91
+ const seconds = Number(value);
92
+ const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(value) - now;
93
+ return Number.isFinite(delay) && delay > 0 ? delay : undefined;
94
+ }
95
+
96
+ export async function waitForRetry(delayMs: number, signal: AbortSignal): Promise<boolean> {
97
+ let remaining = delayMs;
98
+ while (remaining > 0) {
99
+ if (signal.aborted) return false;
100
+ // 服务端长等待不截短,分段避免 Node 定时器溢出。
101
+ const slice = Math.min(remaining, 2147483647);
102
+ const completed = await new Promise<boolean>((resolve) => {
103
+ const finish = (result: boolean) => {
104
+ clearTimeout(timer);
105
+ signal.removeEventListener("abort", abort);
106
+ resolve(result);
107
+ };
108
+ const abort = () => finish(false);
109
+ const timer = setTimeout(() => finish(true), slice);
110
+ signal.addEventListener("abort", abort, { once: true });
111
+ });
112
+ if (!completed) return false;
113
+ remaining -= slice;
114
+ }
115
+ return !signal.aborted;
116
+ }