oira666_pi-limits-wait 0.4.8 → 0.4.9
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 +8 -0
- package/package.json +1 -1
- package/src/index.ts +165 -75
package/README.md
CHANGED
|
@@ -19,6 +19,8 @@ Restart Pi or run `/reload` after installing.
|
|
|
19
19
|
- Uses provider retry timing when available (`retry-after`, `retry-after-ms`, `retry in ...`, reset messages).
|
|
20
20
|
- If no retry timing is available for a rate limit, waits 30 minutes before retrying.
|
|
21
21
|
- On `server_is_overloaded`, waits 5 minutes, then retries. If the provider is still overloaded after Pi's normal retries, it waits another 5 minutes and repeats.
|
|
22
|
+
- On transient network/transport failures — including undici idle-timeout aborts (`UND_ERR_HEADERS_TIMEOUT` / `UND_ERR_BODY_TIMEOUT`), `fetch failed`, `terminated`, `ECONNRESET`, `ETIMEDOUT`, etc. — treats the error as retryable with a short backoff (default 15s, or the provider's `retry-after`) instead of giving up. This prevents a stalled streaming request from turning into a silent hang.
|
|
23
|
+
- For any other (unclassified) non-retryable error, retries the same model a few times (default 3) before falling back / freezing it, so a one-off hiccup does not immediately sideline a model.
|
|
22
24
|
- Shows a countdown in the Pi status/working line.
|
|
23
25
|
- Press Enter during the countdown to skip the wait and retry immediately.
|
|
24
26
|
- Optionally falls back to configured models when the current/default model is rate-limited.
|
|
@@ -74,6 +76,12 @@ Fallback behavior:
|
|
|
74
76
|
|
|
75
77
|
When the settings are loaded, the extension shows the full usable fallback model list. When models become rate-limited, it shows a live countdown for each limited model.
|
|
76
78
|
|
|
79
|
+
## Environment variables
|
|
80
|
+
|
|
81
|
+
| Variable | Default | Description |
|
|
82
|
+
|----------|---------|-------------|
|
|
83
|
+
| `PI_LIMITS_WAIT_FREEZING_ENABLED` | `true` | When a model keeps failing with a non-retryable error, it is normally "frozen" for 1 hour and skipped in favour of other configured models. Set this to `false` (also accepts `0`, `no`, `off`) to disable freezing entirely: the extension will instead try each configured candidate once (after the bounded retries) and then surface the error, never blocking on a long "model-frozen" wait. Useful for non-interactive / subagent runs where no one can press Enter to skip. |
|
|
84
|
+
|
|
77
85
|
## License
|
|
78
86
|
|
|
79
87
|
MIT
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -31,15 +31,22 @@ const PI_IDENTITY_SENTENCE_PATTERN =
|
|
|
31
31
|
|
|
32
32
|
const DEFAULT_RATE_LIMIT_WAIT_MS = 30 * 60 * 1_000; // 30 minutes
|
|
33
33
|
const DEFAULT_OVERLOADED_WAIT_MS = 5 * 60 * 1_000; // 5 minutes
|
|
34
|
-
const
|
|
34
|
+
const DEFAULT_NETWORK_WAIT_MS = 15 * 1_000; // 15 seconds
|
|
35
|
+
const DEFAULT_NON_RETRYABLE_FREEZE_MS = 10 * 60 * 1_000; // 1 hour
|
|
36
|
+
/** How many times a non-retryable error is retried before we freeze/fallback. */
|
|
37
|
+
const NON_RETRYABLE_MAX_ATTEMPTS = 3;
|
|
38
|
+
/** Short backoff between non-retryable retries. */
|
|
39
|
+
const NON_RETRYABLE_RETRY_DELAY_MS = 5 * 1_000; // 5 seconds
|
|
35
40
|
const SETTINGS_FILE_NAME = "limits-wait.json";
|
|
36
41
|
const FALLBACK_MODELS_KEY = "fallback-models";
|
|
42
|
+
/** Env var to toggle the 1-hour model-freeze behaviour. Default: enabled. */
|
|
43
|
+
const FREEZING_ENV_VAR = "PI_LIMITS_WAIT_FREEZING_ENABLED";
|
|
37
44
|
|
|
38
45
|
/** Key used with ctx.ui.setStatus() for the countdown line. */
|
|
39
46
|
const STATUS_KEY = "limits-wait";
|
|
40
47
|
const MODELS_STATUS_KEY = "limits-wait-models";
|
|
41
48
|
|
|
42
|
-
type RetryReason = "rate-limit" | "overloaded" | "authentication" | "model-frozen";
|
|
49
|
+
type RetryReason = "rate-limit" | "overloaded" | "authentication" | "model-frozen" | "network" | "retry";
|
|
43
50
|
|
|
44
51
|
type RetryableError = {
|
|
45
52
|
reason: RetryReason;
|
|
@@ -82,6 +89,8 @@ let lastObservedRateLimitError: { at: number; message: string } | undefined;
|
|
|
82
89
|
const wrappedApis = new Set<Api>();
|
|
83
90
|
const builtinStreamSimpleByApi = new Map<Api, StreamSimpleFn>();
|
|
84
91
|
let fallbackModels: FallbackModel[] = [];
|
|
92
|
+
let nonRetryableMaxAttempts = NON_RETRYABLE_MAX_ATTEMPTS;
|
|
93
|
+
let nonRetryableRetryDelayMs = NON_RETRYABLE_RETRY_DELAY_MS;
|
|
85
94
|
let primaryModel: Model<Api> | undefined;
|
|
86
95
|
let primaryThinkingLevel: ThinkingLevel | undefined;
|
|
87
96
|
let suppressNextModelSelect = false;
|
|
@@ -504,6 +513,40 @@ export function isServerOverloadedError(msg: string): boolean {
|
|
|
504
513
|
);
|
|
505
514
|
}
|
|
506
515
|
|
|
516
|
+
/**
|
|
517
|
+
* Transient network / HTTP transport failures. These are NOT API rate-limit or
|
|
518
|
+
* overload responses, but they are still worth retrying with a short backoff
|
|
519
|
+
* instead of treating them as a hard non-retryable failure (which would freeze
|
|
520
|
+
* the model for an hour). Most importantly this covers undici's idle-timeout
|
|
521
|
+
* aborts (`UND_ERR_HEADERS_TIMEOUT` / `UND_ERR_BODY_TIMEOUT`) that fire when a
|
|
522
|
+
* streaming request stalls without a response.
|
|
523
|
+
*/
|
|
524
|
+
export function isTransientNetworkError(msg: string): boolean {
|
|
525
|
+
const lower = msg.toLowerCase();
|
|
526
|
+
return (
|
|
527
|
+
lower.includes("und_err_headers_timeout") ||
|
|
528
|
+
lower.includes("und_err_body_timeout") ||
|
|
529
|
+
lower.includes("und_err_connect_timeout") ||
|
|
530
|
+
lower.includes("und_err_socket") ||
|
|
531
|
+
lower.includes("headers timeout") ||
|
|
532
|
+
lower.includes("body timeout") ||
|
|
533
|
+
lower.includes("connect timeout") ||
|
|
534
|
+
lower.includes("fetch failed") ||
|
|
535
|
+
lower.includes("terminated") ||
|
|
536
|
+
lower.includes("socket hang up") ||
|
|
537
|
+
lower.includes("other side closed") ||
|
|
538
|
+
lower.includes("network_error") ||
|
|
539
|
+
lower.includes("network error") ||
|
|
540
|
+
lower.includes("econnreset") ||
|
|
541
|
+
lower.includes("econnrefused") ||
|
|
542
|
+
lower.includes("etimedout") ||
|
|
543
|
+
lower.includes("enetunreach") ||
|
|
544
|
+
lower.includes("ehostunreach") ||
|
|
545
|
+
lower.includes("eai_again") ||
|
|
546
|
+
lower.includes("enotfound")
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
507
550
|
export function isAuthenticationRefreshError(msg: string): boolean {
|
|
508
551
|
const lower = msg.toLowerCase();
|
|
509
552
|
return (
|
|
@@ -589,6 +632,9 @@ export function getRetryableError(msg: string): RetryableError | undefined {
|
|
|
589
632
|
if (isRateLimitError(msg)) {
|
|
590
633
|
return { reason: "rate-limit", waitMs: rateLimitWaitMs(msg) };
|
|
591
634
|
}
|
|
635
|
+
if (isTransientNetworkError(msg)) {
|
|
636
|
+
return { reason: "network", waitMs: parseRetryDelayMs(msg) ?? DEFAULT_NETWORK_WAIT_MS };
|
|
637
|
+
}
|
|
592
638
|
return undefined;
|
|
593
639
|
}
|
|
594
640
|
|
|
@@ -598,9 +644,23 @@ function reasonLabel(reason: RetryReason): string {
|
|
|
598
644
|
if (reason === "overloaded") return "Server overloaded";
|
|
599
645
|
if (reason === "authentication") return "Authentication refresh pending";
|
|
600
646
|
if (reason === "model-frozen") return "Model frozen after error";
|
|
647
|
+
if (reason === "network") return "Network/timeout error";
|
|
648
|
+
if (reason === "retry") return "Retrying after error";
|
|
601
649
|
return "Rate limited";
|
|
602
650
|
}
|
|
603
651
|
|
|
652
|
+
/**
|
|
653
|
+
* Whether the 1-hour model-freeze behaviour is enabled. Controlled by the
|
|
654
|
+
* `PI_LIMITS_WAIT_FREEZING_ENABLED` env var (default enabled). Set it to
|
|
655
|
+
* `false`/`0`/`no`/`off` to disable freezing entirely.
|
|
656
|
+
*/
|
|
657
|
+
export function freezingEnabled(): boolean {
|
|
658
|
+
const raw = process.env[FREEZING_ENV_VAR];
|
|
659
|
+
if (raw === undefined) return true;
|
|
660
|
+
const value = raw.trim().toLowerCase();
|
|
661
|
+
return !(value === "false" || value === "0" || value === "no" || value === "off");
|
|
662
|
+
}
|
|
663
|
+
|
|
604
664
|
function statusText(reason: RetryReason, deadline: number, allowSkip: boolean): string {
|
|
605
665
|
const remaining = Math.max(0, deadline - Date.now());
|
|
606
666
|
const totalSecs = Math.ceil(remaining / 1_000);
|
|
@@ -692,14 +752,19 @@ export function waitForRetry(
|
|
|
692
752
|
};
|
|
693
753
|
|
|
694
754
|
tick();
|
|
755
|
+
// The interval only drives the visible 1s countdown; the actual wait is
|
|
756
|
+
// resolved precisely at the deadline by `timer` below (so a 200ms backoff
|
|
757
|
+
// resolves in ~200ms instead of being rounded up to the next 1s tick).
|
|
695
758
|
const ticker = setInterval(() => {
|
|
696
759
|
if (Date.now() >= deadline) { cleanup(); resolve("waited"); return; }
|
|
697
760
|
tick();
|
|
698
761
|
}, 1_000);
|
|
762
|
+
const timer = setTimeout(() => { if (!done) { cleanup(); resolve("waited"); } }, Math.max(0, deadline - Date.now()));
|
|
699
763
|
|
|
700
764
|
function cleanup() {
|
|
701
765
|
done = true;
|
|
702
766
|
clearInterval(ticker);
|
|
767
|
+
clearTimeout(timer);
|
|
703
768
|
signal?.removeEventListener("abort", onAbort);
|
|
704
769
|
unsubInput?.();
|
|
705
770
|
ctx?.ui.setStatus(STATUS_KEY, undefined);
|
|
@@ -769,6 +834,11 @@ type StreamSimpleFn = (
|
|
|
769
834
|
options?: SimpleStreamOptions,
|
|
770
835
|
) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
|
|
771
836
|
|
|
837
|
+
export function __setNonRetryableTuningForTests(maxAttempts: number, retryDelayMs: number): void {
|
|
838
|
+
nonRetryableMaxAttempts = maxAttempts;
|
|
839
|
+
nonRetryableRetryDelayMs = retryDelayMs;
|
|
840
|
+
}
|
|
841
|
+
|
|
772
842
|
export function __configureFallbackModelsForTests(
|
|
773
843
|
models: FallbackModel[],
|
|
774
844
|
ctx?: ExtensionContext,
|
|
@@ -821,6 +891,11 @@ export function streamWithLimitsRetry(
|
|
|
821
891
|
let committed = false;
|
|
822
892
|
const allowFallback = fallbackEnabled() && isFallbackEligibleModel(model);
|
|
823
893
|
let attempt: FallbackModel = allowFallback ? initialAttempt(model) : { model, reasoningEffort: primaryThinkingLevel };
|
|
894
|
+
// Per-stream bookkeeping: how many times each model has hit a non-retryable
|
|
895
|
+
// error, and which models we've exhausted (used to advance fallbacks when
|
|
896
|
+
// freezing is disabled).
|
|
897
|
+
const nonRetryableAttempts = new Map<string, number>();
|
|
898
|
+
const triedModels = new Set<string>();
|
|
824
899
|
|
|
825
900
|
const flush = (buffer: AssistantMessageEvent[]) => {
|
|
826
901
|
for (const event of buffer) output.push(event);
|
|
@@ -879,20 +954,11 @@ export function streamWithLimitsRetry(
|
|
|
879
954
|
break;
|
|
880
955
|
}
|
|
881
956
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
if (buffer.length > 0) {
|
|
888
|
-
flush(buffer);
|
|
889
|
-
} else {
|
|
890
|
-
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
891
|
-
committed = true;
|
|
892
|
-
}
|
|
893
|
-
output.push(event);
|
|
894
|
-
output.end();
|
|
895
|
-
return;
|
|
957
|
+
// Defer handling to the post-loop block, which applies the
|
|
958
|
+
// bounded non-retryable retry / fallback / freeze logic. This
|
|
959
|
+
// runs whether or not fallbacks are configured.
|
|
960
|
+
nonRetryableError = { message: errMsg, event };
|
|
961
|
+
break;
|
|
896
962
|
}
|
|
897
963
|
|
|
898
964
|
if (event.type === "start") {
|
|
@@ -934,25 +1000,21 @@ export function streamWithLimitsRetry(
|
|
|
934
1000
|
if (retryable && observedRateLimit) retryableErrorMessage = observedRateLimit;
|
|
935
1001
|
}
|
|
936
1002
|
if (!retryable) {
|
|
937
|
-
if (
|
|
938
|
-
nonRetryableError = { message: errMsg };
|
|
939
|
-
} else {
|
|
1003
|
+
if (aborted) {
|
|
940
1004
|
if (!committed) {
|
|
941
1005
|
// Synthetic start+error so the stream protocol stays valid.
|
|
942
1006
|
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
943
1007
|
committed = true;
|
|
944
1008
|
}
|
|
945
1009
|
const error = freshMessage(attempt.model);
|
|
946
|
-
error.stopReason =
|
|
1010
|
+
error.stopReason = "aborted";
|
|
947
1011
|
error.errorMessage = errMsg;
|
|
948
|
-
output.push({
|
|
949
|
-
type: "error",
|
|
950
|
-
reason: error.stopReason as "error" | "aborted",
|
|
951
|
-
error,
|
|
952
|
-
});
|
|
1012
|
+
output.push({ type: "error", reason: "aborted", error });
|
|
953
1013
|
output.end();
|
|
954
1014
|
return;
|
|
955
1015
|
}
|
|
1016
|
+
// Defer to the post-loop block for bounded retries / fallback.
|
|
1017
|
+
nonRetryableError = { message: errMsg };
|
|
956
1018
|
}
|
|
957
1019
|
}
|
|
958
1020
|
} finally {
|
|
@@ -964,68 +1026,96 @@ export function streamWithLimitsRetry(
|
|
|
964
1026
|
}
|
|
965
1027
|
}
|
|
966
1028
|
|
|
967
|
-
if (
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
attempt = next;
|
|
973
|
-
continue;
|
|
974
|
-
}
|
|
1029
|
+
if (!retryable) {
|
|
1030
|
+
// Both explicit non-retryable error events/exceptions and a provider
|
|
1031
|
+
// stream that ended without a terminal event land here.
|
|
1032
|
+
const failureEvent = nonRetryableError?.event;
|
|
1033
|
+
const failureMessage = nonRetryableError?.message ?? "Provider stream ended without a terminal event.";
|
|
975
1034
|
|
|
976
|
-
|
|
977
|
-
if (
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1035
|
+
const emitFailure = () => {
|
|
1036
|
+
if (!committed) {
|
|
1037
|
+
if (buffer.length > 0) {
|
|
1038
|
+
flush(buffer);
|
|
1039
|
+
} else {
|
|
1040
|
+
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
1041
|
+
committed = true;
|
|
982
1042
|
}
|
|
983
1043
|
}
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
if (!committed) {
|
|
987
|
-
if (buffer.length > 0) {
|
|
988
|
-
flush(buffer);
|
|
1044
|
+
if (failureEvent) {
|
|
1045
|
+
output.push(failureEvent);
|
|
989
1046
|
} else {
|
|
1047
|
+
const error = freshMessage(attempt.model);
|
|
1048
|
+
error.stopReason = "error";
|
|
1049
|
+
error.errorMessage = failureMessage;
|
|
1050
|
+
output.push({ type: "error", reason: "error", error });
|
|
1051
|
+
}
|
|
1052
|
+
output.end();
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
const emitAborted = () => {
|
|
1056
|
+
if (!committed) {
|
|
990
1057
|
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
991
1058
|
committed = true;
|
|
992
1059
|
}
|
|
993
|
-
}
|
|
994
|
-
if (nonRetryableError.event) {
|
|
995
|
-
output.push(nonRetryableError.event);
|
|
996
|
-
} else {
|
|
997
1060
|
const error = freshMessage(attempt.model);
|
|
998
|
-
error.stopReason = "
|
|
999
|
-
error.errorMessage =
|
|
1000
|
-
output.push({ type: "error", reason: "
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
return;
|
|
1004
|
-
}
|
|
1061
|
+
error.stopReason = "aborted";
|
|
1062
|
+
error.errorMessage = "Request aborted while retrying after error.";
|
|
1063
|
+
output.push({ type: "error", reason: "aborted", error });
|
|
1064
|
+
output.end();
|
|
1065
|
+
};
|
|
1005
1066
|
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
const next = nextAvailableCandidate(attempt.model);
|
|
1011
|
-
if (next) {
|
|
1012
|
-
attempt = next;
|
|
1013
|
-
continue;
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1067
|
+
// Nothing has been streamed yet: give the same model a few quick retries
|
|
1068
|
+
// before we freeze it or fall back. This rides out transient failures
|
|
1069
|
+
// that aren't recognised as retryable (stalls, odd 5xx, malformed
|
|
1070
|
+
// errors) instead of immediately freezing the model for an hour.
|
|
1016
1071
|
if (!committed) {
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1072
|
+
const key = modelKey(attempt.model);
|
|
1073
|
+
const attempts = (nonRetryableAttempts.get(key) ?? 0) + 1;
|
|
1074
|
+
nonRetryableAttempts.set(key, attempts);
|
|
1075
|
+
|
|
1076
|
+
if (attempts < nonRetryableMaxAttempts) {
|
|
1077
|
+
const waitResult = await waitForRetry("retry", nonRetryableRetryDelayMs, options?.signal);
|
|
1078
|
+
if (waitResult === "aborted") {
|
|
1079
|
+
emitAborted();
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
continue; // retry the same model
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// Retries for this model are exhausted.
|
|
1086
|
+
triedModels.add(key);
|
|
1087
|
+
|
|
1088
|
+
if (allowFallback) {
|
|
1089
|
+
if (freezingEnabled()) {
|
|
1090
|
+
rememberNonRetryableFailure(attempt.model, failureMessage);
|
|
1091
|
+
const next = nextAvailableCandidate(attempt.model);
|
|
1092
|
+
if (next) {
|
|
1093
|
+
attempt = next;
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
const deadline = earliestCandidateDeadline(attempt.model);
|
|
1098
|
+
if (deadline) {
|
|
1099
|
+
const waitResult = await waitForRetry("model-frozen", Math.max(0, deadline - Date.now()), options?.signal);
|
|
1100
|
+
if (waitResult !== "aborted") {
|
|
1101
|
+
attempt = nextAvailableCandidate(attempt.model) ?? { model: getPrimaryModel(attempt.model), reasoningEffort: primaryThinkingLevel };
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
} else {
|
|
1106
|
+
// Freezing disabled: walk through the remaining configured
|
|
1107
|
+
// candidates (without freezing) and only give up once they are
|
|
1108
|
+
// all exhausted. Never block on a long "model-frozen" wait.
|
|
1109
|
+
const next = candidateOrder(attempt.model).find((entry) => !triedModels.has(modelKey(entry.model)));
|
|
1110
|
+
if (next) {
|
|
1111
|
+
attempt = next;
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1022
1115
|
}
|
|
1023
1116
|
}
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
error.errorMessage = "Provider stream ended without a terminal event.";
|
|
1027
|
-
output.push({ type: "error", reason: "error", error });
|
|
1028
|
-
output.end();
|
|
1117
|
+
|
|
1118
|
+
emitFailure();
|
|
1029
1119
|
return;
|
|
1030
1120
|
}
|
|
1031
1121
|
|