pi-long-task 0.5.0 → 0.6.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/CHANGELOG.md +18 -0
- package/README.md +85 -2
- package/package.json +1 -1
- package/src/coordinator.ts +450 -34
- package/src/goal_discovery.ts +2 -0
- package/src/goal_loop.ts +76 -0
- package/src/goal_orchestrator.ts +87 -1
- package/src/goal_review.ts +206 -15
- package/src/goal_todo_execution.ts +3 -0
- package/src/goal_todo_generation.ts +96 -3
- package/src/index.ts +2 -0
- package/src/network_failure.ts +574 -0
- package/src/network_recovery.ts +395 -0
- package/src/network_recovery_config.ts +89 -0
- package/src/render.ts +2 -0
- package/src/session_guard.ts +8 -1
- package/src/todo_generator.ts +2 -2
- package/src/types.ts +32 -0
- package/src/worker_config.ts +74 -0
- package/src/worker_session.ts +33 -1
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
const MAX_CAUSE_DEPTH = 10;
|
|
2
|
+
|
|
3
|
+
const NESTED_ERROR_KEYS = [
|
|
4
|
+
"cause",
|
|
5
|
+
"error",
|
|
6
|
+
"errors",
|
|
7
|
+
"innerError",
|
|
8
|
+
"originalError",
|
|
9
|
+
"underlyingError",
|
|
10
|
+
"reason",
|
|
11
|
+
"response",
|
|
12
|
+
"$response",
|
|
13
|
+
"$metadata",
|
|
14
|
+
"diagnosticDetails",
|
|
15
|
+
"payload",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
const TRANSIENT_NETWORK_CODES = new Set([
|
|
19
|
+
"EAI_AGAIN",
|
|
20
|
+
"ECONNABORTED",
|
|
21
|
+
"ECONNCLOSED",
|
|
22
|
+
"ECONNREFUSED",
|
|
23
|
+
"ECONNRESET",
|
|
24
|
+
"EHOSTDOWN",
|
|
25
|
+
"EHOSTUNREACH",
|
|
26
|
+
"ENETDOWN",
|
|
27
|
+
"ENETRESET",
|
|
28
|
+
"ENETUNREACH",
|
|
29
|
+
"ENOTFOUND",
|
|
30
|
+
"EPIPE",
|
|
31
|
+
"ERR_HTTP2_STREAM_CANCEL",
|
|
32
|
+
"ERR_SOCKET_CLOSED",
|
|
33
|
+
"ESOCKETTIMEDOUT",
|
|
34
|
+
"ETIMEDOUT",
|
|
35
|
+
"UND_ERR_BODY_TIMEOUT",
|
|
36
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
37
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
38
|
+
"UND_ERR_SOCKET",
|
|
39
|
+
]);
|
|
40
|
+
const TRANSIENT_TIMEOUT_CODES = new Set([
|
|
41
|
+
"ECONNABORTED",
|
|
42
|
+
"ESOCKETTIMEDOUT",
|
|
43
|
+
"ETIMEDOUT",
|
|
44
|
+
"UND_ERR_BODY_TIMEOUT",
|
|
45
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
46
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
47
|
+
]);
|
|
48
|
+
const RATE_LIMIT_CODES = new Set([
|
|
49
|
+
"RATE_LIMIT_EXCEEDED",
|
|
50
|
+
"RESOURCE_EXHAUSTED",
|
|
51
|
+
"THROTTLED",
|
|
52
|
+
"THROTTLING",
|
|
53
|
+
"THROTTLING_EXCEPTION",
|
|
54
|
+
"TOO_MANY_REQUESTS",
|
|
55
|
+
]);
|
|
56
|
+
const OVERLOAD_CODES = new Set(["CAPACITY_EXCEEDED", "OVERLOADED", "SERVER_OVERLOADED"]);
|
|
57
|
+
const SERVER_ERROR_CODES = new Set(["BAD_GATEWAY", "INTERNAL_SERVER_ERROR", "SERVICE_UNAVAILABLE"]);
|
|
58
|
+
const DETERMINISTIC_TRANSPORT_CODES = new Set([
|
|
59
|
+
"CERT_HAS_EXPIRED",
|
|
60
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
61
|
+
"ERR_TLS_CERT_ALTNAME_INVALID",
|
|
62
|
+
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
63
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const AUTHENTICATION_CODES = new Set([
|
|
67
|
+
"AUTHENTICATION_ERROR",
|
|
68
|
+
"INVALID_API_KEY",
|
|
69
|
+
"INVALID_TOKEN",
|
|
70
|
+
"TOKEN_EXPIRED",
|
|
71
|
+
"UNAUTHENTICATED",
|
|
72
|
+
"UNAUTHORIZED",
|
|
73
|
+
]);
|
|
74
|
+
const AUTHORIZATION_CODES = new Set(["ACCESS_DENIED", "FORBIDDEN", "PERMISSION_DENIED"]);
|
|
75
|
+
const BILLING_CODES = new Set([
|
|
76
|
+
"BILLING_ERROR",
|
|
77
|
+
"BILLING_HARD_LIMIT_REACHED",
|
|
78
|
+
"INSUFFICIENT_CREDITS",
|
|
79
|
+
"PAYMENT_REQUIRED",
|
|
80
|
+
]);
|
|
81
|
+
const QUOTA_CODES = new Set([
|
|
82
|
+
"FREE_USAGE_LIMIT_ERROR",
|
|
83
|
+
"GO_USAGE_LIMIT_ERROR",
|
|
84
|
+
"INSUFFICIENT_QUOTA",
|
|
85
|
+
"MONTHLY_USAGE_LIMIT_REACHED",
|
|
86
|
+
"QUOTA_EXCEEDED",
|
|
87
|
+
"USAGE_LIMIT_REACHED",
|
|
88
|
+
"USAGE_NOT_INCLUDED",
|
|
89
|
+
]);
|
|
90
|
+
const INVALID_MODEL_CODES = new Set(["INVALID_MODEL", "MODEL_NOT_FOUND", "MODEL_NOT_SUPPORTED", "UNKNOWN_MODEL"]);
|
|
91
|
+
const INVALID_REQUEST_CODES = new Set([
|
|
92
|
+
"BAD_REQUEST",
|
|
93
|
+
"CONTENT_POLICY_VIOLATION",
|
|
94
|
+
"CONTEXT_LENGTH_EXCEEDED",
|
|
95
|
+
"INVALID_ARGUMENT",
|
|
96
|
+
"INVALID_REQUEST",
|
|
97
|
+
"INVALID_REQUEST_ERROR",
|
|
98
|
+
"MALFORMED_REQUEST",
|
|
99
|
+
"SAFETY_VIOLATION",
|
|
100
|
+
"UNSUPPORTED_VALUE",
|
|
101
|
+
"VALIDATION_ERROR",
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
const TRANSIENT_HTTP_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504, 522, 523, 524, 525, 527, 529]);
|
|
105
|
+
const NON_RETRYABLE_HTTP_STATUSES = new Set([501, 505, 506, 507, 508, 509, 510, 511]);
|
|
106
|
+
const TRANSIENT_WEBSOCKET_CLOSE_CODES = new Set([1001, 1006, 1011, 1012, 1013, 1014]);
|
|
107
|
+
const NON_RETRYABLE_WEBSOCKET_CLOSE_CODES = new Set([1002, 1003, 1007, 1008, 1009, 1010]);
|
|
108
|
+
|
|
109
|
+
const AUTHENTICATION_PATTERN =
|
|
110
|
+
/\b(?:authentication (?:failed|required|error)|invalid api[- ]?key|invalid (?:access |auth )?token|expired (?:access |auth )?token|token (?:has )?expired|missing api[- ]?key|unauthenticated|unauthorized)\b/i;
|
|
111
|
+
const AUTHORIZATION_PATTERN =
|
|
112
|
+
/\b(?:access denied|authorization (?:failed|required|error)|forbidden|insufficient permissions?|not authorized|permission denied)\b/i;
|
|
113
|
+
const BILLING_PATTERN =
|
|
114
|
+
/\b(?:billing|payment required|payment method|credit balance|credits? exhausted|insufficient credits?|out of (?:credits?|budget)|spend(?:ing)? limit)\b/i;
|
|
115
|
+
const QUOTA_PATTERN =
|
|
116
|
+
/\b(?:FreeUsageLimitError|GoUsageLimitError|insufficient[_ -]?quota|monthly usage limit|weekly usage limit|daily usage limit|quota (?:has been )?(?:exceeded|exhausted|reached)|(?:exceeded|exhausted|reached) (?:your )?(?:current )?quota|usage (?:quota|limit).*?(?:exceeded|exhausted|reached)|usage_not_included)\b/i;
|
|
117
|
+
const INVALID_MODEL_PATTERN =
|
|
118
|
+
/\b(?:invalid model|model (?:does not exist|is not (?:available|supported)|not found)|unknown model|unsupported model)\b/i;
|
|
119
|
+
const INVALID_REQUEST_PATTERN =
|
|
120
|
+
/\b(?:bad request|content policy violation|context (?:length|window).*(?:exceeded|too (?:large|long))|invalid (?:argument|parameter|request)|malformed request|request validation failed|safety violation|unsupported (?:parameter|value))\b/i;
|
|
121
|
+
const DETERMINISTIC_TRANSPORT_PATTERN =
|
|
122
|
+
/\b(?:certificate (?:has )?expired|certificate hostname mismatch|self[- ]signed certificate|unable to verify (?:the )?(?:first|leaf) certificate)\b/i;
|
|
123
|
+
const COORDINATOR_TIMEOUT_PATTERN =
|
|
124
|
+
/\b(?:task|session|planner|reviewer|iteration|goal(?: loop)?) (?:exceeded|timed out|timeout|deadline)\b/i;
|
|
125
|
+
const TIMEOUT_PATTERN =
|
|
126
|
+
/\b(?:connect(?:ion)?|fetch|gateway|headers?|idle|network|read|request|response|socket|upstream|websocket)[- ]?(?:timed? ?out|timeout)|\b(?:ETIMEDOUT|ESOCKETTIMEDOUT|UND_ERR_(?:BODY|CONNECT|HEADERS)_TIMEOUT)\b|\bdeadline exceeded\b/i;
|
|
127
|
+
const STREAM_PATTERN =
|
|
128
|
+
/\b(?:premature (?:close|end)|response body.*(?:aborted|terminated)|socket (?:connection )?(?:was )?(?:closed|disconnected|hung up)|stream (?:closed|disconnected|ended before|ended without|terminated)|terminated prematurely|unexpected end of (?:file|stream)|websocket (?:closed|disconnected|error|stream closed))\b|(?:^|\n)(?:TypeError: )?terminated(?:\n|$)|\bended without (?:a )?(?:terminal|response|message_stop)\b|\bother side closed\b|\bHTTP\/2 request did not get a response\b/i;
|
|
129
|
+
const TRANSPORT_PATTERN =
|
|
130
|
+
/\b(?:connection (?:closed|error|lost|refused|reset)|DNS (?:error|failure|lookup failed)|failed to fetch|fetch failed|host (?:is )?unreachable|network.?error|network (?:connection )?(?:failed|failure|offline|unavailable)|socket hang up|transport (?:error|failed|failure)|upstream connect)\b|\b(?:EAI_AGAIN|ECONNABORTED|ECONNCLOSED|ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ENETDOWN|ENETRESET|ENETUNREACH|ENOTFOUND|EPIPE|ERR_SOCKET_CLOSED|UND_ERR_SOCKET)\b/i;
|
|
131
|
+
const OVERLOAD_PATTERN =
|
|
132
|
+
/\b(?:capacity (?:exceeded|temporarily unavailable)|overloaded|server busy|temporarily unavailable|please retry your request|try your request again|you can retry your request)\b/i;
|
|
133
|
+
const RATE_LIMIT_PATTERN =
|
|
134
|
+
/\b(?:rate[- _]?limit(?:ed|ing|_exceeded)?|too many requests|ResourceExhausted|throttl(?:ed|ing))\b/i;
|
|
135
|
+
const SERVER_ERROR_PATTERN =
|
|
136
|
+
/\b(?:bad gateway|gateway timeout|internal server error|service unavailable|server error)\b/i;
|
|
137
|
+
|
|
138
|
+
export type NetworkFailureReason =
|
|
139
|
+
| "transport_error"
|
|
140
|
+
| "request_timeout"
|
|
141
|
+
| "stream_disconnected"
|
|
142
|
+
| "provider_overloaded"
|
|
143
|
+
| "rate_limited"
|
|
144
|
+
| "server_error"
|
|
145
|
+
| "cancelled"
|
|
146
|
+
| "authentication"
|
|
147
|
+
| "authorization"
|
|
148
|
+
| "billing"
|
|
149
|
+
| "quota_exhausted"
|
|
150
|
+
| "invalid_model"
|
|
151
|
+
| "invalid_request"
|
|
152
|
+
| "http_client_error"
|
|
153
|
+
| "non_retryable_server_error"
|
|
154
|
+
| "unknown";
|
|
155
|
+
|
|
156
|
+
export interface NetworkFailureCauseMetadata {
|
|
157
|
+
depth: number;
|
|
158
|
+
name?: string;
|
|
159
|
+
message?: string;
|
|
160
|
+
code?: string;
|
|
161
|
+
statusCode?: number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Stable diagnostic data retained independently of provider-specific error classes. */
|
|
165
|
+
export interface NetworkFailureMetadata {
|
|
166
|
+
name?: string;
|
|
167
|
+
message: string;
|
|
168
|
+
statusCode?: number;
|
|
169
|
+
statusCodes: readonly number[];
|
|
170
|
+
code?: string;
|
|
171
|
+
codes: readonly string[];
|
|
172
|
+
websocketCloseCode?: number;
|
|
173
|
+
retryAfter?: string;
|
|
174
|
+
retryAfterMs?: number;
|
|
175
|
+
provider?: string;
|
|
176
|
+
requestId?: string;
|
|
177
|
+
causes: readonly NetworkFailureCauseMetadata[];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Coordinator-level decision made after Pi's own bounded retries are exhausted. */
|
|
181
|
+
export interface NetworkFailureClassification {
|
|
182
|
+
recoverable: boolean;
|
|
183
|
+
reason: NetworkFailureReason;
|
|
184
|
+
/** The untouched value, retained for terminal failure propagation and detailed logging. */
|
|
185
|
+
error: unknown;
|
|
186
|
+
metadata: NetworkFailureMetadata;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
interface ErrorCandidate extends NetworkFailureCauseMetadata {
|
|
190
|
+
value: unknown;
|
|
191
|
+
text: string;
|
|
192
|
+
websocketCloseCode?: number;
|
|
193
|
+
headers?: unknown;
|
|
194
|
+
provider?: string;
|
|
195
|
+
requestId?: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Classify native, provider, SDK-wrapper, serialized, and nested-cause failures.
|
|
200
|
+
*
|
|
201
|
+
* Explicit deterministic evidence always wins over transient-looking wrapper
|
|
202
|
+
* text. Unknown failures are deliberately fail-fast rather than guessed to be
|
|
203
|
+
* network outages.
|
|
204
|
+
*/
|
|
205
|
+
export function classifyNetworkFailure(error: unknown): NetworkFailureClassification {
|
|
206
|
+
const candidates = collectCandidates(error);
|
|
207
|
+
const metadata = buildMetadata(error, candidates);
|
|
208
|
+
const allText = candidates
|
|
209
|
+
.map((candidate) => candidate.text)
|
|
210
|
+
.filter(Boolean)
|
|
211
|
+
.join("\n");
|
|
212
|
+
const normalizedCodes = metadata.codes.map(normalizeCode);
|
|
213
|
+
|
|
214
|
+
const terminal = deterministicReason(metadata, normalizedCodes, allText);
|
|
215
|
+
if (terminal) {
|
|
216
|
+
return { recoverable: false, reason: terminal, error, metadata };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const recoverable = recoverableReason(metadata, normalizedCodes, allText);
|
|
220
|
+
if (recoverable) {
|
|
221
|
+
return { recoverable: true, reason: recoverable, error, metadata };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { recoverable: false, reason: "unknown", error, metadata };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function isRecoverableNetworkFailure(error: unknown): boolean {
|
|
228
|
+
return classifyNetworkFailure(error).recoverable;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function deterministicReason(
|
|
232
|
+
metadata: NetworkFailureMetadata,
|
|
233
|
+
normalizedCodes: readonly string[],
|
|
234
|
+
text: string,
|
|
235
|
+
): NetworkFailureReason | undefined {
|
|
236
|
+
if (isCancellation(metadata, normalizedCodes, text)) return "cancelled";
|
|
237
|
+
if (hasCode(normalizedCodes, AUTHENTICATION_CODES) || AUTHENTICATION_PATTERN.test(text)) return "authentication";
|
|
238
|
+
if (hasCode(normalizedCodes, AUTHORIZATION_CODES) || AUTHORIZATION_PATTERN.test(text)) return "authorization";
|
|
239
|
+
if (hasCode(normalizedCodes, BILLING_CODES) || BILLING_PATTERN.test(text)) return "billing";
|
|
240
|
+
if (hasCode(normalizedCodes, QUOTA_CODES) || QUOTA_PATTERN.test(text)) return "quota_exhausted";
|
|
241
|
+
if (hasCode(normalizedCodes, INVALID_MODEL_CODES) || INVALID_MODEL_PATTERN.test(text)) return "invalid_model";
|
|
242
|
+
if (hasCode(normalizedCodes, INVALID_REQUEST_CODES) || INVALID_REQUEST_PATTERN.test(text)) return "invalid_request";
|
|
243
|
+
if (hasCode(normalizedCodes, DETERMINISTIC_TRANSPORT_CODES) || DETERMINISTIC_TRANSPORT_PATTERN.test(text)) {
|
|
244
|
+
return "unknown";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (
|
|
248
|
+
metadata.websocketCloseCode !== undefined &&
|
|
249
|
+
NON_RETRYABLE_WEBSOCKET_CLOSE_CODES.has(metadata.websocketCloseCode)
|
|
250
|
+
) {
|
|
251
|
+
return "invalid_request";
|
|
252
|
+
}
|
|
253
|
+
if (metadata.statusCodes.includes(401)) return "authentication";
|
|
254
|
+
if (metadata.statusCodes.includes(403)) return "authorization";
|
|
255
|
+
if (metadata.statusCodes.includes(402)) return "billing";
|
|
256
|
+
if (metadata.statusCodes.includes(400)) return "invalid_request";
|
|
257
|
+
if (metadata.statusCodes.some((status) => status >= 400 && status < 500 && !TRANSIENT_HTTP_STATUSES.has(status))) {
|
|
258
|
+
return "http_client_error";
|
|
259
|
+
}
|
|
260
|
+
if (metadata.statusCodes.some((status) => NON_RETRYABLE_HTTP_STATUSES.has(status))) {
|
|
261
|
+
return "non_retryable_server_error";
|
|
262
|
+
}
|
|
263
|
+
if (COORDINATOR_TIMEOUT_PATTERN.test(text)) return "unknown";
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function recoverableReason(
|
|
268
|
+
metadata: NetworkFailureMetadata,
|
|
269
|
+
normalizedCodes: readonly string[],
|
|
270
|
+
text: string,
|
|
271
|
+
): NetworkFailureReason | undefined {
|
|
272
|
+
if (metadata.statusCodes.includes(429)) return "rate_limited";
|
|
273
|
+
if (metadata.statusCodes.some((status) => status === 408 || status === 425 || status === 504)) {
|
|
274
|
+
return "request_timeout";
|
|
275
|
+
}
|
|
276
|
+
if (metadata.statusCodes.some((status) => TRANSIENT_HTTP_STATUSES.has(status))) return "server_error";
|
|
277
|
+
if (metadata.websocketCloseCode !== undefined && TRANSIENT_WEBSOCKET_CLOSE_CODES.has(metadata.websocketCloseCode)) {
|
|
278
|
+
return "stream_disconnected";
|
|
279
|
+
}
|
|
280
|
+
if (normalizedCodes.some((code) => TRANSIENT_NETWORK_CODES.has(code))) {
|
|
281
|
+
return normalizedCodes.some((code) => TRANSIENT_TIMEOUT_CODES.has(code)) ? "request_timeout" : "transport_error";
|
|
282
|
+
}
|
|
283
|
+
if (TIMEOUT_PATTERN.test(text)) return "request_timeout";
|
|
284
|
+
if (STREAM_PATTERN.test(text)) return "stream_disconnected";
|
|
285
|
+
if (TRANSPORT_PATTERN.test(text)) return "transport_error";
|
|
286
|
+
if (hasCode(normalizedCodes, RATE_LIMIT_CODES) || RATE_LIMIT_PATTERN.test(text)) return "rate_limited";
|
|
287
|
+
if (hasCode(normalizedCodes, OVERLOAD_CODES) || OVERLOAD_PATTERN.test(text)) return "provider_overloaded";
|
|
288
|
+
if (hasCode(normalizedCodes, SERVER_ERROR_CODES) || SERVER_ERROR_PATTERN.test(text) || hasTransientHttpText(text)) {
|
|
289
|
+
return "server_error";
|
|
290
|
+
}
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function collectCandidates(root: unknown): ErrorCandidate[] {
|
|
295
|
+
const candidates: ErrorCandidate[] = [];
|
|
296
|
+
const seen = new Set<object>();
|
|
297
|
+
|
|
298
|
+
const visit = (value: unknown, depth: number) => {
|
|
299
|
+
if (value === undefined || value === null || depth > MAX_CAUSE_DEPTH) return;
|
|
300
|
+
if (typeof value !== "object") {
|
|
301
|
+
const message = String(value);
|
|
302
|
+
candidates.push({ value, depth, message, text: message });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (seen.has(value)) return;
|
|
306
|
+
seen.add(value);
|
|
307
|
+
|
|
308
|
+
const record = value as Record<string, unknown>;
|
|
309
|
+
const name = stringValue(record.name) ?? (value instanceof Error ? value.name : undefined);
|
|
310
|
+
const message = errorMessage(record, value);
|
|
311
|
+
const statusCode = extractStatusCode(record, message);
|
|
312
|
+
const code = extractCode(record);
|
|
313
|
+
const websocketCloseCode = extractWebSocketCloseCode(record, name, message);
|
|
314
|
+
const text = candidateText(record, { name, message, code, statusCode });
|
|
315
|
+
candidates.push({
|
|
316
|
+
value,
|
|
317
|
+
depth,
|
|
318
|
+
name,
|
|
319
|
+
message,
|
|
320
|
+
code,
|
|
321
|
+
statusCode,
|
|
322
|
+
websocketCloseCode,
|
|
323
|
+
text,
|
|
324
|
+
headers: record.headers,
|
|
325
|
+
provider: stringValue(record.provider) ?? stringValue(record.providerId),
|
|
326
|
+
requestId: extractRequestId(record),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
if (value instanceof AggregateError) {
|
|
330
|
+
for (const nested of value.errors) visit(nested, depth + 1);
|
|
331
|
+
}
|
|
332
|
+
for (const key of NESTED_ERROR_KEYS) {
|
|
333
|
+
const nested = record[key];
|
|
334
|
+
if (Array.isArray(nested)) {
|
|
335
|
+
for (const item of nested) visit(item, depth + 1);
|
|
336
|
+
} else if (nested !== value) {
|
|
337
|
+
visit(nested, depth + 1);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
visit(root, 0);
|
|
343
|
+
return candidates;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function buildMetadata(error: unknown, candidates: readonly ErrorCandidate[]): NetworkFailureMetadata {
|
|
347
|
+
const statuses = unique(
|
|
348
|
+
candidates.flatMap((candidate) => (candidate.statusCode === undefined ? [] : [candidate.statusCode])),
|
|
349
|
+
);
|
|
350
|
+
const codes = unique(candidates.flatMap((candidate) => (candidate.code === undefined ? [] : [candidate.code])));
|
|
351
|
+
const root = candidates[0];
|
|
352
|
+
const headerMetadata = candidates.map((candidate) => retryHeaders(candidate.headers)).find(Boolean);
|
|
353
|
+
const retryAfter = headerMetadata?.retryAfter ?? firstString(candidates, "retryAfter");
|
|
354
|
+
const retryAfterMs = headerMetadata?.retryAfterMs ?? firstFiniteNumber(candidates, "retryAfterMs");
|
|
355
|
+
const message = root?.message || root?.text || fallbackMessage(error, statuses[0]);
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
name: root?.name,
|
|
359
|
+
message,
|
|
360
|
+
statusCode: statuses[0],
|
|
361
|
+
statusCodes: statuses,
|
|
362
|
+
code: codes[0],
|
|
363
|
+
codes,
|
|
364
|
+
websocketCloseCode: candidates.find((candidate) => candidate.websocketCloseCode !== undefined)?.websocketCloseCode,
|
|
365
|
+
retryAfter,
|
|
366
|
+
retryAfterMs,
|
|
367
|
+
provider: candidates.find((candidate) => candidate.provider)?.provider,
|
|
368
|
+
requestId:
|
|
369
|
+
candidates.find((candidate) => candidate.requestId)?.requestId ??
|
|
370
|
+
candidates.map((candidate) => requestIdFromHeaders(candidate.headers)).find(Boolean),
|
|
371
|
+
causes: candidates.map(({ depth, name, message: causeMessage, code, statusCode }) => ({
|
|
372
|
+
depth,
|
|
373
|
+
name,
|
|
374
|
+
message: causeMessage,
|
|
375
|
+
code,
|
|
376
|
+
statusCode,
|
|
377
|
+
})),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function errorMessage(record: Record<string, unknown>, value: object): string | undefined {
|
|
382
|
+
return (
|
|
383
|
+
stringValue(record.message) ??
|
|
384
|
+
stringValue(record.errorMessage) ??
|
|
385
|
+
stringValue(record.statusText) ??
|
|
386
|
+
(value instanceof Error ? value.message : undefined)
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function candidateText(
|
|
391
|
+
record: Record<string, unknown>,
|
|
392
|
+
fields: { name?: string; message?: string; code?: string; statusCode?: number },
|
|
393
|
+
): string {
|
|
394
|
+
const values: unknown[] = [
|
|
395
|
+
fields.name,
|
|
396
|
+
fields.message,
|
|
397
|
+
fields.code,
|
|
398
|
+
fields.statusCode === undefined ? undefined : `HTTP ${fields.statusCode}`,
|
|
399
|
+
record.type,
|
|
400
|
+
typeof record.status === "string" ? record.status : undefined,
|
|
401
|
+
record.statusText,
|
|
402
|
+
typeof record.body === "string" ? record.body : undefined,
|
|
403
|
+
typeof record.details === "string" ? record.details : undefined,
|
|
404
|
+
];
|
|
405
|
+
return unique(values.flatMap((value) => (typeof value === "string" && value ? [value] : []))).join(": ");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function extractStatusCode(record: Record<string, unknown>, message: string | undefined): number | undefined {
|
|
409
|
+
const direct = [record.statusCode, record.status, nestedNumber(record.$metadata, "httpStatusCode")];
|
|
410
|
+
for (const value of direct) {
|
|
411
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599) return value;
|
|
412
|
+
}
|
|
413
|
+
return statusFromText(message);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function statusFromText(text: string | undefined): number | undefined {
|
|
417
|
+
if (!text) return undefined;
|
|
418
|
+
const patterns = [
|
|
419
|
+
/(?:^|\b)HTTP(?:\s+status)?\s*[:=]?\s*(\d{3})\b/i,
|
|
420
|
+
/\bstatus(?:\s+code)?\s*[:=]?\s*(\d{3})\b/i,
|
|
421
|
+
/\b(?:API )?error\s*\((\d{3})\)/i,
|
|
422
|
+
/\bprovider returned error\D*(\d{3})\b/i,
|
|
423
|
+
/^\s*(\d{3})(?:\s|:|-)/,
|
|
424
|
+
];
|
|
425
|
+
for (const pattern of patterns) {
|
|
426
|
+
const match = pattern.exec(text);
|
|
427
|
+
if (!match) continue;
|
|
428
|
+
const value = Number(match[1]);
|
|
429
|
+
if (value >= 100 && value <= 599) return value;
|
|
430
|
+
}
|
|
431
|
+
return undefined;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function extractCode(record: Record<string, unknown>): string | undefined {
|
|
435
|
+
for (const value of [record.code, record.errno, record.errorCode, record.error_code, record.type]) {
|
|
436
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
437
|
+
}
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function extractWebSocketCloseCode(
|
|
442
|
+
record: Record<string, unknown>,
|
|
443
|
+
name: string | undefined,
|
|
444
|
+
message: string | undefined,
|
|
445
|
+
): number | undefined {
|
|
446
|
+
const direct = record.closeCode ?? record.close_code;
|
|
447
|
+
if (typeof direct === "number" && Number.isInteger(direct) && direct >= 1000 && direct <= 4999) return direct;
|
|
448
|
+
if (/websocket/i.test(`${name ?? ""} ${message ?? ""}`)) {
|
|
449
|
+
if (typeof record.code === "number" && record.code >= 1000 && record.code <= 4999) return record.code;
|
|
450
|
+
const match = /(?:close(?:d)?(?: with)?(?: code)?|code)\D*(\d{4})\b/i.exec(message ?? "");
|
|
451
|
+
if (match) return Number(match[1]);
|
|
452
|
+
}
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function extractRequestId(record: Record<string, unknown>): string | undefined {
|
|
457
|
+
return (
|
|
458
|
+
stringValue(record.requestId) ??
|
|
459
|
+
stringValue(record.request_id) ??
|
|
460
|
+
stringValue(record.requestID) ??
|
|
461
|
+
stringValue(record["x-request-id"])
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function retryHeaders(headers: unknown): { retryAfter?: string; retryAfterMs?: number } | undefined {
|
|
466
|
+
const retryAfterMsRaw = headerValue(headers, "retry-after-ms");
|
|
467
|
+
const retryAfterRaw = headerValue(headers, "retry-after");
|
|
468
|
+
const directMs = finiteNumber(retryAfterMsRaw);
|
|
469
|
+
if (directMs !== undefined) return { retryAfter: retryAfterRaw, retryAfterMs: Math.max(0, directMs) };
|
|
470
|
+
if (retryAfterRaw === undefined) return undefined;
|
|
471
|
+
const seconds = finiteNumber(retryAfterRaw);
|
|
472
|
+
return {
|
|
473
|
+
retryAfter: retryAfterRaw,
|
|
474
|
+
retryAfterMs: seconds === undefined ? undefined : Math.max(0, seconds * 1000),
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function requestIdFromHeaders(headers: unknown): string | undefined {
|
|
479
|
+
return headerValue(headers, "x-request-id") ?? headerValue(headers, "request-id");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function headerValue(headers: unknown, name: string): string | undefined {
|
|
483
|
+
if (!headers || typeof headers !== "object") return undefined;
|
|
484
|
+
if ("get" in headers && typeof headers.get === "function") {
|
|
485
|
+
const value = headers.get(name);
|
|
486
|
+
return typeof value === "string" && value ? value : undefined;
|
|
487
|
+
}
|
|
488
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
489
|
+
if (key.toLowerCase() === name && (typeof value === "string" || typeof value === "number")) return String(value);
|
|
490
|
+
}
|
|
491
|
+
return undefined;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function firstString(candidates: readonly ErrorCandidate[], key: string): string | undefined {
|
|
495
|
+
for (const candidate of candidates) {
|
|
496
|
+
if (!candidate.value || typeof candidate.value !== "object") continue;
|
|
497
|
+
const value = (candidate.value as Record<string, unknown>)[key];
|
|
498
|
+
if (typeof value === "string" && value) return value;
|
|
499
|
+
}
|
|
500
|
+
return undefined;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function firstFiniteNumber(candidates: readonly ErrorCandidate[], key: string): number | undefined {
|
|
504
|
+
for (const candidate of candidates) {
|
|
505
|
+
if (!candidate.value || typeof candidate.value !== "object") continue;
|
|
506
|
+
const value = (candidate.value as Record<string, unknown>)[key];
|
|
507
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
508
|
+
}
|
|
509
|
+
return undefined;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function isCancellation(metadata: NetworkFailureMetadata, codes: readonly string[], text: string): boolean {
|
|
513
|
+
return (
|
|
514
|
+
metadata.name === "AbortError" ||
|
|
515
|
+
codes.includes("ABORT_ERR") ||
|
|
516
|
+
codes.includes("ERR_ABORTED") ||
|
|
517
|
+
/\b(?:operation|request|session) (?:was )?aborted\b|\baborted by (?:outer )?signal\b/i.test(text)
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function hasTransientHttpText(text: string): boolean {
|
|
522
|
+
for (const pattern of [
|
|
523
|
+
/(?:^|\b)HTTP(?:\s+status)?\s*[:=]?\s*(429|500|502|503|504|522|523|524|525|527|529)\b/gi,
|
|
524
|
+
/\bstatus(?:\s+code)?\s*[:=]?\s*(429|500|502|503|504|522|523|524|525|527|529)\b/gi,
|
|
525
|
+
/^\s*(429|500|502|503|504|522|523|524|525|527|529)(?:\s|:|-)/g,
|
|
526
|
+
]) {
|
|
527
|
+
if (pattern.test(text)) return true;
|
|
528
|
+
}
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function hasCode(codes: readonly string[], expected: ReadonlySet<string>): boolean {
|
|
533
|
+
return codes.some((code) => expected.has(code));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function normalizeCode(code: string): string {
|
|
537
|
+
return code
|
|
538
|
+
.trim()
|
|
539
|
+
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
|
540
|
+
.replace(/[\s-]+/g, "_")
|
|
541
|
+
.toUpperCase();
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function nestedNumber(value: unknown, key: string): number | undefined {
|
|
545
|
+
if (!value || typeof value !== "object") return undefined;
|
|
546
|
+
const nested = (value as Record<string, unknown>)[key];
|
|
547
|
+
return typeof nested === "number" ? nested : undefined;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
551
|
+
const number = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : NaN;
|
|
552
|
+
return Number.isFinite(number) ? number : undefined;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function stringValue(value: unknown): string | undefined {
|
|
556
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function fallbackMessage(error: unknown, status: number | undefined): string {
|
|
560
|
+
if (status !== undefined) return `HTTP ${status}`;
|
|
561
|
+
if (error === undefined) return "undefined";
|
|
562
|
+
if (error === null) return "null";
|
|
563
|
+
try {
|
|
564
|
+
const serialized = JSON.stringify(error);
|
|
565
|
+
if (serialized) return serialized;
|
|
566
|
+
} catch {
|
|
567
|
+
// Fall through to the always-safe coercion below.
|
|
568
|
+
}
|
|
569
|
+
return String(error);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function unique<T>(values: readonly T[]): T[] {
|
|
573
|
+
return [...new Set(values)];
|
|
574
|
+
}
|