@juspay/neurolink 9.92.2 → 9.93.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.
@@ -53,11 +53,6 @@ const BLOCKED_UPSTREAM_HEADERS = new Set([
53
53
  let primaryAccountIndex = 0;
54
54
  /** Track account count so we can reset primaryAccountIndex when it changes. */
55
55
  let lastKnownAccountCount = 0;
56
- /** Stable account key used by legacy fixed-config route factories. Runtime
57
- * config providers pass the request generation's primary key explicitly.
58
- * When undefined, home semantics retain insertion-order behavior. */
59
- let configuredPrimaryAccountKey;
60
- let configuredAccountAllowlist;
61
56
  const MAX_AUTH_RETRIES = 5;
62
57
  const MAX_TRANSIENT_SAME_ACCOUNT_RETRIES = 2;
63
58
  const TRANSIENT_SAME_ACCOUNT_RETRY_DELAYS_MS = [250, 1_000];
@@ -129,7 +124,7 @@ function advancePrimaryIfCurrent(accountKey, enabledCount, primaryAccountKey) {
129
124
  * no key is configured or the key cannot be matched (account disabled/
130
125
  * removed). The resolution is per-request because enabledAccounts membership
131
126
  * can shift between requests. */
132
- function resolveHomeIndex(enabledAccounts, primaryAccountKey = configuredPrimaryAccountKey) {
127
+ function resolveHomeIndex(enabledAccounts, primaryAccountKey) {
133
128
  if (!primaryAccountKey) {
134
129
  return 0;
135
130
  }
@@ -140,7 +135,7 @@ function resolveHomeIndex(enabledAccounts, primaryAccountKey = configuredPrimary
140
135
  * primaryAccountIndex back to its index so traffic returns to the preferred
141
136
  * account once its rate limit window expires. Called at the start of each
142
137
  * request. Home is resolved fresh per call via resolveHomeIndex. */
143
- function maybeResetPrimaryToHome(enabledAccounts, primaryAccountKey = configuredPrimaryAccountKey) {
138
+ function maybeResetPrimaryToHome(enabledAccounts, primaryAccountKey) {
144
139
  if (enabledAccounts.length <= 1) {
145
140
  return;
146
141
  }
@@ -463,28 +458,30 @@ function accountSortMetrics(accountKey, now, sessionSoftLimit, sessionResetToler
463
458
  }
464
459
  /**
465
460
  * Order accounts to MAXIMIZE quota utilization (fill-first, smart order):
466
- * spend the window that expires SOONEST first, so its about-to-reset
467
- * allowance isn't wasted, then move to accounts with longer-dated resets.
461
+ * spend the overall weekly allowance that expires SOONEST first, so quota
462
+ * cannot disappear while traffic is consuming a newer weekly window. The 5h
463
+ * window remains an availability boundary: a session at its soft limit is
464
+ * temporarily demoted until that session resets.
468
465
  *
469
466
  * Priority among usable accounts:
470
467
  * 1. no quota data yet — probe first: one request reveals its windows and
471
468
  * self-corrects the ordering. (Ranking unknowns last would starve them
472
469
  * forever: never picked → never observed → never comparable.)
473
470
  * 2. session headroom before session-saturated (>= soft limit or
474
- * "throttled") — saturated accounts then follow the same bucketed
475
- * session ordering below: soonest back in service first, weekly
476
- * deciding same-bucket ties.
477
- * 3. soonest SESSION (5h) reset — the capacity expiring soonest; compared
478
- * in tolerance buckets so near-simultaneous resets count as equal.
479
- * 4. soonest WEEKLY (7d) reset — decides between same-bucket sessions.
480
- * 5. highest weekly utilization — finish off the one closest to done
471
+ * "throttled") — do not re-hammer an urgent weekly account while its 5h
472
+ * capacity is temporarily unavailable.
473
+ * 3. soonest WEEKLY (7d) reset — consume the oldest overall allowance
474
+ * before it expires.
475
+ * 4. soonest SESSION (5h) reset — decides equal-weekly or fresh-weekly ties,
476
+ * using tolerance buckets for comparator stability.
477
+ * 5. highest weekly utilization — finish off the one closest to done.
481
478
  * 6. configured primary account, then insertion order
482
- * An account with NO ticking session window (fresh, not yet started) has
483
- * nothing expiring and sorts after ticking windows in step 3.
479
+ * Saturated pairs are ordered by soonest 5h recovery first, then weekly reset,
480
+ * because neither can consume more quota until session capacity returns.
484
481
  * Cooling/rejected accounts sort last, soonest-back-to-service first, as
485
482
  * last resort.
486
483
  */
487
- function orderAccountsByQuota(accounts, now, primaryKey = configuredPrimaryAccountKey, sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs()) {
484
+ function orderAccountsByQuota(accounts, now, primaryKey, sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs()) {
488
485
  const metrics = (key) => accountSortMetrics(key, now, sessionSoftLimit, sessionResetToleranceMs);
489
486
  return [...accounts].sort((a, b) => {
490
487
  const ma = metrics(a.key);
@@ -503,14 +500,21 @@ function orderAccountsByQuota(accounts, now, primaryKey = configuredPrimaryAccou
503
500
  if (ma.saturated !== mb.saturated) {
504
501
  return ma.saturated ? 1 : -1;
505
502
  }
506
- // Saturated pairs fall through to the same bucketed session ordering:
507
- // soonest-back-in-service at bucket granularity, weekly deciding ties —
508
- // honoring the configured tolerance instead of exact-epoch comparison.
509
- if (ma.sessionResetBucket !== mb.sessionResetBucket) {
510
- return ma.sessionResetBucket - mb.sessionResetBucket;
503
+ if (ma.saturated && mb.saturated) {
504
+ if (ma.sessionResetBucket !== mb.sessionResetBucket) {
505
+ return ma.sessionResetBucket - mb.sessionResetBucket;
506
+ }
507
+ if (ma.weeklyReset !== mb.weeklyReset) {
508
+ return ma.weeklyReset - mb.weeklyReset;
509
+ }
511
510
  }
512
- if (ma.weeklyReset !== mb.weeklyReset) {
513
- return ma.weeklyReset - mb.weeklyReset;
511
+ else {
512
+ if (ma.weeklyReset !== mb.weeklyReset) {
513
+ return ma.weeklyReset - mb.weeklyReset;
514
+ }
515
+ if (ma.sessionResetBucket !== mb.sessionResetBucket) {
516
+ return ma.sessionResetBucket - mb.sessionResetBucket;
517
+ }
514
518
  }
515
519
  if (ma.weeklyUsed !== mb.weeklyUsed) {
516
520
  return mb.weeklyUsed - ma.weeklyUsed;
@@ -1538,7 +1542,7 @@ async function handleClaudePassthroughJsonResponse(args) {
1538
1542
  return responseJson;
1539
1543
  }
1540
1544
  async function loadClaudeProxyAccounts(args) {
1541
- const { ctx, body, tracer, requestStartTime, accountStrategy, primaryAccountKey = configuredPrimaryAccountKey, accountAllowlist = configuredAccountAllowlist, quotaRoutingEnabled = isQuotaRoutingEnabled(), sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), buildLoggedClaudeError, } = args;
1545
+ const { ctx, body, tracer, requestStartTime, accountStrategy, primaryAccountKey, accountAllowlist, quotaRoutingEnabled = isQuotaRoutingEnabled(), sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), buildLoggedClaudeError, } = args;
1542
1546
  const fs = await import("fs");
1543
1547
  const os = await import("os");
1544
1548
  const accounts = [];
@@ -1722,10 +1726,16 @@ async function loadClaudeProxyAccounts(args) {
1722
1726
  const quotaOrdered = accountStrategy === "fill-first" &&
1723
1727
  orderedAccounts.length > 1 &&
1724
1728
  quotaRoutingEnabled;
1729
+ if (!quotaOrdered && accountStrategy === "fill-first") {
1730
+ // Apply the request-scoped home before deriving this request's order. A
1731
+ // hot-reloaded primary change must not lag by one request. Round-robin
1732
+ // deliberately skips this reset so its rotating index remains strict.
1733
+ maybeResetPrimaryToHome(enabledAccounts, primaryAccountKey);
1734
+ }
1725
1735
  if (quotaOrdered) {
1726
- // Fill-first with a smart fill order: spend the account whose window resets
1727
- // soonest first (max utilization), proactively skipping any whose window is
1728
- // rejected until its reset. Supersedes the static home/primary index.
1736
+ // Fill-first with a smart fill order: spend the account whose weekly window
1737
+ // expires soonest while temporarily demoting sessions without headroom.
1738
+ // Supersedes the static home/primary index.
1729
1739
  orderedAccounts = orderAccountsByQuota(enabledAccounts, Date.now(), primaryAccountKey, sessionSoftLimit, sessionResetToleranceMs);
1730
1740
  if (logger.shouldLog("debug")) {
1731
1741
  logger.debug(`[proxy] quota-ordered fill sequence: ${orderedAccounts
@@ -3052,7 +3062,10 @@ async function handleAnthropicAuthRetry(args) {
3052
3062
  : String(retryFetchErr);
3053
3063
  authRetryError = `network error on retry ${authRetry + 1}: ${message}`;
3054
3064
  currentLastError = authRetryError;
3055
- retryLogAttempt(502, "network_error", message, { retryable: true });
3065
+ retryLogAttempt(502, "network_error", message, {
3066
+ retryable: isRetryableNetworkError(retryFetchErr),
3067
+ errorCode: getErrorCode(retryFetchErr) ?? "unknown",
3068
+ });
3056
3069
  logger.debug(`[proxy] ${authRetryError}`);
3057
3070
  break;
3058
3071
  }
@@ -3427,6 +3440,7 @@ function createAnthropicAttemptLogger(args) {
3427
3440
  responseTimeMs: Date.now() - requestStart,
3428
3441
  ...(errorType ? { errorType } : {}),
3429
3442
  ...(errorMessage ? { errorMessage } : {}),
3443
+ ...(extra?.errorCode ? { errorCode: extra.errorCode } : {}),
3430
3444
  ...(extra?.inputTokens !== undefined
3431
3445
  ? { inputTokens: extra.inputTokens }
3432
3446
  : {}),
@@ -3641,19 +3655,27 @@ async function fetchAnthropicAccountResponse(args) {
3641
3655
  });
3642
3656
  }
3643
3657
  catch (fetchErr) {
3644
- if (!isRetryableNetworkError(fetchErr)) {
3645
- throw fetchErr;
3646
- }
3658
+ const retryable = isRetryableNetworkError(fetchErr);
3659
+ // Every dispatched upstream request is an attempt, including terminal
3660
+ // transport failures. Record it once before preserving the throw behavior.
3647
3661
  sawNetworkError = true;
3648
3662
  recordAttemptError(account.label, account.type, 502);
3649
3663
  const errorCode = getErrorCode(fetchErr) ?? "unknown";
3650
3664
  const errorMessage = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
3651
3665
  lastError = errorMessage;
3652
- logger.always(`[proxy] fetch error account=${account.label} code=${errorCode} (retryable): ${errorMessage}`);
3653
- logAttempt(502, "network_error", errorMessage);
3666
+ logger.always(`[proxy] fetch error account=${account.label} code=${errorCode} (${retryable ? "retryable" : "terminal"}): ${errorMessage}`);
3667
+ logAttempt(502, "network_error", errorMessage, {
3668
+ retryable,
3669
+ errorCode,
3670
+ });
3654
3671
  tracer?.setError("network_error", errorMessage);
3655
- tracer?.recordRetry(account.label, "network_error");
3672
+ if (retryable) {
3673
+ tracer?.recordRetry(account.label, "network_error");
3674
+ }
3656
3675
  currentUpstreamSpan?.end();
3676
+ if (!retryable) {
3677
+ throw fetchErr;
3678
+ }
3657
3679
  return {
3658
3680
  continueLoop: true,
3659
3681
  retrySameAccount: true,
@@ -3791,15 +3813,6 @@ async function handleAnthropicRoutedClaudeRequest(args) {
3791
3813
  attemptNumber: 0,
3792
3814
  };
3793
3815
  const acctSelectionSpan = tracer?.startAccountSelection();
3794
- // Try to return to the home primary account if its cooling has expired.
3795
- // Skipped under quota routing, where the fill order is derived per-request
3796
- // from live quota (soonest-reset-first) rather than a static home index.
3797
- const usingQuotaOrder = accountStrategy === "fill-first" &&
3798
- enabledAccounts.length > 1 &&
3799
- quotaRoutingEnabled;
3800
- if (!usingQuotaOrder) {
3801
- maybeResetPrimaryToHome(enabledAccounts, primaryAccountKey);
3802
- }
3803
3816
  // Never re-hammer accounts with a known active cooldown. When every account
3804
3817
  // is cooling, report the earliest persisted retry time without an upstream
3805
3818
  // call; restarting the proxy must not erase or bypass this quarantine.
@@ -4174,10 +4187,6 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
4174
4187
  const runtimeConfigProvider = isClaudeProxyRouteRuntimeOptions(accountAllowlistOrRuntimeOptions)
4175
4188
  ? accountAllowlistOrRuntimeOptions.runtimeConfigProvider
4176
4189
  : undefined;
4177
- configuredPrimaryAccountKey = primaryAccountKey;
4178
- configuredAccountAllowlist = accountAllowlist
4179
- ? new Set(accountAllowlist)
4180
- : undefined;
4181
4190
  return {
4182
4191
  prefix: `${basePath}/v1`,
4183
4192
  routes: [
@@ -4472,16 +4481,13 @@ function describeTransportError(error) {
4472
4481
  */
4473
4482
  function isRetryableNetworkError(error) {
4474
4483
  const code = getErrorCode(error);
4475
- // Check non-retryable codes FIRST — before the string-based heuristic
4476
- // which could false-positive on error messages containing these strings.
4477
- const NON_RETRYABLE_CODES = ["ENOTFOUND"];
4478
- if (code && NON_RETRYABLE_CODES.includes(code)) {
4479
- return false;
4480
- }
4481
4484
  if (code &&
4482
4485
  [
4483
4486
  "ECONNREFUSED",
4484
4487
  "ECONNRESET",
4488
+ // The Anthropic host is fixed, so ENOTFOUND can be a transient resolver
4489
+ // outage. Keep it inside the existing bounded same-account retry budget.
4490
+ "ENOTFOUND",
4485
4491
  "ETIMEDOUT",
4486
4492
  "EHOSTUNREACH",
4487
4493
  "UND_ERR_CONNECT_TIMEOUT",
@@ -4493,13 +4499,9 @@ function isRetryableNetworkError(error) {
4493
4499
  }
4494
4500
  const message = error instanceof Error ? error.message : String(error);
4495
4501
  const normalized = message.toLowerCase();
4496
- // Exclude ENOTFOUND from string-based heuristic — DNS failures are permanent
4497
- // and rotating accounts won't help since they all hit the same host.
4498
- if (normalized.includes("enotfound")) {
4499
- return false;
4500
- }
4501
4502
  return (normalized.includes("econnrefused") ||
4502
4503
  normalized.includes("econnreset") ||
4504
+ normalized.includes("enotfound") ||
4503
4505
  normalized.includes("etimedout") ||
4504
4506
  normalized.includes("timed out") ||
4505
4507
  normalized.includes("connection error") ||
@@ -4608,14 +4610,6 @@ export const __testHooks = {
4608
4610
  const state = accountRuntimeState.get(key);
4609
4611
  return state ? { ...state } : undefined;
4610
4612
  },
4611
- setConfiguredPrimaryAccountKey: (key) => {
4612
- configuredPrimaryAccountKey = key;
4613
- },
4614
- getConfiguredPrimaryAccountKey: () => configuredPrimaryAccountKey,
4615
- setConfiguredAccountAllowlist: (allowlist) => {
4616
- configuredAccountAllowlist = allowlist ? new Set(allowlist) : undefined;
4617
- },
4618
- getConfiguredAccountAllowlist: () => configuredAccountAllowlist ? [...configuredAccountAllowlist] : undefined,
4619
4613
  setPrimaryAccountIndex: (index) => {
4620
4614
  primaryAccountIndex = index;
4621
4615
  },
@@ -4631,8 +4625,6 @@ export const __testHooks = {
4631
4625
  transientCooldownAdmissionSchedules.clear();
4632
4626
  primaryAccountIndex = 0;
4633
4627
  lastKnownAccountCount = 0;
4634
- configuredPrimaryAccountKey = undefined;
4635
- configuredAccountAllowlist = undefined;
4636
4628
  },
4637
4629
  polyfillOAuthBody: (bodyStr, isClaudeClientRequest) => polyfillOAuthBody(bodyStr, "test-account-token", null, isClaudeClientRequest),
4638
4630
  isAntiAbuseConstruction429,
@@ -437,6 +437,8 @@ export type RequestLogEntry = {
437
437
  responseTimeMs: number;
438
438
  errorType?: string;
439
439
  errorMessage?: string;
440
+ /** Low-level transport code such as ETIMEDOUT or EADDRNOTAVAIL. */
441
+ errorCode?: string;
440
442
  inputTokens?: number;
441
443
  outputTokens?: number;
442
444
  cacheCreationTokens?: number;
@@ -461,6 +463,8 @@ export type RequestAttemptLogEntry = {
461
463
  responseTimeMs: number;
462
464
  errorType?: string;
463
465
  errorMessage?: string;
466
+ /** Low-level transport code such as ETIMEDOUT or EADDRNOTAVAIL. */
467
+ errorCode?: string;
464
468
  /** Whether this failed attempt may be retried without changing the request. */
465
469
  retryable?: boolean;
466
470
  /** Distinguishes short-lived admission throttles from exhausted quota windows. */
@@ -514,6 +518,8 @@ export type AnthropicAttemptLogger = (status: number, errorType?: string, errorM
514
518
  cacheCreationTokens?: number;
515
519
  cacheReadTokens?: number;
516
520
  retryable?: boolean;
521
+ /** Low-level transport code such as ETIMEDOUT or EADDRNOTAVAIL. */
522
+ errorCode?: string;
517
523
  rateLimitKind?: "transient" | "quota";
518
524
  cooldownReason?: "transient" | "session" | "weekly" | "unified";
519
525
  /** Override used when one account selection performs an OAuth retry fetch. */
@@ -945,6 +951,80 @@ export type ProxyRuntimeActivity = {
945
951
  activeRequests: number;
946
952
  lastActivityAt: string | null;
947
953
  };
954
+ /** Terminal state observed while the HTTP adapter relays a response body. */
955
+ export type ProxyResponseTerminalOutcome = "completed" | "bodyless" | "client_cancelled" | "stream_error";
956
+ /** Non-blocking callbacks for response lifecycle metadata. */
957
+ export type ProxyResponseTrackingObserver = {
958
+ onFirstChunk?: (details: {
959
+ /** Decoded response-body bytes observed by the adapter. */
960
+ observedBodyBytes: number;
961
+ responseChunks: 1;
962
+ }) => void;
963
+ onTerminal?: (details: {
964
+ outcome: ProxyResponseTerminalOutcome;
965
+ /** Decoded response-body bytes observed by the adapter. */
966
+ observedBodyBytes: number;
967
+ responseChunks: number;
968
+ }) => void;
969
+ };
970
+ /** Versioned lifecycle event names persisted by the proxy adapter. */
971
+ export type ProxyLifecycleEventName = "request_accepted" | "response_headers" | "response_first_chunk" | "request_terminal";
972
+ /** Client-facing terminal classifications recorded by lifecycle metadata. */
973
+ export type ProxyLifecycleTerminalOutcome = ProxyResponseTerminalOutcome | "handler_error";
974
+ /** Content-free lifecycle event accepted by the bounded metadata logger. */
975
+ export type ProxyLifecycleEventInput = {
976
+ event: ProxyLifecycleEventName;
977
+ requestId: string;
978
+ method: string;
979
+ path: string;
980
+ model?: string;
981
+ stream?: boolean;
982
+ toolCount?: number;
983
+ sessionHash?: string;
984
+ requestBytes?: number;
985
+ responseStatus?: number;
986
+ /** Decoded response-body bytes observed by the adapter. */
987
+ observedBodyBytes?: number;
988
+ responseChunks?: number;
989
+ elapsedMs?: number;
990
+ terminalOutcome?: ProxyLifecycleTerminalOutcome;
991
+ errorType?: string;
992
+ errorCode?: string;
993
+ timestampMs?: number;
994
+ monotonicMs?: number;
995
+ };
996
+ /** Data-quality counters for the bounded lifecycle metadata sink. */
997
+ export type ProxyLifecycleLoggerSnapshot = {
998
+ enabled: boolean;
999
+ schemaVersion: number;
1000
+ processInstanceId: string;
1001
+ nextSequence: number;
1002
+ attempted: number;
1003
+ enqueued: number;
1004
+ written: number;
1005
+ dropped: number;
1006
+ queueDrops: number;
1007
+ invalidDrops: number;
1008
+ writeDrops: number;
1009
+ writeFailures: number;
1010
+ pending: number;
1011
+ inFlight: number;
1012
+ flushing: boolean;
1013
+ };
1014
+ /** Lifecycle logger configuration. Queue overrides are used by stress tests. */
1015
+ export type ProxyLifecycleLoggerOptions = {
1016
+ enabled: boolean;
1017
+ logDir?: string;
1018
+ queueCapacity?: number;
1019
+ batchSize?: number;
1020
+ flushIntervalMs?: number;
1021
+ };
1022
+ /** Serialized lifecycle line awaiting a bounded batch write. */
1023
+ export type QueuedProxyLifecycleEvent = {
1024
+ logDir: string;
1025
+ date: string;
1026
+ record: Record<string, unknown>;
1027
+ };
948
1028
  /** Request metadata retained by the HTTP adapter for terminal error logging. */
949
1029
  export type RuntimeRequestMetadata = {
950
1030
  requestId: string;
@@ -954,6 +1034,8 @@ export type RuntimeRequestMetadata = {
954
1034
  model: string;
955
1035
  stream: boolean;
956
1036
  toolCount: number;
1037
+ terminalErrorType?: string;
1038
+ terminalErrorCode?: string;
957
1039
  };
958
1040
  /** Accumulated upstream body capture from a raw stream. */
959
1041
  export type RawStreamCapture = {
@@ -1,8 +1,8 @@
1
- import type { ProxyActivitySnapshot } from "../types/index.js";
1
+ import type { ProxyActivitySnapshot, ProxyResponseTrackingObserver } from "../types/index.js";
2
2
  /** Track one client-facing proxy request until its response body settles. */
3
3
  export declare function beginProxyRequest(): () => void;
4
4
  export declare function getProxyActivitySnapshot(): ProxyActivitySnapshot;
5
5
  export declare function isProxyActivityQuiet(snapshot: ProxyActivitySnapshot, quietThresholdMs: number, nowMs?: number): boolean;
6
6
  /** Keep activity open until the response body completes, errors, or is cancelled. */
7
- export declare function trackProxyResponse(response: Response, finishRequest: () => void): Response;
7
+ export declare function trackProxyResponse(response: Response, finishRequest: () => void, observer?: ProxyResponseTrackingObserver): Response;
8
8
  export declare function resetProxyActivityForTests(): void;
@@ -1,8 +1,24 @@
1
+ import { withTimeout } from "../utils/async/withTimeout.js";
2
+ import { logger } from "../utils/logger.js";
3
+ const PROXY_RESPONSE_CANCEL_TIMEOUT_MS = 1_000;
1
4
  let activeRequests = 0;
2
5
  let lastActivityAtMs = null;
3
6
  function touchActivity() {
4
7
  lastActivityAtMs = Date.now();
5
8
  }
9
+ function safelyNotifyObserver(callback) {
10
+ try {
11
+ callback?.();
12
+ }
13
+ catch (error) {
14
+ // Observability must never alter response handling.
15
+ if (logger.shouldLog("debug")) {
16
+ logger.debug("[proxy] response lifecycle observer failed", {
17
+ error: error instanceof Error ? error.message : String(error),
18
+ });
19
+ }
20
+ }
21
+ }
6
22
  /** Track one client-facing proxy request until its response body settles. */
7
23
  export function beginProxyRequest() {
8
24
  activeRequests += 1;
@@ -33,35 +49,59 @@ export function isProxyActivityQuiet(snapshot, quietThresholdMs, nowMs = Date.no
33
49
  return nowMs - snapshot.lastActivityAt.getTime() >= quietThresholdMs;
34
50
  }
35
51
  /** Keep activity open until the response body completes, errors, or is cancelled. */
36
- export function trackProxyResponse(response, finishRequest) {
52
+ export function trackProxyResponse(response, finishRequest, observer) {
37
53
  if (!response.body) {
38
54
  finishRequest();
55
+ safelyNotifyObserver(() => observer?.onTerminal?.({
56
+ outcome: "bodyless",
57
+ observedBodyBytes: 0,
58
+ responseChunks: 0,
59
+ }));
39
60
  return response;
40
61
  }
41
62
  const reader = response.body.getReader();
63
+ let observedBodyBytes = 0;
64
+ let responseChunks = 0;
65
+ let settled = false;
66
+ const settle = (outcome) => {
67
+ if (settled) {
68
+ return;
69
+ }
70
+ settled = true;
71
+ finishRequest();
72
+ safelyNotifyObserver(() => observer?.onTerminal?.({
73
+ outcome,
74
+ observedBodyBytes,
75
+ responseChunks,
76
+ }));
77
+ };
42
78
  const trackedBody = new ReadableStream({
43
79
  async pull(controller) {
44
80
  try {
45
81
  const { value, done } = await reader.read();
46
82
  if (done) {
47
- finishRequest();
83
+ settle("completed");
48
84
  controller.close();
49
85
  return;
50
86
  }
51
87
  controller.enqueue(value);
88
+ observedBodyBytes += value.byteLength;
89
+ responseChunks += 1;
90
+ if (responseChunks === 1) {
91
+ safelyNotifyObserver(() => observer?.onFirstChunk?.({
92
+ observedBodyBytes,
93
+ responseChunks: 1,
94
+ }));
95
+ }
52
96
  }
53
97
  catch (error) {
54
- finishRequest();
98
+ settle("stream_error");
55
99
  controller.error(error);
56
100
  }
57
101
  },
58
102
  async cancel(reason) {
59
- try {
60
- await reader.cancel(reason);
61
- }
62
- finally {
63
- finishRequest();
64
- }
103
+ settle("client_cancelled");
104
+ await withTimeout(reader.cancel(reason), PROXY_RESPONSE_CANCEL_TIMEOUT_MS, "Timed out cancelling the upstream proxy response");
65
105
  },
66
106
  });
67
107
  return new Response(trackedBody, {
@@ -0,0 +1,8 @@
1
+ import type { ProxyLifecycleEventInput, ProxyLifecycleLoggerOptions, ProxyLifecycleLoggerSnapshot } from "../types/index.js";
2
+ export declare function hashProxyLifecycleSessionId(sessionId: string | undefined): string | undefined;
3
+ export declare function configureProxyLifecycleLogger(options: ProxyLifecycleLoggerOptions): void;
4
+ /** Enqueue fixed-size lifecycle metadata without awaiting filesystem work. */
5
+ export declare function logProxyLifecycleEvent(input: ProxyLifecycleEventInput): void;
6
+ export declare function flushProxyLifecycleEvents(): Promise<void>;
7
+ export declare function getProxyLifecycleLoggerSnapshot(): ProxyLifecycleLoggerSnapshot;
8
+ export declare function resetProxyLifecycleLoggerForTests(): void;