@f5-sales-demo/pi-ai 22.6.0 → 22.7.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/pi-ai",
4
- "version": "22.6.0",
4
+ "version": "22.7.1",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -46,7 +46,7 @@
46
46
  "@anthropic-ai/sdk": "0.124.0",
47
47
  "@aws-sdk/client-bedrock-runtime": "3.1127.0",
48
48
  "@bufbuild/protobuf": "2.14.1",
49
- "@f5-sales-demo/pi-utils": "22.6.0",
49
+ "@f5-sales-demo/pi-utils": "22.7.1",
50
50
  "@google/genai": "2.21.0",
51
51
  "@sinclair/typebox": "0.34.52",
52
52
  "@smithy/node-http-handler": "4.12.1",
@@ -26,6 +26,7 @@ import { getEnvApiKey } from "../stream";
26
26
  import {
27
27
  type Api,
28
28
  type AssistantMessage,
29
+ type AssistantMessageEvent,
29
30
  type Context,
30
31
  isSpecialServiceTier,
31
32
  type Model,
@@ -47,8 +48,9 @@ import {
47
48
  } from "../utils";
48
49
  import { AssistantMessageEventStream } from "../utils/event-stream";
49
50
  import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
50
- import { getOpenAIStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
51
+ import { getOpenAIStreamIdleTimeoutMs } from "../utils/idle-iterator";
51
52
  import { parseStreamingJson } from "../utils/json-parse";
53
+ import { isTransportClosureError } from "../utils/retry";
52
54
  import { adaptSchemaForStrict, NO_STRICT } from "../utils/schema";
53
55
  import {
54
56
  CODEX_BASE_URL,
@@ -184,7 +186,6 @@ interface CodexRequestContext {
184
186
 
185
187
  interface CodexRequestSetup {
186
188
  requestSignal: AbortSignal;
187
- wrapCodexSseStream: (source: AsyncGenerator<Record<string, unknown>>) => AsyncGenerator<Record<string, unknown>>;
188
189
  requestAbortController: AbortController;
189
190
  }
190
191
 
@@ -194,6 +195,8 @@ interface CodexWebSocketRequestState {
194
195
  }
195
196
 
196
197
  interface CodexStreamRuntime {
198
+ stagedEvents: AssistantMessageEvent[];
199
+ firstTransportFailure?: unknown;
197
200
  websocketRequestState?: CodexWebSocketRequestState;
198
201
  continuationRecoveryAttempt: number;
199
202
  sawResponseAccepted: boolean;
@@ -211,6 +214,8 @@ interface CodexStreamRuntime {
211
214
  nativeOutputItems: Array<Record<string, unknown>>;
212
215
  websocketStreamRetries: number;
213
216
  providerRetryAttempt: number;
217
+ transportRestartAttempt: number;
218
+ sawReplayUnsafeActivity: boolean;
214
219
  sawTerminalEvent: boolean;
215
220
  canSafelyReplayWebsocketOverSse: boolean;
216
221
  }
@@ -450,15 +455,7 @@ function createRequestSetup(options: OpenAICodexResponsesOptions | undefined): C
450
455
  const requestSignal = options?.signal
451
456
  ? AbortSignal.any([options.signal, requestAbortController.signal])
452
457
  : requestAbortController.signal;
453
- const wrapCodexSseStream = (
454
- source: AsyncGenerator<Record<string, unknown>>,
455
- ): AsyncGenerator<Record<string, unknown>> =>
456
- iterateWithIdleTimeout(source, {
457
- idleTimeoutMs: getOpenAIStreamIdleTimeoutMs(),
458
- errorMessage: "OpenAI Codex SSE stream stalled while waiting for the next event",
459
- onIdle: () => requestAbortController.abort(),
460
- });
461
- return { requestAbortController, requestSignal, wrapCodexSseStream };
458
+ return { requestAbortController, requestSignal };
462
459
  }
463
460
 
464
461
  async function buildCodexRequestContext(
@@ -763,20 +760,83 @@ async function openCodexWebSocketTransport(
763
760
  return { eventStream, requestBodyForState, transport: "websocket", websocketRequestState };
764
761
  }
765
762
 
763
+ class CodexSseIdleTimeoutError extends Error {
764
+ constructor() {
765
+ super("OpenAI Codex SSE stream stalled while waiting for the next event");
766
+ this.name = "CodexSseIdleTimeoutError";
767
+ }
768
+ }
769
+
770
+ async function* iterateCodexSseAttempt(
771
+ source: AsyncGenerator<Record<string, unknown>>,
772
+ attempt: AbortController,
773
+ cleanup: () => void,
774
+ ): AsyncGenerator<Record<string, unknown>> {
775
+ const timeoutMs = getOpenAIStreamIdleTimeoutMs();
776
+ try {
777
+ while (true) {
778
+ attempt.signal.throwIfAborted();
779
+ let timer: NodeJS.Timeout | undefined;
780
+ try {
781
+ const next = source.next();
782
+ const result =
783
+ timeoutMs === undefined
784
+ ? await next
785
+ : await Promise.race([
786
+ next,
787
+ new Promise<never>((_resolve, reject) => {
788
+ timer = setTimeout(() => {
789
+ const error = new CodexSseIdleTimeoutError();
790
+ reject(error);
791
+ attempt.abort(error);
792
+ }, timeoutMs);
793
+ }),
794
+ ]);
795
+ if (timer !== undefined) {
796
+ clearTimeout(timer);
797
+ timer = undefined;
798
+ }
799
+ attempt.signal.throwIfAborted();
800
+ if (result.done) return;
801
+ yield result.value;
802
+ } finally {
803
+ if (timer !== undefined) clearTimeout(timer);
804
+ }
805
+ }
806
+ } finally {
807
+ attempt.abort();
808
+ cleanup();
809
+ try {
810
+ await source.return(undefined);
811
+ } catch {
812
+ /* Preserve the original stream failure. */
813
+ }
814
+ }
815
+ }
816
+
766
817
  async function openCodexSseTransport(
767
818
  requestContext: CodexRequestContext,
768
819
  requestSetup: CodexRequestSetup,
769
820
  options: OpenAICodexResponsesOptions | undefined,
770
821
  state: CodexWebSocketSessionState | undefined,
771
822
  body = requestContext.transformedBody,
823
+ retryFetch = true,
772
824
  ): Promise<{
773
825
  eventStream: AsyncGenerator<Record<string, unknown>>;
774
826
  requestBodyForState: RequestBody;
775
827
  transport: CodexTransport;
776
828
  websocketRequestState?: CodexWebSocketRequestState;
777
829
  }> {
778
- const eventStream = requestSetup.wrapCodexSseStream(
779
- await openCodexSseEventStream(
830
+ const attempt = new AbortController();
831
+ const signal = requestSetup.requestSignal;
832
+ const onAbort = () => attempt.abort(signal.reason);
833
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
834
+ signal.addEventListener("abort", onAbort, { once: true });
835
+ if (signal.aborted) onAbort();
836
+ let source: AsyncGenerator<Record<string, unknown>>;
837
+ try {
838
+ attempt.signal.throwIfAborted();
839
+ source = await openCodexSseEventStream(
780
840
  requestContext.url,
781
841
  requestContext.requestHeaders,
782
842
  requestContext.accountId,
@@ -784,9 +844,16 @@ async function openCodexSseTransport(
784
844
  options?.sessionId,
785
845
  body,
786
846
  state,
787
- requestSetup.requestSignal,
788
- ),
789
- );
847
+ attempt.signal,
848
+ retryFetch,
849
+ );
850
+ } catch (error) {
851
+ attempt.abort();
852
+ cleanup();
853
+ throw error;
854
+ }
855
+ const eventStream = iterateCodexSseAttempt(source, attempt, cleanup);
856
+
790
857
  return { eventStream, requestBodyForState: structuredCloneJSON(body), transport: "sse" };
791
858
  }
792
859
 
@@ -832,6 +899,7 @@ function createCodexStreamRuntime(initial: {
832
899
  websocketRequestState?: CodexWebSocketRequestState;
833
900
  }): CodexStreamRuntime {
834
901
  return {
902
+ stagedEvents: [],
835
903
  websocketRequestState: initial.websocketRequestState,
836
904
  continuationRecoveryAttempt: 0,
837
905
  sawResponseAccepted: false,
@@ -848,11 +916,37 @@ function createCodexStreamRuntime(initial: {
848
916
  nativeOutputItems: [],
849
917
  websocketStreamRetries: 0,
850
918
  providerRetryAttempt: 0,
919
+ transportRestartAttempt: 0,
920
+ sawReplayUnsafeActivity: false,
851
921
  sawTerminalEvent: false,
852
922
  canSafelyReplayWebsocketOverSse: true,
853
923
  };
854
924
  }
855
925
 
926
+ // Empty normalized blocks belong to the attempt until replay becomes unsafe.
927
+ class CodexAttemptEventStream extends AssistantMessageEventStream {
928
+ constructor(
929
+ private readonly target: AssistantMessageEventStream,
930
+ private readonly runtime: CodexStreamRuntime,
931
+ ) {
932
+ super();
933
+ }
934
+
935
+ override push(event: AssistantMessageEvent): void {
936
+ if (this.runtime.sawReplayUnsafeActivity || this.runtime.sawTerminalEvent) {
937
+ this.flush();
938
+ this.target.push(event);
939
+ } else {
940
+ this.runtime.stagedEvents.push(structuredCloneJSON(event));
941
+ }
942
+ }
943
+
944
+ flush(): void {
945
+ for (const event of this.runtime.stagedEvents) this.target.push(event);
946
+ this.runtime.stagedEvents.length = 0;
947
+ }
948
+ }
949
+
856
950
  async function processCodexResponseStream(
857
951
  context: CodexStreamProcessingContext,
858
952
  runtime: CodexStreamRuntime,
@@ -860,6 +954,7 @@ async function processCodexResponseStream(
860
954
  const { output, stream } = context;
861
955
  runtime.grammarToolInputProperties = context.requestContext.grammarToolInputProperties;
862
956
  stream.push({ type: "start", partial: output });
957
+ const attemptStream = new CodexAttemptEventStream(stream, runtime);
863
958
 
864
959
  while (true) {
865
960
  try {
@@ -868,10 +963,17 @@ async function processCodexResponseStream(
868
963
  await context.options?.onProviderStreamEvent?.(rawEvent, context.model);
869
964
  firstTokenTime = handleCodexStreamEvent({
870
965
  ...context,
966
+ stream: attemptStream,
871
967
  runtime,
872
968
  rawEvent,
873
969
  firstTokenTime,
874
970
  });
971
+ if (runtime.sawReplayUnsafeActivity || runtime.sawTerminalEvent) attemptStream.flush();
972
+ }
973
+ if (!runtime.sawTerminalEvent) {
974
+ throw Object.assign(new Error("Codex stream ended before terminal completion event"), {
975
+ code: "ERR_STREAM_PREMATURE_CLOSE",
976
+ });
875
977
  }
876
978
  return { firstTokenTime };
877
979
  } catch (error) {
@@ -894,6 +996,27 @@ function handleCodexStreamEvent(args: {
894
996
  const { model, output, stream, runtime, rawEvent } = args;
895
997
  const eventType = typeof rawEvent.type === "string" ? rawEvent.type : "";
896
998
  if (!eventType) return args.firstTokenTime;
999
+ // Tool activity is unsafe from its first event, even if no arguments or tool result exists yet.
1000
+ const item = asRecord(rawEvent.item);
1001
+ const part = asRecord(rawEvent.part);
1002
+ const hasText = (value: unknown): boolean => typeof value === "string" && value.length > 0;
1003
+ if (
1004
+ /^response\.(?:function_call|custom_tool_call|.*_call\.)/.test(eventType) ||
1005
+ (item && typeof item.type === "string" && item.type !== "message" && item.type !== "reasoning") ||
1006
+ hasText(rawEvent.delta) ||
1007
+ hasText(rawEvent.text) ||
1008
+ hasText(part?.text) ||
1009
+ hasText(part?.refusal) ||
1010
+ (item &&
1011
+ [item.content, item.summary].some(
1012
+ parts =>
1013
+ Array.isArray(parts) &&
1014
+ parts.some(value => hasText(asRecord(value)?.text) || hasText(asRecord(value)?.refusal)),
1015
+ ))
1016
+ ) {
1017
+ runtime.sawReplayUnsafeActivity = true;
1018
+ }
1019
+
897
1020
  if (
898
1021
  eventType === "response.created" ||
899
1022
  eventType === "response.in_progress" ||
@@ -1376,6 +1499,9 @@ async function recoverCodexStreamError(
1376
1499
  runtime: CodexStreamRuntime,
1377
1500
  error: unknown,
1378
1501
  ): Promise<boolean> {
1502
+ if (runtime.transport === "sse" && (error instanceof CodexSseIdleTimeoutError || isTransportClosureError(error))) {
1503
+ return tryRestartCodexSseTransport(context, runtime, error);
1504
+ }
1379
1505
  if (isCodexRejectedContinuation(error)) {
1380
1506
  return tryRecoverCodexContinuation(context, runtime);
1381
1507
  }
@@ -1391,6 +1517,101 @@ async function recoverCodexStreamError(
1391
1517
  return false;
1392
1518
  }
1393
1519
 
1520
+ async function sleepBeforeCodexSseRestart(delayMs: number, signal: AbortSignal): Promise<void> {
1521
+ signal.throwIfAborted();
1522
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
1523
+ const timer = setTimeout(resolve, delayMs);
1524
+ const onAbort = () => reject(signal.reason);
1525
+ signal.addEventListener("abort", onAbort, { once: true });
1526
+ try {
1527
+ await promise;
1528
+ signal.throwIfAborted();
1529
+ } finally {
1530
+ clearTimeout(timer);
1531
+ signal.removeEventListener("abort", onAbort);
1532
+ }
1533
+ }
1534
+
1535
+ async function tryRestartCodexSseTransport(
1536
+ context: CodexStreamProcessingContext,
1537
+ runtime: CodexStreamRuntime,
1538
+ originalError: unknown,
1539
+ ): Promise<boolean> {
1540
+ runtime.firstTransportFailure ??= originalError;
1541
+ let error = originalError;
1542
+ while (true) {
1543
+ const replayEligible =
1544
+ !runtime.sawReplayUnsafeActivity &&
1545
+ !runtime.sawTerminalEvent &&
1546
+ !context.requestSetup.requestSignal.aborted &&
1547
+ context.output.content.every(
1548
+ block =>
1549
+ (block.type === "text" && block.text.length === 0) ||
1550
+ (block.type === "thinking" && block.thinking.length === 0),
1551
+ );
1552
+ logCodexDebug("codex SSE transport recovery", {
1553
+ transport: runtime.transport,
1554
+ model: context.model.id,
1555
+ elapsedMs: Date.now() - context.startTime,
1556
+ attempt: runtime.transportRestartAttempt,
1557
+ terminalEventSeen: runtime.sawTerminalEvent,
1558
+ replayEligible,
1559
+ });
1560
+ if (!replayEligible || !(error instanceof CodexSseIdleTimeoutError || isTransportClosureError(error))) {
1561
+ if (error !== originalError) throw error;
1562
+ return false;
1563
+ }
1564
+ if (runtime.transportRestartAttempt >= 2) throw runtime.firstTransportFailure;
1565
+ runtime.transportRestartAttempt += 1;
1566
+ try {
1567
+ await runtime.eventStream.return(undefined);
1568
+ } catch {
1569
+ /* Preserve the recovery failure. */
1570
+ }
1571
+ runtime.currentItem = null;
1572
+ runtime.currentBlock = null;
1573
+ runtime.currentIndex = -1;
1574
+ runtime.outputSlots.clear();
1575
+ runtime.completedItemIds.clear();
1576
+ runtime.nativeOutputItems.length = 0;
1577
+ runtime.sawResponseAccepted = false;
1578
+ runtime.sawOutputEvent = false;
1579
+ runtime.sawTerminalEvent = false;
1580
+ runtime.stagedEvents.length = 0;
1581
+ resetOutputState(context.output);
1582
+ delete context.output.responseId;
1583
+ delete context.output.providerPayload;
1584
+ delete context.output.duration;
1585
+ delete context.output.ttft;
1586
+ delete context.output.errorMessage;
1587
+ context.output.responseAttribution = { requestedModel: context.model.id };
1588
+ context.firstTokenTime = undefined;
1589
+ const state = context.requestContext.websocketState;
1590
+ if (state) {
1591
+ resetCodexWebSocketAppendState(state);
1592
+ resetCodexSessionMetadata(state);
1593
+ }
1594
+ await sleepBeforeCodexSseRestart(500 * runtime.transportRestartAttempt, context.requestSetup.requestSignal);
1595
+ try {
1596
+ const next = await openCodexSseTransport(
1597
+ context.requestContext,
1598
+ context.requestSetup,
1599
+ context.options,
1600
+ state,
1601
+ context.requestContext.transformedBody,
1602
+ false,
1603
+ );
1604
+ runtime.eventStream = next.eventStream;
1605
+ runtime.requestBodyForState = next.requestBodyForState;
1606
+ runtime.websocketRequestState = undefined;
1607
+ return true;
1608
+ } catch (reopenError) {
1609
+ if (context.requestSetup.requestSignal.aborted) throw reopenError;
1610
+ error = reopenError;
1611
+ }
1612
+ }
1613
+ }
1614
+
1394
1615
  function isCodexRejectedContinuation(error: unknown): boolean {
1395
1616
  return (
1396
1617
  error instanceof CodexProviderStreamError &&
@@ -1479,6 +1700,7 @@ async function tryReconnectCodexWebSocketOnConnectionLimit(
1479
1700
  runtime.outputSlots.clear();
1480
1701
  runtime.completedItemIds.clear();
1481
1702
  runtime.nativeOutputItems.length = 0;
1703
+ runtime.stagedEvents.length = 0;
1482
1704
  resetOutputState(context.output);
1483
1705
  context.firstTokenTime = undefined;
1484
1706
  recordCodexWebSocketFailure(websocketState, true);
@@ -1540,6 +1762,7 @@ async function tryReplayWebsocketFailureOverSse(
1540
1762
  runtime.outputSlots.clear();
1541
1763
  runtime.completedItemIds.clear();
1542
1764
  runtime.nativeOutputItems.length = 0;
1765
+ runtime.stagedEvents.length = 0;
1543
1766
  resetOutputState(context.output);
1544
1767
  context.firstTokenTime = undefined;
1545
1768
  }
@@ -1581,6 +1804,7 @@ async function tryRetryCodexProviderError(
1581
1804
  runtime.outputSlots.clear();
1582
1805
  runtime.completedItemIds.clear();
1583
1806
  runtime.sawTerminalEvent = false;
1807
+ runtime.stagedEvents.length = 0;
1584
1808
  resetOutputState(context.output);
1585
1809
  context.firstTokenTime = undefined;
1586
1810
  await abortableSleep(CODEX_RETRY_DELAY_MS * runtime.providerRetryAttempt, context.requestSetup.requestSignal);
@@ -2314,6 +2538,7 @@ async function openCodexSseEventStream(
2314
2538
  body: RequestBody,
2315
2539
  state: CodexWebSocketSessionState | undefined,
2316
2540
  signal?: AbortSignal,
2541
+ retryFetch = true,
2317
2542
  ): Promise<AsyncGenerator<Record<string, unknown>>> {
2318
2543
  const headers = createCodexHeaders(requestHeaders, accountId, apiKey, sessionId, "sse", state);
2319
2544
  logCodexDebug("codex request", {
@@ -2332,6 +2557,7 @@ async function openCodexSseEventStream(
2332
2557
  body: JSON.stringify(body),
2333
2558
  },
2334
2559
  signal,
2560
+ retryFetch,
2335
2561
  );
2336
2562
  logCodexDebug("codex response", {
2337
2563
  url: response.url,
@@ -2353,7 +2579,39 @@ async function openCodexSseEventStream(
2353
2579
  if (!response.body) {
2354
2580
  throw new Error("No response body");
2355
2581
  }
2356
- return readSseJson<Record<string, unknown>>(response.body, signal);
2582
+ signal?.throwIfAborted();
2583
+ const reader = response.body.getReader();
2584
+ let closed = false;
2585
+ const close = () => {
2586
+ if (closed) return;
2587
+ closed = true;
2588
+ // Cancel settles pending reads synchronously; do not await an upstream cancel hook.
2589
+ void reader.cancel().catch(() => {});
2590
+ reader.releaseLock();
2591
+ };
2592
+ const onAbort = () => close();
2593
+ signal?.addEventListener("abort", onAbort, { once: true });
2594
+ const owned = new ReadableStream<Uint8Array>({
2595
+ async pull(controller) {
2596
+ try {
2597
+ const result = await reader.read();
2598
+ if (result.done) controller.close();
2599
+ else controller.enqueue(result.value);
2600
+ } catch (error) {
2601
+ controller.error(error);
2602
+ }
2603
+ },
2604
+ cancel: close,
2605
+ });
2606
+ async function* events(): AsyncGenerator<Record<string, unknown>> {
2607
+ try {
2608
+ yield* readSseJson<Record<string, unknown>>(owned);
2609
+ } finally {
2610
+ close();
2611
+ signal?.removeEventListener("abort", onAbort);
2612
+ }
2613
+ }
2614
+ return events();
2357
2615
  }
2358
2616
 
2359
2617
  function createCodexHeaders(
@@ -2437,13 +2695,14 @@ function getRetryDelayMs(
2437
2695
  return { delay: CODEX_RETRY_DELAY_MS * (attempt + 1), serverProvided: false };
2438
2696
  }
2439
2697
 
2440
- async function fetchWithRetry(url: string, init: RequestInit, signal?: AbortSignal): Promise<Response> {
2698
+ async function fetchWithRetry(url: string, init: RequestInit, signal?: AbortSignal, retry = true): Promise<Response> {
2441
2699
  let attempt = 0;
2442
2700
  let rateLimitTimeSpent = 0;
2443
2701
  while (true) {
2444
2702
  try {
2703
+ signal?.throwIfAborted();
2445
2704
  const response = await fetch(url, { ...init, signal: signal ?? init.signal });
2446
- if (!CODEX_RETRYABLE_STATUS.has(response.status)) {
2705
+ if (!retry || !CODEX_RETRYABLE_STATUS.has(response.status)) {
2447
2706
  return response;
2448
2707
  }
2449
2708
  if (signal?.aborted) return response;
@@ -2465,7 +2724,7 @@ async function fetchWithRetry(url: string, init: RequestInit, signal?: AbortSign
2465
2724
  }
2466
2725
  await abortableSleep(delay, signal);
2467
2726
  } catch (error) {
2468
- if (attempt >= CODEX_MAX_RETRIES || signal?.aborted) {
2727
+ if (!retry || attempt >= CODEX_MAX_RETRIES || signal?.aborted) {
2469
2728
  throw error;
2470
2729
  }
2471
2730
  const delay = CODEX_RETRY_DELAY_MS * (attempt + 1);
@@ -1,6 +1,7 @@
1
1
  type ErrorLike = {
2
2
  message?: string;
3
3
  name?: string;
4
+ code?: string;
4
5
  status?: number;
5
6
  statusCode?: number;
6
7
  response?: { status?: number };
@@ -13,6 +14,31 @@ const TRANSIENT_MESSAGE_PATTERN =
13
14
  const VALIDATION_MESSAGE_PATTERN =
14
15
  /invalid|validation|bad request|unsupported|schema|missing required|not found|unauthorized|forbidden/i;
15
16
 
17
+ const BUN_SOCKET_CLOSE_MESSAGE =
18
+ "The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()";
19
+
20
+ /** Recognize transport closures without interpreting arbitrary provider failure text as replay-safe. */
21
+ export function isTransportClosureError(error: unknown, depth = 0): boolean {
22
+ if (!error || typeof error !== "object" || depth > 2) return false;
23
+ const info = error as ErrorLike;
24
+ const message = info.message ?? "";
25
+ const status = extractHttpStatusFromError(error);
26
+ if (
27
+ info.name === "AbortError" ||
28
+ /aborted|cancelled|canceled/i.test(message) ||
29
+ VALIDATION_MESSAGE_PATTERN.test(message) ||
30
+ (status !== undefined && status >= 400 && status < 500)
31
+ )
32
+ return false;
33
+ return (
34
+ message === BUN_SOCKET_CLOSE_MESSAGE ||
35
+ info.code === "ECONNRESET" ||
36
+ info.code === "ERR_STREAM_PREMATURE_CLOSE" ||
37
+ /\b(?:ECONNRESET|ERR_STREAM_PREMATURE_CLOSE)\b/.test(message) ||
38
+ isTransportClosureError(info.cause, depth + 1)
39
+ );
40
+ }
41
+
16
42
  /**
17
43
  * Identify errors that should be retried (timeouts, 5xx, 408, 429, transient network failures).
18
44
  */
@@ -31,7 +57,7 @@ export function isRetryableError(error: unknown): boolean {
31
57
 
32
58
  if (VALIDATION_MESSAGE_PATTERN.test(message)) return false;
33
59
 
34
- return TRANSIENT_MESSAGE_PATTERN.test(message);
60
+ return isTransportClosureError(error) || TRANSIENT_MESSAGE_PATTERN.test(message);
35
61
  }
36
62
 
37
63
  export function extractHttpStatusFromError(error: unknown): number | undefined {