failover-error.js
4.68 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
/**
* FailoverError - 结构化错误分类
*
* 参考 openclaw/src/agents/cli-runner/helpers.ts 中的错误处理模式
*
* 将原始 Error 分类为可操作的 FailoverReason + 重试策略,
* 替代 AgentManager._isRecoverableSessionError() 中的纯字符串匹配。
*/
/** 错误分类枚举 */
const FailoverReason = Object.freeze({
TIMEOUT: 'timeout',
NO_OUTPUT_TIMEOUT: 'no-output-timeout',
RATE_LIMIT: 'rate-limit',
AUTH: 'auth',
MODEL_NOT_FOUND: 'model-not-found',
NETWORK: 'network',
TRANSPORT_CLOSED: 'transport-closed',
SPAWN_ERROR: 'spawn-error',
INTERNAL: 'internal',
UNKNOWN: 'unknown',
});
/** 可重试的错误类型集合 */
const RETRYABLE_REASONS = new Set([
FailoverReason.TIMEOUT,
FailoverReason.NO_OUTPUT_TIMEOUT,
FailoverReason.RATE_LIMIT,
FailoverReason.NETWORK,
FailoverReason.TRANSPORT_CLOSED,
]);
/**
* 从原始错误中推导分类
*
* @param {Error} err
* @returns {{ reason: string, retryable: boolean, delayMs: number }}
*/
function classifyError(err) {
const msg = `${err?.message || ''}`.toLowerCase();
const code = err?.code || '';
// ── 超时 ──
if (msg.includes('overall-timeout') || msg.includes('overall timeout')) {
return { reason: FailoverReason.TIMEOUT, retryable: true, delayMs: 1000 };
}
if (msg.includes('no-output-timeout') || msg.includes('no output timeout')) {
return { reason: FailoverReason.NO_OUTPUT_TIMEOUT, retryable: true, delayMs: 1000 };
}
if (msg.includes('timeout') || msg.includes('timed out') || code === 'ETIMEDOUT') {
return { reason: FailoverReason.TIMEOUT, retryable: true, delayMs: 1000 };
}
// ── 限流 ──
if (msg.includes('rate limit') || msg.includes('429')
|| msg.includes('too many requests') || msg.includes('quota')
|| msg.includes('resource_exhausted')) {
return { reason: FailoverReason.RATE_LIMIT, retryable: true, delayMs: 5000 };
}
// ── 认证 ──
if (msg.includes('unauthorized') || msg.includes('unauthenticated')
|| msg.includes('401') || msg.includes('403')
|| msg.includes('api key') || msg.includes('invalid key')
|| msg.includes('permission denied')) {
return { reason: FailoverReason.AUTH, retryable: false, delayMs: 0 };
}
// ── 模型不存在 ──
if (msg.includes('model not found') || msg.includes('model_not_found')
|| msg.includes('does not exist') || msg.includes('404')
|| msg.includes('no such model')) {
return { reason: FailoverReason.MODEL_NOT_FOUND, retryable: false, delayMs: 0 };
}
// ── 传输层关闭 ──
if (msg.includes('transport is closed') || msg.includes('transport closed')
|| msg.includes('disconnected') || msg.includes('econnreset')
|| msg.includes('broken pipe') || msg.includes('pipe')
|| msg.includes('session not connected') || msg.includes('connection lost')) {
return { reason: FailoverReason.TRANSPORT_CLOSED, retryable: true, delayMs: 2000 };
}
// ── 进程启动错误 ──
if (msg.includes('spawn') || code === 'ENOENT' || code === 'EACCES'
|| msg.includes('command not found') || msg.includes('no such file')) {
return { reason: FailoverReason.SPAWN_ERROR, retryable: false, delayMs: 0 };
}
// ── 网络 ──
if (msg.includes('econnrefused') || msg.includes('enetunreach')
|| msg.includes('ehostunreach') || msg.includes('dns')
|| msg.includes('socket hang up') || msg.includes('fetch failed')
|| code === 'ECONNREFUSED' || code === 'ENETUNREACH') {
return { reason: FailoverReason.NETWORK, retryable: true, delayMs: 3000 };
}
return { reason: FailoverReason.UNKNOWN, retryable: false, delayMs: 0 };
}
/**
* FailoverError — 封装原始错误 + 分类信息
*
* 用法:
* try { ... } catch (err) {
* const failover = FailoverError.from(err);
* if (failover.retryable) {
* await sleep(failover.delayMs);
* // retry
* }
* }
*/
class FailoverError extends Error {
/**
* @param {Error} originalError
* @param {{ reason: string, retryable: boolean, delayMs: number }} classification
*/
constructor(originalError, classification) {
super(originalError.message);
this.name = 'FailoverError';
this.original = originalError;
this.reason = classification.reason;
this.retryable = classification.retryable;
this.delayMs = classification.delayMs;
this.stack = originalError.stack;
}
/**
* 工厂方法 — 从任意 Error 创建 FailoverError
* @param {Error} err
* @returns {FailoverError}
*/
static from(err) {
if (err instanceof FailoverError) return err;
return new FailoverError(err, classifyError(err));
}
}
module.exports = { FailoverError, FailoverReason, classifyError, RETRYABLE_REASONS };