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.
Files changed (3) hide show
  1. package/README.md +8 -0
  2. package/package.json +1 -1
  3. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-limits-wait",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
4
4
  "description": "Extension for PI that allows to automatically wait for limits to reset and retry the request",
5
5
  "type": "module",
6
6
  "license": "MIT",
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 DEFAULT_NON_RETRYABLE_FREEZE_MS = 60 * 60 * 1_000; // 1 hour
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
- if (allowFallback) {
883
- nonRetryableError = { message: errMsg, event };
884
- break;
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 (allowFallback && !aborted) {
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 = aborted ? "aborted" : "error";
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 (nonRetryableError) {
968
- if (allowFallback && !committed) {
969
- rememberNonRetryableFailure(attempt.model, nonRetryableError.message);
970
- const next = nextAvailableCandidate(attempt.model);
971
- if (next) {
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
- const deadline = earliestCandidateDeadline(attempt.model);
977
- if (deadline) {
978
- const waitResult = await waitForRetry("model-frozen", Math.max(0, deadline - Date.now()), options?.signal);
979
- if (waitResult !== "aborted") {
980
- attempt = nextAvailableCandidate(attempt.model) ?? { model: getPrimaryModel(attempt.model), reasoningEffort: primaryThinkingLevel };
981
- continue;
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 = "error";
999
- error.errorMessage = nonRetryableError.message;
1000
- output.push({ type: "error", reason: "error", error });
1001
- }
1002
- output.end();
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
- if (!retryable) {
1007
- if (allowFallback && !committed) {
1008
- const message = "Provider stream ended without a terminal event.";
1009
- rememberNonRetryableFailure(attempt.model, message);
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
- if (buffer.length > 0) {
1018
- flush(buffer);
1019
- } else {
1020
- output.push({ type: "start", partial: freshMessage(attempt.model) });
1021
- committed = true;
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
- const error = freshMessage(attempt.model);
1025
- error.stopReason = "error";
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