@monotykamary/pi-retry 0.6.9 → 0.7.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 +15 -3
- package/package.json +7 -1
- package/retry.ts +63 -22
- package/src/error-patterns.ts +73 -3
package/README.md
CHANGED
|
@@ -23,7 +23,8 @@ This extension automatically detects and retries **all** errors by default, with
|
|
|
23
23
|
|------------|----------------|----------|
|
|
24
24
|
| **Any retryable error** (catch-all) | **Indefinite** with capped backoff | Everything else — provider hiccups, stream exhaustion, credit issues, unknown errors |
|
|
25
25
|
| HTTP 400/413 | **Indefinite** with capped backoff, NO compaction | Transient context overflow that might resolve |
|
|
26
|
-
| Credit / payment errors | **Indefinite** with capped backoff | "Not Enough Credits", insufficient balance, 402 |
|
|
26
|
+
| Credit / payment errors | **Indefinite** with capped backoff | "Not Enough Credits", insufficient balance, 402 — top up and the retry loop auto-resumes |
|
|
27
|
+
| **Quota / session-limit / budget exhaustion** | **Not retried** — notify + stop | "You've hit your limit", `insufficient_quota`, "out of budget", suspended accounts |
|
|
27
28
|
| Connection errors | **Indefinite** with capped backoff | Network hiccups, connection drops, socket errors, stream exhaustion |
|
|
28
29
|
| Max tokens (`stopReason: "length"`) | **Auto-continue** indefinitely with hidden continuation turns | Model hits output token limit mid-generation |
|
|
29
30
|
|
|
@@ -50,6 +51,7 @@ This extension provides **automatic** infinite retry with sensible exponential b
|
|
|
50
51
|
- Automatic detection of 400/413, connection, credit, and stream exhaustion errors
|
|
51
52
|
- **Auto-continuation** when the model hits its max output tokens (`stopReason: "length"`) — indefinite, no cap, hidden from the TUI
|
|
52
53
|
- **Indefinite retry** — Keeps retrying until success
|
|
54
|
+
- **Auto-stop on quota/budget exhaustion** — Session limits, plan quotas, and budget caps ("You've hit your limit", "out of budget", `insufficient_quota`, suspended accounts) are detected and **not** retried, with a notification explaining why
|
|
53
55
|
- Exponential backoff with cap: max 60s between retries
|
|
54
56
|
- **Hidden triggers** — provider-valid custom messages use `display: false`, so retries do not add TUI clutter
|
|
55
57
|
- Manual controls via unified `/retry` command
|
|
@@ -133,7 +135,7 @@ const BACKOFF_MULTIPLIER = 2; // Double each time
|
|
|
133
135
|
|
|
134
136
|
1. **Listen to `agent_end` event** — Fires after each agent turn completes
|
|
135
137
|
2. **Check for any error** — Examine the last assistant message for `stopReason === "error"`
|
|
136
|
-
3. **Blacklist check** — Skip known permanent failures (invalid API key, model not found, etc.)
|
|
138
|
+
3. **Blacklist check** — Skip known permanent failures (invalid API key, model not found, quota/session-limit/budget exhaustion, suspended accounts, etc.)
|
|
137
139
|
4. **Categorize for messaging** — Classify into 400/413, credit, connection, or other for nice UI notifications
|
|
138
140
|
5. **Retry or continue with hidden turns** — Wait with exponential backoff, then trigger a provider-valid custom user turn via `pi.sendMessage()` with `display: false` and `triggerTurn: true`
|
|
139
141
|
6. **Valid provider context** — Hidden retry and continuation messages remain in context so providers never receive a trailing assistant message
|
|
@@ -158,6 +160,16 @@ These are explicitly **not** retried:
|
|
|
158
160
|
- Model not found / unknown model / no such model / model does not exist
|
|
159
161
|
- Unsupported model
|
|
160
162
|
|
|
163
|
+
### Non-Retryable (Quota / Session Limit / Budget)
|
|
164
|
+
Exhausted quotas, session limits, and budgets are auto-detected and stop the retry loop (with an explanatory notification), because retrying is pointless until you act or the reset window passes:
|
|
165
|
+
- **Usage / session limits with reset windows** — "You've hit your limit · resets …" and "5-hour limit reached" (Claude Code), "You've hit your usage limit" / "You've exceeded your usage limit" (Codex)
|
|
166
|
+
- **Plan / billing quotas** — OpenAI `insufficient_quota`, "You exceeded your current quota, please check your plan and billing details" (OpenAI, Gemini — reached only after pi's built-in 429 retry gives up)
|
|
167
|
+
- **Hard allotments** — OpenRouter `free-models-per-day`, Alibaba "Allocated quota exceeded" (`Throttling.AllocationQuota`), GitHub Copilot "premium request allowance"
|
|
168
|
+
- **Budget exhaustion** — "out of budget", "Budget has been exceeded" (LiteLLM-style proxies), max/spending/monthly limits
|
|
169
|
+
- **Suspended accounts** — "Your account … is suspended" (Kimi `exceeded_current_quota_error`)
|
|
170
|
+
|
|
171
|
+
Deliberate distinction: plain pay-as-you-go **balance** errors stay retryable — DeepSeek 402 "Insufficient Balance", OpenRouter 402 "Insufficient credits", Kimi "exceeded your current token quota". A mid-session top-up lets the retry loop auto-resume, whereas session limits and budgets do not self-resolve for hours.
|
|
172
|
+
|
|
161
173
|
### Max Tokens (stopReason: "length")
|
|
162
174
|
- The model hit its `max_tokens` / output token limit
|
|
163
175
|
- The model's response was truncated mid-generation
|
|
@@ -291,7 +303,7 @@ pi install npm:@georgebashi/pi-retry
|
|
|
291
303
|
- Error messages remain in the session history (but are invisible to the LLM)
|
|
292
304
|
- May hit the same error repeatedly if the issue is persistent (use `Ctrl+C` to abort)
|
|
293
305
|
- **Warning**: Retrying 400/413 without reducing context may fail repeatedly if the payload is genuinely too large
|
|
294
|
-
- Non-retryable errors (invalid API key, missing model) are logged but not retried — you'll need to fix the underlying issue
|
|
306
|
+
- Non-retryable errors (invalid API key, missing model, quota/session-limit/budget exhaustion) are logged but not retried — you'll need to fix the underlying issue, then use `/retry`
|
|
295
307
|
|
|
296
308
|
---
|
|
297
309
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@monotykamary/pi-retry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Extension suite for pi coding agent that handles 400/413 errors and connection errors with automatic retry",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Tom X Nguyen",
|
|
@@ -32,9 +32,15 @@
|
|
|
32
32
|
"src/",
|
|
33
33
|
"README.md"
|
|
34
34
|
],
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@earendil-works/pi-agent-core": "*",
|
|
37
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
38
|
+
"@earendil-works/pi-tui": "*"
|
|
39
|
+
},
|
|
35
40
|
"devDependencies": {
|
|
36
41
|
"@earendil-works/pi-agent-core": "^0.84.0",
|
|
37
42
|
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
43
|
+
"@earendil-works/pi-tui": "^0.84.0",
|
|
38
44
|
"@types/node": "25.9.1",
|
|
39
45
|
"@vitest/coverage-v8": "4.1.7",
|
|
40
46
|
"knip": "6.14.1",
|
package/retry.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
2
3
|
import { Agent } from "@earendil-works/pi-agent-core";
|
|
3
4
|
import { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import {
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
hasRetryableError,
|
|
9
10
|
isNonRetryableError,
|
|
10
11
|
isSilencedError,
|
|
12
|
+
hasQuotaExhaustedError,
|
|
11
13
|
hasMaxTokensStop,
|
|
12
14
|
isContextOverflowError,
|
|
13
15
|
isAssistantMessage,
|
|
@@ -29,8 +31,9 @@ const RETRY_CANCELLED_EVENT = "pi-retry:cancelled";
|
|
|
29
31
|
* Unified retry extension — retries EVERY error by default.
|
|
30
32
|
*
|
|
31
33
|
* Philosophy: any assistant message with stopReason === "error" is retried
|
|
32
|
-
* indefinitely with exponential backoff, except a
|
|
33
|
-
* permanent failures (invalid API key, model not
|
|
34
|
+
* indefinitely with exponential backoff, except a small blacklist of known
|
|
35
|
+
* permanent failures and hard-stop conditions (invalid API key, model not
|
|
36
|
+
* found, quota/session-limit/budget exhaustion, suspended accounts, etc.).
|
|
34
37
|
*
|
|
35
38
|
* Specific categories (400/413, credit, connection, stream exhaustion, etc.)
|
|
36
39
|
* are tracked for diagnostics but all share the same retry mechanism.
|
|
@@ -102,9 +105,10 @@ const stateOther = new RetryState();
|
|
|
102
105
|
// Max_tokens continuation state (indefinite — no cap needed)
|
|
103
106
|
const stateContinuation = new ContinuationState();
|
|
104
107
|
|
|
105
|
-
// Abort flag: set when
|
|
106
|
-
// session_start and
|
|
107
|
-
// from starting a hidden retry turn after
|
|
108
|
+
// Abort flag: set when Pi's active signal is aborted or turn_end reports
|
|
109
|
+
// stopReason "aborted", cleared on session_start and fresh user activity.
|
|
110
|
+
// Prevents triggerInvisibleContinue() from starting a hidden retry turn after
|
|
111
|
+
// the user explicitly cancelled, even if a tool/provider reports an error.
|
|
108
112
|
let _userAborted = false;
|
|
109
113
|
|
|
110
114
|
// Mutex: only one triggerInvisibleContinue may be in-flight at a time.
|
|
@@ -186,25 +190,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
186
190
|
|
|
187
191
|
pi.on("input", () => {
|
|
188
192
|
_inputGeneration++;
|
|
193
|
+
// The generation change cancels any older loop; the new user request gets
|
|
194
|
+
// its own retry eligibility even if the previous request was aborted.
|
|
195
|
+
_userAborted = false;
|
|
189
196
|
});
|
|
190
197
|
|
|
191
198
|
// Reset retry counters on successful completion (not max_tokens, not error)
|
|
192
199
|
pi.on("turn_end", async (event, ctx) => {
|
|
193
200
|
const msg = event.message as any;
|
|
201
|
+
if (
|
|
202
|
+
ctx.signal?.aborted ||
|
|
203
|
+
(msg.role === "assistant" && msg.stopReason === "aborted")
|
|
204
|
+
) {
|
|
205
|
+
// User cancelled — reset retry state so it doesn't leak into other
|
|
206
|
+
// branches of the session tree. The signal check matters for tool calls:
|
|
207
|
+
// some tools/providers finish with an error-shaped result after abort.
|
|
208
|
+
state400.reset();
|
|
209
|
+
stateCredit.reset();
|
|
210
|
+
stateConnection.reset();
|
|
211
|
+
stateOther.reset();
|
|
212
|
+
stateContinuation.endContinuation();
|
|
213
|
+
// Signal to any in-flight triggerInvisibleContinue or pending retry
|
|
214
|
+
// that the user has cancelled — do not queue another retry turn.
|
|
215
|
+
_userAborted = true;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
194
218
|
if (msg.role === "assistant" && msg.stopReason !== "error") {
|
|
195
|
-
if (msg.stopReason === "aborted") {
|
|
196
|
-
// User cancelled — reset retry state so it doesn't leak into other
|
|
197
|
-
// branches of the session tree.
|
|
198
|
-
state400.reset();
|
|
199
|
-
stateCredit.reset();
|
|
200
|
-
stateConnection.reset();
|
|
201
|
-
stateOther.reset();
|
|
202
|
-
stateContinuation.endContinuation();
|
|
203
|
-
// Signal to any in-flight triggerInvisibleContinue or pending retry
|
|
204
|
-
// that the user has cancelled — do not queue another retry turn.
|
|
205
|
-
_userAborted = true;
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
219
|
if (msg.stopReason !== "length") {
|
|
209
220
|
// Normal completion — reset everything including continuation count
|
|
210
221
|
state400.succeed();
|
|
@@ -230,6 +241,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
230
241
|
// triggerInvisibleContinue(), which owns the retry loop with backoff
|
|
231
242
|
// sleeps that happen AFTER processEvents returns (outside the agent run).
|
|
232
243
|
pi.on("agent_end", async (event, ctx) => {
|
|
244
|
+
// Prefer Pi's run signal over provider-specific stop-reason mapping. An
|
|
245
|
+
// Escape during a Fabric/core tool may settle as an error-shaped result,
|
|
246
|
+
// but it is still a user cancellation and must never schedule a retry.
|
|
247
|
+
if (ctx.signal?.aborted) {
|
|
248
|
+
_userAborted = true;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
233
252
|
const entries = ctx.sessionManager.getEntries();
|
|
234
253
|
const lastAssistant = getLastAssistantMessage(entries);
|
|
235
254
|
|
|
@@ -309,7 +328,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
309
328
|
// (silenced errors are neither retried nor shown)
|
|
310
329
|
if (isNonRetryableError(lastAssistant) && !isSilencedError(lastAssistant)) {
|
|
311
330
|
const errorMsg = lastAssistant.errorMessage || "Unknown error";
|
|
312
|
-
ctx.ui.notify(
|
|
331
|
+
ctx.ui.notify(
|
|
332
|
+
hasQuotaExhaustedError(lastAssistant)
|
|
333
|
+
? `Quota/limit exhausted — not retrying (fix plan/billing or wait for the reset window, then /retry): ${errorMsg.substring(0, 100)}`
|
|
334
|
+
: `Non-retryable error (not retried): ${errorMsg.substring(0, 100)}`,
|
|
335
|
+
"error",
|
|
336
|
+
);
|
|
313
337
|
}
|
|
314
338
|
});
|
|
315
339
|
|
|
@@ -422,6 +446,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
422
446
|
return;
|
|
423
447
|
}
|
|
424
448
|
|
|
449
|
+
// Non-retryable errors (permanent failures + quota/budget
|
|
450
|
+
// exhaustion): report clearly instead of the generic fallback.
|
|
451
|
+
if (isNonRetryableError(lastAssistant)) {
|
|
452
|
+
const errorMsg = lastAssistant.errorMessage || "Unknown error";
|
|
453
|
+
ctx.ui.notify(
|
|
454
|
+
hasQuotaExhaustedError(lastAssistant)
|
|
455
|
+
? `Quota/limit exhausted — resolve the plan/billing issue or wait for the reset window first: ${errorMsg.substring(0, 100)}`
|
|
456
|
+
: `Non-retryable error (fix the underlying issue first, then /retry): ${errorMsg.substring(0, 100)}`,
|
|
457
|
+
"warning",
|
|
458
|
+
);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
425
462
|
// Auto-detect error type and trigger appropriate retry
|
|
426
463
|
if (has400or413Error(lastAssistant)) {
|
|
427
464
|
ctx.ui.notify("Manually retrying 400/413 error...", "info");
|
|
@@ -480,16 +517,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
480
517
|
if (ctx.mode === "tui") {
|
|
481
518
|
_terminalInputUnsubscribe = ctx.ui.onTerminalInput(data => {
|
|
482
519
|
if (
|
|
483
|
-
data
|
|
520
|
+
!matchesKey(data, "escape") ||
|
|
484
521
|
!_continueInProgress ||
|
|
485
522
|
_continueGeneration !== _sessionGeneration
|
|
486
523
|
) {
|
|
487
524
|
return undefined;
|
|
488
525
|
}
|
|
489
526
|
|
|
527
|
+
// Cancel pi-retry's out-of-band loop, but let Pi's native interrupt
|
|
528
|
+
// handler receive the same key. Pi owns the active turn and queue: its
|
|
529
|
+
// handler aborts the current tool/model call and clears any queued
|
|
530
|
+
// steer/follow-up messages. Consuming Escape here bypasses that cleanup
|
|
531
|
+
// and can let AgentSession continue after the user asked it to stop.
|
|
490
532
|
_userAborted = true;
|
|
491
|
-
|
|
492
|
-
return { consume: true };
|
|
533
|
+
return undefined;
|
|
493
534
|
});
|
|
494
535
|
}
|
|
495
536
|
});
|
package/src/error-patterns.ts
CHANGED
|
@@ -131,6 +131,56 @@ const SILENCED_PATTERNS = [
|
|
|
131
131
|
/cannot continue from message role/i,
|
|
132
132
|
];
|
|
133
133
|
|
|
134
|
+
// Quota / session-limit / budget exhaustion — hard stops:
|
|
135
|
+
// retrying is pointless until the user upgrades, tops up a budget, or waits
|
|
136
|
+
// out a reset window measured in hours/days. Distinct from per-minute rate
|
|
137
|
+
// limits and pay-as-you-go balance errors, which stay retryable.
|
|
138
|
+
//
|
|
139
|
+
// Evidence (real provider messages):
|
|
140
|
+
// - Claude Code: "You've hit your limit · resets 4pm (Asia/Kuala_Lumpur)"
|
|
141
|
+
// "Claude usage limit reached. Your limit will reset at 3pm"
|
|
142
|
+
// "5-hour limit reached · resets 12pm"
|
|
143
|
+
// - Codex: "You've hit your usage limit. Upgrade to Plus"
|
|
144
|
+
// "You've exceeded your usage limit."
|
|
145
|
+
// - OpenAI: code "insufficient_quota" — "You exceeded your current
|
|
146
|
+
// quota, please check your plan and billing details"
|
|
147
|
+
// - Gemini: same sentence in 429 RESOURCE_EXHAUSTED responses; only
|
|
148
|
+
// reaches us after pi's built-in 429 retry gives up
|
|
149
|
+
// - OpenRouter: "Rate limit exceeded: free-models-per-day. ..."
|
|
150
|
+
// - Alibaba: "Allocated quota exceeded, please increase your quota limit"
|
|
151
|
+
// (Throttling.AllocationQuota — hard cap; RateQuota is
|
|
152
|
+
// transient and stays retryable)
|
|
153
|
+
// - Copilot: "You have exceeded your premium request allowance"
|
|
154
|
+
// - LiteLLM: "Budget has been exceeded! Current cost: …, Max budget: …"
|
|
155
|
+
// - Kimi: "Your account {org}<{ak}> is suspended, please check your
|
|
156
|
+
// plan and billing details" (exceeded_current_quota_error)
|
|
157
|
+
export const QUOTA_EXHAUSTED_PATTERNS = [
|
|
158
|
+
// Session / usage limits with reset windows (Claude, Codex)
|
|
159
|
+
/hit your (usage )?limit/i,
|
|
160
|
+
/usage\s*limit\s*(has\s*been\s*)?reached/i,
|
|
161
|
+
/hour\s*limit\s*reached/i, // "5-hour limit reached" — must NOT hit DeepSeek 429 "Rate Limit Reached"
|
|
162
|
+
/limit\s*will\s*reset\s*at/i,
|
|
163
|
+
/session\s*(limit|quota)/i,
|
|
164
|
+
/exceeded your usage limit/i,
|
|
165
|
+
// Billing / plan quotas (OpenAI insufficient_quota, Gemini RESOURCE_EXHAUSTED)
|
|
166
|
+
/insufficient[_\s]quota/i,
|
|
167
|
+
/exceeded your current quota/i, // Kimi's "...current token quota" (balance, retryable) intentionally not matched
|
|
168
|
+
// Hard allotments
|
|
169
|
+
/free.models.per.day/i, // OpenRouter free-tier daily pool
|
|
170
|
+
/allocated\s*quota/i, // Alibaba Throttling.AllocationQuota
|
|
171
|
+
/premium\s*request\s*allowance/i, // GitHub Copilot monthly allowance
|
|
172
|
+
/monthly\s*(limit|quota|budget|allowance)/i,
|
|
173
|
+
// Budget exhaustion (LiteLLM and similar proxies/gateways)
|
|
174
|
+
/out of budget/i,
|
|
175
|
+
/budget\s*(has\s*been\s*)?(exceeded|exhausted|limit)/i,
|
|
176
|
+
/max(imum)?\s*budget\s*(exceeded|reached|limit)/i,
|
|
177
|
+
/spending\s*limit/i,
|
|
178
|
+
// Suspended accounts (Kimi exceeded_current_quota_error suspended form)
|
|
179
|
+
/account\b[^.]*\bis\s*suspended/i,
|
|
180
|
+
// Generic
|
|
181
|
+
/quota\s*(exhausted|depleted)/i,
|
|
182
|
+
];
|
|
183
|
+
|
|
134
184
|
// ── Type guard ──
|
|
135
185
|
|
|
136
186
|
export function isAssistantMessage(message: AgentMessage): message is Extract<AgentMessage, { role: "assistant" }> {
|
|
@@ -184,7 +234,7 @@ export function isContextOverflowError(message: AgentMessage): boolean {
|
|
|
184
234
|
export function hasRetryableError(message: AgentMessage): boolean {
|
|
185
235
|
if (!isAssistantMessage(message)) return false;
|
|
186
236
|
if (message.stopReason !== "error" || !message.errorMessage) return false;
|
|
187
|
-
return !
|
|
237
|
+
return !isNonRetryableError(message);
|
|
188
238
|
}
|
|
189
239
|
|
|
190
240
|
/**
|
|
@@ -193,7 +243,26 @@ export function hasRetryableError(message: AgentMessage): boolean {
|
|
|
193
243
|
export function isNonRetryableError(message: AgentMessage): boolean {
|
|
194
244
|
if (!isAssistantMessage(message)) return false;
|
|
195
245
|
if (message.stopReason !== "error" || !message.errorMessage) return false;
|
|
196
|
-
return
|
|
246
|
+
return (
|
|
247
|
+
NON_RETRYABLE_PATTERNS.some(p => p.test(message.errorMessage!)) ||
|
|
248
|
+
QUOTA_EXHAUSTED_PATTERNS.some(p => p.test(message.errorMessage!))
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Returns true for quota / session-limit / budget exhaustion errors where
|
|
254
|
+
* retrying is pointless until the user acts (upgrade, top up a budget) or a
|
|
255
|
+
* long reset window passes (hours/days). Treated as non-retryable.
|
|
256
|
+
*
|
|
257
|
+
* Deliberately NOT matched: per-minute rate limits (429s) and pay-as-you-go
|
|
258
|
+
* balance errors (DeepSeek "Insufficient Balance", OpenRouter "Insufficient
|
|
259
|
+
* credits", Kimi "exceeded your current token quota") — those stay retryable
|
|
260
|
+
* so a mid-session top-up auto-resumes.
|
|
261
|
+
*/
|
|
262
|
+
export function hasQuotaExhaustedError(message: AgentMessage): boolean {
|
|
263
|
+
if (!isAssistantMessage(message)) return false;
|
|
264
|
+
if (message.stopReason !== "error" || !message.errorMessage) return false;
|
|
265
|
+
return QUOTA_EXHAUSTED_PATTERNS.some(p => p.test(message.errorMessage!));
|
|
197
266
|
}
|
|
198
267
|
|
|
199
268
|
/**
|
|
@@ -209,7 +278,8 @@ export function isSilencedError(message: AgentMessage): boolean {
|
|
|
209
278
|
|
|
210
279
|
// ── Categorisation (for UI messages) ──
|
|
211
280
|
|
|
212
|
-
export function getErrorCategory(errorMessage: string): '400-413' | 'credit' | 'connection' | 'builtin' | 'other' {
|
|
281
|
+
export function getErrorCategory(errorMessage: string): '400-413' | 'credit' | 'connection' | 'builtin' | 'quota' | 'other' {
|
|
282
|
+
if (QUOTA_EXHAUSTED_PATTERNS.some(p => p.test(errorMessage))) return 'quota';
|
|
213
283
|
if (ERROR_400_413_PATTERNS.some(p => p.test(errorMessage))) return '400-413';
|
|
214
284
|
if (CREDIT_ERROR_PATTERNS.some(p => p.test(errorMessage))) return 'credit';
|
|
215
285
|
if (CONNECTION_ERROR_PATTERNS.some(p => p.test(errorMessage))) return 'connection';
|