@juspay/neurolink 13.8.6 → 13.8.8

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.
@@ -2,6 +2,7 @@ import { initializeProxyOtelLogs, routeProxyConsoleToOtel, shutdownProxyOtelLogs
2
2
  import { registerCliShutdownOwner } from "../lifecycle.js";
3
3
  import { writeFileAtomic } from "../proxy-clients/snapshot.js";
4
4
  import { spawn } from "node:child_process";
5
+ import { Server as HttpServer } from "node:http";
5
6
  import { homedir } from "node:os";
6
7
  import { dirname, join } from "node:path";
7
8
  import { stripVTControlCharacters } from "node:util";
@@ -67,6 +68,19 @@ const PROXY_TELEMETRY_SCRIPT_PATH = fileURLToPath(new URL("../../../scripts/obse
67
68
  */
68
69
  const gatedShareRequests = new WeakSet();
69
70
  const PROXY_LIFECYCLE_SHUTDOWN_TIMEOUT_MS = 5_000;
71
+ /** Reverse proxies in front of this server (cloudflared keeps idle origin
72
+ * connections for 90s) reuse sockets longer than Node's 5s default. A socket
73
+ * the server closes while the tunnel sends a POST on it surfaces as a 502,
74
+ * because a POST is never retried, so the server must be the side that waits
75
+ * longer. headersTimeout has to exceed it, or Node cuts idle sockets early. */
76
+ const PROXY_SERVER_KEEP_ALIVE_TIMEOUT_MS = 120_000;
77
+ const PROXY_SERVER_HEADERS_TIMEOUT_MS = 125_000;
78
+ function extendProxyServerKeepAlive(server) {
79
+ if (server instanceof HttpServer) {
80
+ server.keepAliveTimeout = PROXY_SERVER_KEEP_ALIVE_TIMEOUT_MS;
81
+ server.headersTimeout = PROXY_SERVER_HEADERS_TIMEOUT_MS;
82
+ }
83
+ }
70
84
  // Allow the normal 30-second worker drain plus startup settlement, leaving
71
85
  // telemetry and scheduling headroom inside launchd's 45-second stop budget.
72
86
  const PROXY_SUPERVISOR_DRAIN_TIMEOUT_MS = 35_000;
@@ -2399,7 +2413,7 @@ function recordBackgroundRefreshFailure(key) {
2399
2413
  });
2400
2414
  }
2401
2415
  async function refreshProxyTokensInBackground(accountAllowlist) {
2402
- const { needsRefresh, refreshToken, persistTokens, isPermanentRefreshFailure, } = await import("../../proxy/tokenRefresh.js");
2416
+ const { needsBackgroundRefresh, refreshToken, persistTokens, isPermanentRefreshFailure, } = await import("../../proxy/tokenRefresh.js");
2403
2417
  const { tokenStore } = await import("../../auth/tokenStore.js");
2404
2418
  let storedAnthropicAccountCount;
2405
2419
  try {
@@ -2422,7 +2436,7 @@ async function refreshProxyTokensInBackground(accountAllowlist) {
2422
2436
  refreshToken: tokens.refreshToken,
2423
2437
  expiresAt: tokens.expiresAt,
2424
2438
  };
2425
- if (!needsRefresh(account)) {
2439
+ if (!needsBackgroundRefresh(account)) {
2426
2440
  backgroundRefreshFailures.delete(key);
2427
2441
  continue;
2428
2442
  }
@@ -2473,7 +2487,7 @@ async function refreshProxyTokensInBackground(accountAllowlist) {
2473
2487
  expiresAt: creds.oauth.expiresAt,
2474
2488
  };
2475
2489
  const legacyKey = LEGACY_ANTHROPIC_ACCOUNT_KEY;
2476
- if (!needsRefresh(account)) {
2490
+ if (!needsBackgroundRefresh(account)) {
2477
2491
  backgroundRefreshFailures.delete(legacyKey);
2478
2492
  return;
2479
2493
  }
@@ -2607,12 +2621,14 @@ function registerProxyShutdownHandlers(params) {
2607
2621
  }
2608
2622
  const usageStatsFlush = import("../../proxy/usageStats.js").then(({ flushUsageStats }) => flushUsageStats());
2609
2623
  const requestLogsFlush = import("../../proxy/requestLogger.js").then(({ flushRequestLogs }) => flushRequestLogs());
2610
- const [usageStatsFlushResult, lifecycleFlushResult, requestLogsFlushResult,] = await Promise.allSettled([
2624
+ const snapshotWritesFlush = import("../../proxy/snapshotPersistence.js").then(({ flushSnapshotWrites }) => flushSnapshotWrites());
2625
+ const [usageStatsFlushResult, lifecycleFlushResult, requestLogsFlushResult, snapshotWritesFlushResult,] = await Promise.allSettled([
2611
2626
  withTimeout(usageStatsFlush, PROXY_LIFECYCLE_SHUTDOWN_TIMEOUT_MS, "Timed out flushing proxy usage statistics during shutdown"),
2612
2627
  withTimeout(flushProxyLifecycleEvents(), PROXY_LIFECYCLE_SHUTDOWN_TIMEOUT_MS, "Timed out flushing proxy lifecycle metadata during shutdown"),
2613
2628
  // This flush owns a separate bounded budget covering the body worker
2614
2629
  // deadline plus index/OTLP publication; do not truncate it to five seconds.
2615
2630
  requestLogsFlush,
2631
+ withTimeout(snapshotWritesFlush, PROXY_LIFECYCLE_SHUTDOWN_TIMEOUT_MS, "Timed out flushing proxy snapshot writes during shutdown"),
2616
2632
  ]);
2617
2633
  if (usageStatsFlushResult.status === "rejected") {
2618
2634
  const error = usageStatsFlushResult.reason;
@@ -2626,6 +2642,10 @@ function registerProxyShutdownHandlers(params) {
2626
2642
  const error = requestLogsFlushResult.reason;
2627
2643
  logger.warn(`[proxy] request log flush failed during shutdown: ${error instanceof Error ? error.message : String(error)}`);
2628
2644
  }
2645
+ if (snapshotWritesFlushResult.status === "rejected") {
2646
+ const error = snapshotWritesFlushResult.reason;
2647
+ logger.warn(`[proxy] snapshot write flush failed during shutdown: ${error instanceof Error ? error.message : String(error)}`);
2648
+ }
2629
2649
  try {
2630
2650
  const { flushOpenTelemetry, shutdownOpenTelemetry } = await import("../../services/server/ai/observability/instrumentation.js");
2631
2651
  await withShutdownDeadline((async () => {
@@ -2678,6 +2698,7 @@ async function startProxyRuntime(params) {
2678
2698
  port: params.port,
2679
2699
  hostname: params.host,
2680
2700
  });
2701
+ extendProxyServerKeepAlive(server);
2681
2702
  // The gate-only listener. It exists only while this node lends something, and
2682
2703
  // it is the port an operator exposes: the main port keeps serving the
2683
2704
  // operator's own untokened client exactly as before.
@@ -2712,6 +2733,7 @@ async function startProxyRuntime(params) {
2712
2733
  listening = true;
2713
2734
  resolve(started);
2714
2735
  });
2736
+ extendProxyServerKeepAlive(started);
2715
2737
  started.on("error", (error) => {
2716
2738
  if (!listening) {
2717
2739
  reject(error);
@@ -11,8 +11,19 @@ import type { ChildProcess } from "child_process";
11
11
  import type { MCPTransportType, MCPServerInfo, MCPClientResult } from "../types/index.js";
12
12
  /**
13
13
  * Synchronous on purpose: it is the one thing that can run inside the
14
- * `exit` event, and SIGTERM is delivered by the kernel even though this
14
+ * `exit` event, and signals are delivered by the kernel even though this
15
15
  * process is already on its way out.
16
+ *
17
+ * A live server is signalled through `child.kill()` whenever its transport
18
+ * still holds the child, for the reason given on `closingStdioChildren`:
19
+ * between a child's exit and its `close` event `transport.pid` still reads the
20
+ * old number, which the kernel may already have handed to another process.
21
+ * `transport.pid` remains only for an SDK that no longer exposes the child.
22
+ *
23
+ * A server whose close() was already under way gets SIGKILL. The SDK had
24
+ * committed to stopping it and would have escalated to SIGKILL within four
25
+ * seconds; the `exit` event cannot wait for that, so a server that ignores
26
+ * SIGTERM would otherwise be left running.
16
27
  */
17
28
  export declare const terminateLiveStdioServers: () => void;
18
29
  /**
@@ -78,29 +78,100 @@ const stderrTails = new WeakMap();
78
78
  * has exited, so a server the manager closed is not signalled again.
79
79
  */
80
80
  const liveStdioTransports = new Set();
81
+ /**
82
+ * Children of transports whose `close()` has started and whose process has
83
+ * not yet closed.
84
+ *
85
+ * `close()` drops its reference to the child before it does anything else, so
86
+ * from then on `transport.pid` reads null, and a transport in that state is
87
+ * invisible to the set above. The close lasts up to four seconds for a server
88
+ * that ignores EOF and SIGTERM (`stdin.end()`, 2s, SIGTERM, 2s, SIGKILL). A
89
+ * host that calls `process.exit()` inside that window never reaches the SDK's
90
+ * escalation, so the exit hook signals the child held here.
91
+ *
92
+ * The child is held as a `ChildProcess` and signalled with `child.kill()`,
93
+ * never as a pid handed to `process.kill()`. Node clears the child's handle in
94
+ * the same callback in which libuv reaps it, and `kill()` signals only through
95
+ * that handle, so it does nothing for a child that has exited. The kernel
96
+ * cannot reuse a pid before the reap, which means this can never reach a
97
+ * process that merely inherited the number. A child leaves this set when it
98
+ * exits or closes, whichever comes first.
99
+ */
100
+ const closingStdioChildren = new Set();
81
101
  let stdioExitHookInstalled = false;
102
+ let stdioChildProcessClass;
103
+ /**
104
+ * The SDK exposes no handle on the child; `pid` and `stderr` are its whole
105
+ * public surface. `_process` is private, so it is read through `Reflect.get`
106
+ * and used only if it is a real `ChildProcess`. An SDK release that renames or
107
+ * reshapes it yields undefined here.
108
+ */
109
+ const stdioChildOf = (transport, childProcessClass) => {
110
+ const candidate = Reflect.get(transport, "_process");
111
+ return candidate instanceof childProcessClass ? candidate : undefined;
112
+ };
82
113
  /**
83
114
  * Synchronous on purpose: it is the one thing that can run inside the
84
- * `exit` event, and SIGTERM is delivered by the kernel even though this
115
+ * `exit` event, and signals are delivered by the kernel even though this
85
116
  * process is already on its way out.
117
+ *
118
+ * A live server is signalled through `child.kill()` whenever its transport
119
+ * still holds the child, for the reason given on `closingStdioChildren`:
120
+ * between a child's exit and its `close` event `transport.pid` still reads the
121
+ * old number, which the kernel may already have handed to another process.
122
+ * `transport.pid` remains only for an SDK that no longer exposes the child.
123
+ *
124
+ * A server whose close() was already under way gets SIGKILL. The SDK had
125
+ * committed to stopping it and would have escalated to SIGKILL within four
126
+ * seconds; the `exit` event cannot wait for that, so a server that ignores
127
+ * SIGTERM would otherwise be left running.
86
128
  */
87
129
  export const terminateLiveStdioServers = () => {
88
130
  for (const transport of liveStdioTransports) {
89
- if ("pid" in transport && typeof transport.pid === "number") {
90
- try {
91
- process.kill(transport.pid, "SIGTERM");
131
+ const child = stdioChildProcessClass && stdioChildOf(transport, stdioChildProcessClass);
132
+ try {
133
+ if (child) {
134
+ child.kill("SIGTERM");
92
135
  }
93
- catch {
94
- // Already gone.
136
+ else if ("pid" in transport && typeof transport.pid === "number") {
137
+ process.kill(transport.pid, "SIGTERM");
95
138
  }
96
139
  }
140
+ catch {
141
+ // Already gone.
142
+ }
143
+ }
144
+ for (const child of closingStdioChildren) {
145
+ try {
146
+ child.kill("SIGKILL");
147
+ }
148
+ catch {
149
+ // Already gone.
150
+ }
97
151
  }
98
152
  };
99
- const trackStdioTransport = (transport) => {
153
+ /**
154
+ * Without the child, an SDK release that hides it leaves the close window as
155
+ * open as it was before this set existed.
156
+ */
157
+ const holdChildWhileClosing = (transport, childProcessClass) => {
158
+ const child = stdioChildOf(transport, childProcessClass);
159
+ if (!child || child.exitCode !== null || child.signalCode !== null) {
160
+ return;
161
+ }
162
+ closingStdioChildren.add(child);
163
+ const release = () => {
164
+ closingStdioChildren.delete(child);
165
+ };
166
+ child.once("exit", release);
167
+ child.once("close", release);
168
+ };
169
+ const trackStdioTransport = (transport, childProcessClass) => {
100
170
  if (!stdioExitHookInstalled) {
101
171
  stdioExitHookInstalled = true;
102
172
  process.on("exit", terminateLiveStdioServers);
103
173
  }
174
+ stdioChildProcessClass = childProcessClass;
104
175
  liveStdioTransports.add(transport);
105
176
  // Client.connect() chains an onclose that is already set rather than
106
177
  // replacing it, and the manager listens on client.onclose, so this one
@@ -109,6 +180,13 @@ const trackStdioTransport = (transport) => {
109
180
  transport.onclose = () => {
110
181
  liveStdioTransports.delete(transport);
111
182
  };
183
+ // The child has to be read before close() starts, which is the last moment
184
+ // the transport still has it.
185
+ const closeTransport = transport.close.bind(transport);
186
+ transport.close = async () => {
187
+ holdChildWhileClosing(transport, childProcessClass);
188
+ await closeTransport();
189
+ };
112
190
  };
113
191
  /**
114
192
  * MCPClientFactory
@@ -354,6 +432,7 @@ export class MCPClientFactory {
354
432
  throw new Error(`Command is required for stdio transport`);
355
433
  }
356
434
  const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
435
+ const { ChildProcess: childProcessClass } = await import("node:child_process");
357
436
  const transport = new StdioClientTransport({
358
437
  command: config.command,
359
438
  args: config.args || [],
@@ -366,7 +445,7 @@ export class MCPClientFactory {
366
445
  cwd: config.cwd,
367
446
  stderr: "pipe",
368
447
  });
369
- trackStdioTransport(transport);
448
+ trackStdioTransport(transport, childProcessClass);
370
449
  const tail = new StderrTail();
371
450
  stderrTails.set(transport, tail);
372
451
  transport.stderr?.on("data", (chunk) => {
@@ -10,7 +10,7 @@ import { DEFAULT_MAX_STEPS } from "../../core/constants.js";
10
10
  import { streamAnalyticsCollector } from "../../core/streamAnalytics.js";
11
11
  import { getModelCapabilities, getRecommendedModelForTier, isModelAvailableForTier, } from "../../models/anthropicModels.js";
12
12
  import { createOAuthFetch } from "../../proxy/oauthFetch.js";
13
- import { createProxyFetch } from "../../proxy/proxyFetch.js";
13
+ import { createProxyFetch, fetchProxyAware } from "../../proxy/proxyFetch.js";
14
14
  import { getCapturedLimitSnapshot, getCapturedResponseHeaders, logClaudeLimitSnapshot, runInLimitCaptureScope, setLimitSpanAttributes, withLimitCapture, wrapFetchWithLimitCapture, } from "./rateLimitCapture.js";
15
15
  import { AuthenticationError, NetworkError, ProviderError, RateLimitError, } from "../../types/index.js";
16
16
  import { classifyProviderError, messageNamesStatus, } from "../../utils/errorClassifier.js";
@@ -886,7 +886,7 @@ export class AnthropicProvider extends BaseProvider {
886
886
  // cosmetic choice. If Anthropic ever publishes a separate UA for
887
887
  // third-party OAuth clients, switch to that. See `auth/anthropicOAuth.ts`
888
888
  // for the source of `CLAUDE_CLI_USER_AGENT` / `CLAUDE_CODE_CLIENT_ID`.
889
- const response = await fetch(ANTHROPIC_TOKEN_URL, {
889
+ const response = await fetchProxyAware(ANTHROPIC_TOKEN_URL, {
890
890
  method: "POST",
891
891
  headers: {
892
892
  "Content-Type": "application/x-www-form-urlencoded",
@@ -19,6 +19,7 @@ import { tokenStore } from "../auth/tokenStore.js";
19
19
  import { CLAUDE_CLI_USER_AGENT, OAUTH_BETA_HEADERS, } from "../auth/anthropicOAuth.js";
20
20
  import { needsRefresh, refreshTokenFromLatest, isPermanentRefreshFailure, persistTokens, } from "./tokenRefresh.js";
21
21
  import { isAccountAllowed } from "./accountSelection.js";
22
+ import { fetchProxyAware } from "./proxyFetch.js";
22
23
  export const ANTHROPIC_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
23
24
  const USAGE_FETCH_TIMEOUT_MS = 10_000;
24
25
  /**
@@ -57,7 +58,7 @@ export async function listAnthropicAccountsForUsage(allowlist) {
57
58
  }
58
59
  async function requestUsage(token) {
59
60
  try {
60
- const response = await fetch(ANTHROPIC_USAGE_URL, {
61
+ const response = await fetchProxyAware(ANTHROPIC_USAGE_URL, {
61
62
  method: "GET",
62
63
  headers: {
63
64
  authorization: `Bearer ${token}`,
@@ -10,6 +10,7 @@ import { tokenStore } from "../auth/tokenStore.js";
10
10
  import { normalizeAnthropicAccountKey } from "./accountSelection.js";
11
11
  import { CODEX_USAGE_URL, CODEX_USER_AGENT, decodeCodexAccessToken, resolveCodexAccountId, } from "../auth/codexOAuth.js";
12
12
  import { logger } from "../utils/logger.js";
13
+ import { fetchProxyAware } from "./proxyFetch.js";
13
14
  export const CODEX_ACCOUNT_PREFIX = "codex:";
14
15
  /**
15
16
  * Native fallback legs (e.g. Claude-on-Vertex) key their usage rows as
@@ -207,7 +208,7 @@ export async function fetchCodexAccountUsage(account, options = {}) {
207
208
  }
208
209
  const accountId = resolveCodexAccountId(account.token);
209
210
  try {
210
- const response = await fetch(CODEX_USAGE_URL, {
211
+ const response = await fetchProxyAware(CODEX_USAGE_URL, {
211
212
  method: "GET",
212
213
  headers: {
213
214
  Authorization: `Bearer ${account.token}`,
@@ -12,6 +12,12 @@ export declare class ProviderTransportCoordinator {
12
12
  acquire(signal?: AbortSignal): Promise<ProxyProviderTransportPermit>;
13
13
  reportSuccess(permit: ProxyProviderTransportPermit): void;
14
14
  reportTransportFailure(errorCode: string | undefined, transportScope: "shared_provider_transport" | "connection_transport", permit: ProxyProviderTransportPermit, connectPhase?: boolean): void;
15
+ /**
16
+ * A connect to the provider just completed. That is the transport recovering,
17
+ * and it is known long before a probe's response headers, which for a
18
+ * non-streaming request arrive only after the whole generation.
19
+ */
20
+ reportConnectionEstablished(): void;
15
21
  reportProbeAbandoned(permit: ProxyProviderTransportPermit): void;
16
22
  clear(): void;
17
23
  private wait;
@@ -1,6 +1,9 @@
1
1
  import { TimeoutError, withTimeout } from "../utils/async/withTimeout.js";
2
2
  const SHARED_TRANSPORT_BACKOFF_MS = 250;
3
- const RECOVERY_PROBE_WAIT_TIMEOUT_MS = 30_000;
3
+ /** Upper bound on how long a request parks behind someone else's probe. A
4
+ * waiter that times out gets its own bounded retry, so this only caps the
5
+ * stall a healthy request can inherit from an unrelated slow one. */
6
+ const RECOVERY_PROBE_WAIT_TIMEOUT_MS = 3_000;
4
7
  export class ProviderTransportCoordinator {
5
8
  generation = 0;
6
9
  degraded = false;
@@ -72,6 +75,17 @@ export class ProviderTransportCoordinator {
72
75
  if (permit.probe && this.probe?.generation !== permit.generation) {
73
76
  return;
74
77
  }
78
+ if (transportScope === "connection_transport") {
79
+ // One socket failing (a lost SYN, a reset) says nothing about the
80
+ // provider. The failing request already retries on its own budget, so
81
+ // parking every other request behind a probe only spreads the stall.
82
+ if (permit.probe && this.probe?.generation === permit.generation) {
83
+ this.probe.resolve("abandoned");
84
+ this.probe = undefined;
85
+ this.generation += 1;
86
+ }
87
+ return;
88
+ }
75
89
  this.degraded = true;
76
90
  this.backoffUntil = Date.now() + SHARED_TRANSPORT_BACKOFF_MS;
77
91
  this.lastErrorCode = errorCode ?? null;
@@ -83,6 +97,25 @@ export class ProviderTransportCoordinator {
83
97
  }
84
98
  this.generation += 1;
85
99
  }
100
+ /**
101
+ * A connect to the provider just completed. That is the transport recovering,
102
+ * and it is known long before a probe's response headers, which for a
103
+ * non-streaming request arrive only after the whole generation.
104
+ */
105
+ reportConnectionEstablished() {
106
+ if (!this.degraded) {
107
+ return;
108
+ }
109
+ this.degraded = false;
110
+ this.backoffUntil = 0;
111
+ this.lastErrorCode = null;
112
+ this.lastTransportScope = "shared_provider_transport";
113
+ this.lastFailureConnectPhase = false;
114
+ if (this.probe) {
115
+ this.probe.resolve("recovered");
116
+ this.probe = undefined;
117
+ }
118
+ }
86
119
  reportProbeAbandoned(permit) {
87
120
  if (!permit.allowed ||
88
121
  !permit.probe ||
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Tuned global undici dispatcher for the proxy's upstream forwards.
3
3
  *
4
- * The Claude passthrough (`claudeProxyRoutes.ts`) forwards every request to
5
- * Anthropic via the global `fetch` → undici global dispatcher. undici keep-alives
4
+ * Direct proxy-command fetches use the global `fetch` → undici global
5
+ * dispatcher. Undici keeps connections alive
6
6
  * by default, but with a ~4s idle timeout: between Claude Code turns (the user
7
7
  * reads output, a tool runs, the model thinks) the idle socket closes, so the
8
8
  * next request opens a brand-new TCP connection. Under sustained use that is a
@@ -3,8 +3,8 @@
3
3
  * Supports HTTP/HTTPS, SOCKS4/5, authentication, and NO_PROXY bypass
4
4
  * Lightweight implementation extracted from research of major proxy packages
5
5
  */
6
- import type { ProxyAgent } from "undici";
7
- import type { GoogleGenAIHttpOptions } from "../types/index.js";
6
+ import type { Dispatcher, ProxyAgent } from "undici";
7
+ import type { GoogleGenAIHttpOptions, ProxyTimeouts } from "../types/index.js";
8
8
  /**
9
9
  * Classify a fetch failure as a transient network error worth retrying.
10
10
  *
@@ -32,7 +32,13 @@ export declare function isTransientNetworkError(error: unknown): boolean;
32
32
  * under proxy rules) gets no fallback to a direct connection from this: a proxy
33
33
  * that cannot be used (an unsupported SOCKS URL, an unparsable URL) throws.
34
34
  */
35
- export declare function getProxyDispatcherForUrl(targetUrl: string): Promise<ProxyAgent | null>;
35
+ export declare function getProxyDispatcherForUrl(targetUrl: string, timeouts?: ProxyTimeouts): Promise<ProxyAgent | null>;
36
+ /**
37
+ * Fetch for the proxy command's own outbound requests. A configured proxy is
38
+ * mandatory for its target: a failed proxy connection must never retry direct.
39
+ * The optional dispatcher preserves callers' direct-connection tuning.
40
+ */
41
+ export declare function fetchProxyAware(url: string | URL, init?: RequestInit, directDispatcher?: Dispatcher, proxyTimeouts?: ProxyTimeouts): Promise<Response>;
36
42
  /**
37
43
  * Create a proxy-aware fetch function with enhanced capabilities
38
44
  * Supports HTTP/HTTPS, SOCKS4/5, authentication, and NO_PROXY bypass
@@ -331,7 +331,7 @@ function selectProxyUrl(targetUrl) {
331
331
  /**
332
332
  * Create appropriate proxy agent based on protocol
333
333
  */
334
- async function createProxyAgent(proxyUrl) {
334
+ async function createProxyAgent(proxyUrl, timeouts = {}) {
335
335
  const parsed = parseProxyUrl(proxyUrl);
336
336
  logger.debug("[Proxy] Creating proxy agent", {
337
337
  protocol: parsed.protocol,
@@ -344,7 +344,7 @@ async function createProxyAgent(proxyUrl) {
344
344
  case "https:": {
345
345
  // Use existing undici ProxyAgent for HTTP/HTTPS
346
346
  const { ProxyAgent } = await import("undici");
347
- return new ProxyAgent(proxyUrl);
347
+ return new ProxyAgent({ uri: proxyUrl, ...timeouts });
348
348
  }
349
349
  case "socks4:":
350
350
  case "socks5:": {
@@ -358,19 +358,20 @@ async function createProxyAgent(proxyUrl) {
358
358
  }
359
359
  /**
360
360
  * One ProxyAgent per proxy URL for the whole process, so connections to the
361
- * proxy are pooled and an agent is not rebuilt per request. The cache key is
362
- * a hash of the masked URL: credentials never sit in the map.
361
+ * proxy are pooled and an agent is not rebuilt per request. Hashing the full
362
+ * URL keeps credentials out of map keys without mixing distinct proxy logins.
363
363
  */
364
- async function getOrCreateProxyAgent(proxyUrl) {
364
+ async function getOrCreateProxyAgent(proxyUrl, timeouts = {}) {
365
365
  const globalWithCache = globalThis;
366
366
  if (!globalWithCache.__NL_PROXY_AGENT_CACHE__) {
367
367
  globalWithCache.__NL_PROXY_AGENT_CACHE__ = new Map();
368
368
  }
369
369
  const agentCache = globalWithCache.__NL_PROXY_AGENT_CACHE__;
370
370
  const cacheKey = createHash("sha256")
371
- .update(maskProxyUrl(proxyUrl) ?? proxyUrl)
371
+ .update(proxyUrl)
372
+ .update(JSON.stringify(timeouts))
372
373
  .digest("hex");
373
- const dispatcher = agentCache.get(cacheKey) || (await createProxyAgent(proxyUrl));
374
+ const dispatcher = agentCache.get(cacheKey) || (await createProxyAgent(proxyUrl, timeouts));
374
375
  agentCache.set(cacheKey, dispatcher);
375
376
  return dispatcher;
376
377
  }
@@ -384,9 +385,30 @@ async function getOrCreateProxyAgent(proxyUrl) {
384
385
  * under proxy rules) gets no fallback to a direct connection from this: a proxy
385
386
  * that cannot be used (an unsupported SOCKS URL, an unparsable URL) throws.
386
387
  */
387
- export async function getProxyDispatcherForUrl(targetUrl) {
388
+ export async function getProxyDispatcherForUrl(targetUrl, timeouts = {}) {
388
389
  const proxyUrl = selectProxyUrl(targetUrl);
389
- return proxyUrl ? getOrCreateProxyAgent(proxyUrl) : null;
390
+ return proxyUrl ? getOrCreateProxyAgent(proxyUrl, timeouts) : null;
391
+ }
392
+ /**
393
+ * Fetch for the proxy command's own outbound requests. A configured proxy is
394
+ * mandatory for its target: a failed proxy connection must never retry direct.
395
+ * The optional dispatcher preserves callers' direct-connection tuning.
396
+ */
397
+ export async function fetchProxyAware(url, init, directDispatcher, proxyTimeouts) {
398
+ const proxyDispatcher = await getProxyDispatcherForUrl(url.toString(), proxyTimeouts);
399
+ if (!proxyDispatcher) {
400
+ return fetch(url, directDispatcher
401
+ ? { ...init, dispatcher: directDispatcher }
402
+ : init);
403
+ }
404
+ const { fetch: undiciFetch } = await import("undici");
405
+ // Undici and lib.dom describe the same runtime Response with different
406
+ // iterator types.
407
+ const response = (await undiciFetch(url, {
408
+ ...init,
409
+ dispatcher: proxyDispatcher,
410
+ }));
411
+ return response;
390
412
  }
391
413
  function sanitizeProxyUrl(url) {
392
414
  return maskProxyUrl(url) ?? "NOT_SET";
@@ -11,7 +11,7 @@ export declare function inferClaudeProxyModelTier(modelName: string): ClaudeProx
11
11
  export declare function buildProxyTranslationPlan(primary: {
12
12
  provider: string;
13
13
  model?: string;
14
- }, fallbackChain: FallbackEntry[], requestedModel: string, _parsed: ParsedClaudeRequest, allowAutoFallback?: boolean): ProxyTranslationPlan;
14
+ }, fallbackChain: FallbackEntry[], requestedModel: string, _parsed?: ParsedClaudeRequest, allowAutoFallback?: boolean): ProxyTranslationPlan;
15
15
  /**
16
16
  * Parse the retry-after header from an upstream 429 response.
17
17
  * Returns milliseconds to wait, or 0 if no valid header present.
@@ -1,2 +1,8 @@
1
1
  export declare function writeJsonSnapshotAtomically(targetPath: string, data: unknown, mode?: number): Promise<void>;
2
+ /** Settles every queued snapshot write. Shutdown awaits this so a write a
3
+ * request left running in the background still lands before exit. It drains
4
+ * until the queue stays empty, because a producer that outlived the listener
5
+ * close can queue another write while earlier ones settle; the caller bounds
6
+ * the whole drain with its own timeout. */
7
+ export declare function flushSnapshotWrites(): Promise<void>;
2
8
  export declare function clearSnapshotWriteLocksForTests(): void;
@@ -35,6 +35,16 @@ export async function writeJsonSnapshotAtomically(targetPath, data, mode = 0o600
35
35
  }
36
36
  }
37
37
  }
38
+ /** Settles every queued snapshot write. Shutdown awaits this so a write a
39
+ * request left running in the background still lands before exit. It drains
40
+ * until the queue stays empty, because a producer that outlived the listener
41
+ * close can queue another write while earlier ones settle; the caller bounds
42
+ * the whole drain with its own timeout. */
43
+ export async function flushSnapshotWrites() {
44
+ while (writeLocks.size > 0) {
45
+ await Promise.allSettled([...writeLocks.values()]);
46
+ }
47
+ }
38
48
  export function clearSnapshotWriteLocksForTests() {
39
49
  writeLocks.clear();
40
50
  }
@@ -1,5 +1,7 @@
1
1
  import type { RefreshableAccount, RefreshResult, TokenPersistTarget } from "../types/index.js";
2
2
  export declare function needsRefresh(account: RefreshableAccount): boolean;
3
+ export declare function needsBackgroundRefresh(account: RefreshableAccount): boolean;
4
+ export declare function needsInlineRefresh(account: RefreshableAccount): boolean;
3
5
  export declare function isPermanentRefreshFailure(result: RefreshResult): boolean;
4
6
  /**
5
7
  * Serialize refreshes by refresh-token value. OAuth refresh tokens may rotate,
@@ -4,17 +4,33 @@ import { ANTHROPIC_TOKEN_URL, ANTHROPIC_TOKEN_URL_FALLBACK, CLAUDE_CLI_USER_AGEN
4
4
  import { writeJsonSnapshotAtomically } from "./snapshotPersistence.js";
5
5
  import { withTimeout } from "../utils/async/withTimeout.js";
6
6
  import { redactUrlForError } from "../utils/logSanitize.js";
7
+ import { fetchProxyAware } from "./proxyFetch.js";
7
8
  const BUFFER_MS = 5 * 60 * 1000;
9
+ /** The background sweep runs every 30s and refreshes this far ahead, so a
10
+ * token is normally renewed long before a live request would have to. */
11
+ const BACKGROUND_REFRESH_BUFFER_MS = 10 * 60 * 1000;
12
+ /** A request refreshes inline only this close to expiry, i.e. when the sweep
13
+ * has not managed to, so the refresh round trip stays off the hot path. */
14
+ const INLINE_REFRESH_BUFFER_MS = 2 * 60 * 1000;
8
15
  const SUCCESS_CACHE_MS = 60_000;
9
16
  // Bound the best-effort persisted-credential reconciliation read so a hung or
10
17
  // slow token-store decrypt can never stall (or abort) a live token refresh.
11
18
  const PEEK_TOKENS_TIMEOUT_MS = 2_000;
12
19
  const refreshesInFlight = new Map();
13
- export function needsRefresh(account) {
20
+ function expiresWithin(account, bufferMs) {
14
21
  return !!(account.expiresAt &&
15
- account.expiresAt <= Date.now() + BUFFER_MS &&
22
+ account.expiresAt <= Date.now() + bufferMs &&
16
23
  account.refreshToken);
17
24
  }
25
+ export function needsRefresh(account) {
26
+ return expiresWithin(account, BUFFER_MS);
27
+ }
28
+ export function needsBackgroundRefresh(account) {
29
+ return expiresWithin(account, BACKGROUND_REFRESH_BUFFER_MS);
30
+ }
31
+ export function needsInlineRefresh(account) {
32
+ return expiresWithin(account, INLINE_REFRESH_BUFFER_MS);
33
+ }
18
34
  function isRefreshCredentialRejection(status) {
19
35
  return status === 400 || status === 401 || status === 403 || status === 404;
20
36
  }
@@ -43,7 +59,7 @@ async function performTokenRefresh(account) {
43
59
  let terminalFailure;
44
60
  for (const url of urls) {
45
61
  try {
46
- const resp = await fetch(url, {
62
+ const resp = await fetchProxyAware(url, {
47
63
  method: "POST",
48
64
  headers,
49
65
  body: requestBody,
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { logger } from "../utils/logger.js";
16
16
  import { claudeDisabledThinkingReplacement, modelSupportsForcedToolChoice, } from "../models/modelRegistry.js";
17
- import { isTransientNetworkError } from "./proxyFetch.js";
17
+ import { fetchProxyAware, isTransientNetworkError } from "./proxyFetch.js";
18
18
  import { readCacheCreation1hTokens } from "./proxyTokenUsage.js";
19
19
  /** Vertex pins the Anthropic API version in the body, not a header. */
20
20
  const VERTEX_ANTHROPIC_VERSION = "vertex-2023-10-16";
@@ -552,7 +552,7 @@ export async function dispatchVertexAnthropicPassthrough(request) {
552
552
  model: request.model,
553
553
  stream,
554
554
  });
555
- return fetch(url, {
555
+ return fetchProxyAware(url, {
556
556
  method: "POST",
557
557
  headers: {
558
558
  authorization: `Bearer ${token}`,