@adaptic/utils 0.0.990 → 0.0.992

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/dist/index.cjs CHANGED
@@ -2691,6 +2691,48 @@ class AlpacaMarketDataAPI extends require$$0$1.EventEmitter {
2691
2691
  };
2692
2692
  reconnectAttempts = {};
2693
2693
  reconnectTimers = {};
2694
+ /**
2695
+ * Wall-clock timestamp of the most recent Alpaca app-level error code
2696
+ * 406 ("connection limit exceeded") received on each stream. Used by
2697
+ * {@link scheduleReconnect} to apply a long backoff with jitter rather
2698
+ * than the normal sub-second exponential ramp — without this, hitting
2699
+ * Alpaca's account-wide concurrent-connection cap (typical for blue/green
2700
+ * deploy rollovers where the old pod's WS slots haven't been released
2701
+ * yet) produced a 10-attempt retry storm that compounded the slot
2702
+ * pressure and consumed the per-account connection quota across the
2703
+ * organisation.
2704
+ *
2705
+ * Cleared once the long-backoff retry is scheduled so that subsequent
2706
+ * normal failures fall back to the standard sub-second exponential.
2707
+ *
2708
+ * @see CONNECTION_LIMIT_BACKOFF_MS / CONNECTION_LIMIT_BACKOFF_JITTER_MS
2709
+ */
2710
+ lastConnectionLimitAt = {};
2711
+ /**
2712
+ * Five-minute base backoff after Alpaca's app-level 406. Long enough
2713
+ * for Alpaca's server-side cleanup to release stale slots in typical
2714
+ * rollover scenarios; short enough that an operator doesn't need to
2715
+ * intervene. Mirrors the equivalent MassiveClient
2716
+ * `MAX_CONNECTIONS_RETRY_DELAY_MS` (engine v1.0.59) so both providers
2717
+ * behave identically under the same failure mode.
2718
+ */
2719
+ CONNECTION_LIMIT_BACKOFF_MS = 5 * 60_000;
2720
+ /**
2721
+ * ±30 s of uniform jitter on the connection-limit backoff. Prevents
2722
+ * a thundering-herd retry when all three streams (stock / option /
2723
+ * crypto) hit 406 simultaneously during a deploy rollover — without
2724
+ * jitter they'd all retry at the same wall-clock instant and could
2725
+ * re-trip the account cap together.
2726
+ */
2727
+ CONNECTION_LIMIT_BACKOFF_JITTER_MS = 30_000;
2728
+ /**
2729
+ * Recency window within which a 406 is considered "still applicable"
2730
+ * to a subsequent reconnect-schedule call. The 406 message handler
2731
+ * stamps {@link lastConnectionLimitAt} and the `close` handler fires
2732
+ * shortly afterwards (sub-second typically) — the window is wide
2733
+ * enough to absorb scheduling delays without false-positives.
2734
+ */
2735
+ CONNECTION_LIMIT_RECENCY_MS = 30_000;
2694
2736
  setMode(mode = "production") {
2695
2737
  if (mode === "sandbox") {
2696
2738
  // sandbox mode
@@ -2822,6 +2864,18 @@ class AlpacaMarketDataAPI extends require$$0$1.EventEmitter {
2822
2864
  }
2823
2865
  else if (message.T === "error") {
2824
2866
  log$l(`${streamType} stream error: ${message.msg} (code: ${message.code}, raw: ${JSON.stringify(message)})`, { type: "error" });
2867
+ // Alpaca code 406: "connection limit exceeded" — account-wide
2868
+ // concurrent-WS cap reached. The Alpaca server will close the
2869
+ // socket immediately after this frame, which would normally
2870
+ // trigger our standard sub-second exponential reconnect chain
2871
+ // (1 s, 2 s, 4 s, 8 s, 16 s, 30 s × 5) — exactly the wrong
2872
+ // behaviour against a rate-limit response. Stamp the recency
2873
+ // marker so {@link scheduleReconnect} switches to the
2874
+ // 5-minute jittered backoff instead.
2875
+ if (typeof message.code === "number" &&
2876
+ message.code === 406) {
2877
+ this.lastConnectionLimitAt[streamType] = Date.now();
2878
+ }
2825
2879
  }
2826
2880
  else if (message.S) {
2827
2881
  super.emit(`${streamType}-${message.T}`, message);
@@ -2855,6 +2909,37 @@ class AlpacaMarketDataAPI extends require$$0$1.EventEmitter {
2855
2909
  });
2856
2910
  }
2857
2911
  scheduleReconnect(streamType) {
2912
+ // 406-recovery fast path. When the most recent close was preceded
2913
+ // by an Alpaca app-level 406 ("connection limit exceeded"), the
2914
+ // standard sub-second exponential ramp is exactly wrong — it
2915
+ // hammers the rate-limit endpoint and prolongs the slot pressure.
2916
+ // Use a 5-minute jittered backoff instead and reset the normal
2917
+ // attempt counter so we don't fall off the end of maxAttempts
2918
+ // and permanently give up on a transient rollover blip.
2919
+ const connectionLimitAt = this.lastConnectionLimitAt[streamType];
2920
+ const isRecentConnectionLimit = typeof connectionLimitAt === "number" &&
2921
+ Date.now() - connectionLimitAt <= this.CONNECTION_LIMIT_RECENCY_MS;
2922
+ if (isRecentConnectionLimit) {
2923
+ const jitter = Math.floor((Math.random() - 0.5) *
2924
+ 2 *
2925
+ this.CONNECTION_LIMIT_BACKOFF_JITTER_MS);
2926
+ const delayMs = this.CONNECTION_LIMIT_BACKOFF_MS + jitter;
2927
+ // Reset normal attempt counter so the next 406 retry doesn't
2928
+ // inherit a stale exponential cap.
2929
+ this.reconnectAttempts[streamType] = 0;
2930
+ // Consume the recency marker — subsequent reconnects fall back
2931
+ // to the standard exponential path unless a new 406 arrives.
2932
+ delete this.lastConnectionLimitAt[streamType];
2933
+ log$l(`${streamType} stream: Alpaca 406 connection-limit recovery — backing off ${Math.round(delayMs / 1000)}s before retry to allow account-wide slot release`, { type: "warn" });
2934
+ if (this.reconnectTimers[streamType]) {
2935
+ clearTimeout(this.reconnectTimers[streamType]);
2936
+ }
2937
+ this.reconnectTimers[streamType] = setTimeout(() => {
2938
+ log$l(`${streamType} stream: attempting reconnect after 406-recovery backoff`, { type: "info" });
2939
+ this.connect(streamType);
2940
+ }, delayMs);
2941
+ return;
2942
+ }
2858
2943
  const attempts = this.reconnectAttempts[streamType] ?? 0;
2859
2944
  const maxAttempts = 10;
2860
2945
  if (attempts >= maxAttempts) {
@@ -6044,6 +6129,202 @@ async function createLimitOrder(auth, params = {
6044
6129
  }
6045
6130
 
6046
6131
  // Utility function for debug logging
6132
+ /** Sliding-window size (number of recent outcomes tracked per host). */
6133
+ const CIRCUIT_WINDOW_SIZE = 20;
6134
+ /** Maximum age (ms) of an outcome before it is dropped from the window. */
6135
+ const CIRCUIT_WINDOW_TTL_MS = 30_000;
6136
+ /**
6137
+ * Failure ratio (0..1) at which the breaker trips when the window
6138
+ * is full (i.e. CIRCUIT_WINDOW_SIZE outcomes recorded).
6139
+ *
6140
+ * Tuned to be permissive enough that a few transient failures during
6141
+ * normal operation do not trip the breaker, but tight enough that a
6142
+ * persistent upstream outage (where >= half of recent calls are
6143
+ * failing) is detected quickly. With WINDOW=20 and threshold=0.6 we
6144
+ * need 12 failures within the most recent 20 attempts to trip.
6145
+ */
6146
+ const CIRCUIT_TRIP_FAILURE_RATIO = 0.6;
6147
+ /**
6148
+ * Minimum number of recent outcomes required before the failure-ratio
6149
+ * gate is even evaluated. Without this guard, a single failure on a
6150
+ * cold host would give a 100 % ratio and trip the breaker on the
6151
+ * second attempt — which would be useless protection while also being
6152
+ * highly destructive.
6153
+ */
6154
+ const CIRCUIT_MIN_SAMPLES = 8;
6155
+ /**
6156
+ * How long the breaker stays open before transitioning to half-open
6157
+ * (i.e. allowing the next request through as a probe). The next
6158
+ * outcome — success or failure — fully recloses or re-opens the
6159
+ * breaker. 5 s aligns with typical upstream recovery times and is
6160
+ * short enough that the next caller drives the probe.
6161
+ */
6162
+ const CIRCUIT_OPEN_COOLDOWN_MS = 5_000;
6163
+ /**
6164
+ * Fail-fast latency. We sleep a short, deterministic amount before
6165
+ * rejecting so the upstream caller's outer timeout (commonly 10–25 s)
6166
+ * sees a clean rejection rather than a synchronous reject-storm that
6167
+ * could starve other event-loop work. 100 ms aligns with the user's
6168
+ * G1 analysis target: "cap per-symbol fallback latency at 100 ms
6169
+ * during outages — eliminating the 25 s × N-concurrent-symbols
6170
+ * event-loop hog."
6171
+ */
6172
+ const CIRCUIT_FAIL_FAST_LATENCY_MS = 100;
6173
+ const circuitStates = new Map();
6174
+ function getCircuitState(host) {
6175
+ let state = circuitStates.get(host);
6176
+ if (!state) {
6177
+ state = {
6178
+ host,
6179
+ recent: [],
6180
+ openedAt: 0,
6181
+ lastTripFailureRatio: 0,
6182
+ };
6183
+ circuitStates.set(host, state);
6184
+ }
6185
+ return state;
6186
+ }
6187
+ function pruneStaleSamples(state, now) {
6188
+ const cutoff = now - CIRCUIT_WINDOW_TTL_MS;
6189
+ // Remove entries older than the TTL. Window is small so a simple
6190
+ // filter is fine — no need for a deque structure.
6191
+ if (state.recent.length === 0)
6192
+ return;
6193
+ let firstFreshIndex = 0;
6194
+ while (firstFreshIndex < state.recent.length &&
6195
+ state.recent[firstFreshIndex].atMs < cutoff) {
6196
+ firstFreshIndex += 1;
6197
+ }
6198
+ if (firstFreshIndex > 0) {
6199
+ state.recent.splice(0, firstFreshIndex);
6200
+ }
6201
+ }
6202
+ function recordOutcome(host, ok) {
6203
+ const now = Date.now();
6204
+ const state = getCircuitState(host);
6205
+ pruneStaleSamples(state, now);
6206
+ state.recent.push({ atMs: now, ok });
6207
+ if (state.recent.length > CIRCUIT_WINDOW_SIZE) {
6208
+ state.recent.shift();
6209
+ }
6210
+ // If we are in the open or half-open window and just received an
6211
+ // outcome, decide whether to close or re-open.
6212
+ if (state.openedAt > 0) {
6213
+ if (now - state.openedAt >= CIRCUIT_OPEN_COOLDOWN_MS) {
6214
+ // Half-open probe outcome arrived.
6215
+ if (ok) {
6216
+ // Recovered. Close the breaker.
6217
+ state.openedAt = 0;
6218
+ state.lastTripFailureRatio = 0;
6219
+ getLogger().info(`Circuit breaker for ${host} closed — upstream recovered`, { host });
6220
+ }
6221
+ else {
6222
+ // Probe failed. Re-open the breaker for another cooldown.
6223
+ state.openedAt = now;
6224
+ }
6225
+ return;
6226
+ }
6227
+ // Still inside the cooldown window — outcomes are recorded for
6228
+ // statistics but the breaker stays open regardless.
6229
+ return;
6230
+ }
6231
+ // Closed breaker — evaluate whether the failure ratio has tripped.
6232
+ if (state.recent.length < CIRCUIT_MIN_SAMPLES)
6233
+ return;
6234
+ const failures = state.recent.reduce((acc, entry) => acc + (entry.ok ? 0 : 1), 0);
6235
+ const ratio = failures / state.recent.length;
6236
+ if (ratio >= CIRCUIT_TRIP_FAILURE_RATIO) {
6237
+ state.openedAt = now;
6238
+ state.lastTripFailureRatio = ratio;
6239
+ getLogger().warn(`Circuit breaker for ${host} opened — ${failures}/${state.recent.length} recent attempts failed (${(ratio * 100).toFixed(0)}%); fail-fast for ${CIRCUIT_OPEN_COOLDOWN_MS}ms`, {
6240
+ host,
6241
+ recentFailures: failures,
6242
+ recentSamples: state.recent.length,
6243
+ failureRatio: ratio,
6244
+ cooldownMs: CIRCUIT_OPEN_COOLDOWN_MS,
6245
+ });
6246
+ }
6247
+ }
6248
+ function isCircuitOpen(host, now) {
6249
+ const state = circuitStates.get(host);
6250
+ if (!state || state.openedAt === 0)
6251
+ return false;
6252
+ pruneStaleSamples(state, now);
6253
+ return now - state.openedAt < CIRCUIT_OPEN_COOLDOWN_MS;
6254
+ }
6255
+ /**
6256
+ * Error thrown by {@link fetchWithRetry} when the per-host circuit
6257
+ * breaker is open and fail-fast suppression is in effect.
6258
+ *
6259
+ * Carries the host, the failure ratio that tripped the breaker, and
6260
+ * the remaining cooldown so callers can render an actionable log.
6261
+ */
6262
+ class CircuitOpenError extends Error {
6263
+ code = "MASSIVE_CIRCUIT_OPEN";
6264
+ host;
6265
+ tripFailureRatio;
6266
+ cooldownRemainingMs;
6267
+ constructor(host, tripFailureRatio, cooldownRemainingMs) {
6268
+ super(`Circuit open for ${host} — fail-fast (recent failure ratio ${(tripFailureRatio * 100).toFixed(0)}%, retry in ${cooldownRemainingMs}ms)`);
6269
+ this.name = "CircuitOpenError";
6270
+ this.host = host;
6271
+ this.tripFailureRatio = tripFailureRatio;
6272
+ this.cooldownRemainingMs = cooldownRemainingMs;
6273
+ }
6274
+ }
6275
+ /**
6276
+ * Force-close the breaker for a given host. Exposed for tests and
6277
+ * operator-runbook scripts so a stuck-open breaker can be reset
6278
+ * without bouncing the process. Not intended for hot-path use.
6279
+ *
6280
+ * @param host The hostname whose breaker should be reset.
6281
+ */
6282
+ function resetCircuitBreaker(host) {
6283
+ const state = circuitStates.get(host);
6284
+ if (!state)
6285
+ return;
6286
+ state.openedAt = 0;
6287
+ state.lastTripFailureRatio = 0;
6288
+ state.recent = [];
6289
+ }
6290
+ /**
6291
+ * Snapshot of all known per-host circuit-breaker states. Intended for
6292
+ * an operational-truth / status endpoint to surface upstream health.
6293
+ *
6294
+ * @returns Map of host → {open, openedAt, recentSamples, failureRatio,
6295
+ * lastTripFailureRatio}.
6296
+ */
6297
+ function getCircuitBreakerSnapshot() {
6298
+ const now = Date.now();
6299
+ const snapshot = {};
6300
+ for (const [host, state] of circuitStates) {
6301
+ pruneStaleSamples(state, now);
6302
+ const failures = state.recent.reduce((acc, entry) => acc + (entry.ok ? 0 : 1), 0);
6303
+ const ratio = state.recent.length > 0 ? failures / state.recent.length : 0;
6304
+ snapshot[host] = {
6305
+ open: state.openedAt > 0 && now - state.openedAt < CIRCUIT_OPEN_COOLDOWN_MS,
6306
+ openedAt: state.openedAt,
6307
+ cooldownRemainingMs: state.openedAt > 0
6308
+ ? Math.max(0, CIRCUIT_OPEN_COOLDOWN_MS - (now - state.openedAt))
6309
+ : 0,
6310
+ recentSamples: state.recent.length,
6311
+ failureRatio: ratio,
6312
+ lastTripFailureRatio: state.lastTripFailureRatio,
6313
+ };
6314
+ }
6315
+ return snapshot;
6316
+ }
6317
+ function hostnameFromUrl(url) {
6318
+ try {
6319
+ return new URL(url).hostname;
6320
+ }
6321
+ catch {
6322
+ return null;
6323
+ }
6324
+ }
6325
+ function sleep(ms) {
6326
+ return new Promise((resolve) => setTimeout(resolve, ms));
6327
+ }
6047
6328
  /**
6048
6329
  * Debug logging utility that respects environment debug flags.
6049
6330
  * Logs messages through the configured structured logger when LUMIC_DEBUG
@@ -6155,9 +6436,46 @@ function hideApiKeyFromurl(url) {
6155
6436
  * @throws Will throw an error if the fetch fails after the specified number of retries.
6156
6437
  */
6157
6438
  async function fetchWithRetry(url, options = {}, retries = 3, initialBackoff = 1000) {
6439
+ // Per-host circuit-breaker check. When upstream is failing
6440
+ // pervasively (e.g. Massive REST in an outage), short-circuit to a
6441
+ // 100 ms fail-fast rejection so the caller's outer timeout (25 s,
6442
+ // 10 s, etc.) does not pile concurrent symbol fetches against a
6443
+ // dead host. Without this, an upstream outage during a screener tick
6444
+ // can lock up the event loop for up to (timeout × N-concurrent-symbols).
6445
+ const host = hostnameFromUrl(url);
6446
+ if (host) {
6447
+ const now = Date.now();
6448
+ if (isCircuitOpen(host, now)) {
6449
+ const state = getCircuitState(host);
6450
+ const cooldownRemainingMs = Math.max(0, CIRCUIT_OPEN_COOLDOWN_MS - (now - state.openedAt));
6451
+ await sleep(CIRCUIT_FAIL_FAST_LATENCY_MS);
6452
+ throw new CircuitOpenError(host, state.lastTripFailureRatio, cooldownRemainingMs);
6453
+ }
6454
+ }
6158
6455
  return withRetry(async () => {
6159
- const response = await fetch(url, options);
6456
+ let response;
6457
+ try {
6458
+ response = await fetch(url, options);
6459
+ }
6460
+ catch (networkError) {
6461
+ // Network failure (e.g. DNS, connection refused, TLS abort).
6462
+ // Record against the breaker so a sustained outage trips it
6463
+ // for subsequent calls; then re-throw so withRetry handles
6464
+ // its own retry policy.
6465
+ if (host)
6466
+ recordOutcome(host, false);
6467
+ throw networkError;
6468
+ }
6160
6469
  if (!response.ok) {
6470
+ // Classify the outcome for the circuit breaker. 5xx and 429
6471
+ // are upstream-health signals (record as failure). 4xx client
6472
+ // errors are caller-side and should NOT affect the breaker —
6473
+ // a request with a bad API key shouldn't trip the host's
6474
+ // breaker for everyone else.
6475
+ const upstreamUnhealthy = response.status === 429 ||
6476
+ (response.status >= 500 && response.status < 600);
6477
+ if (host)
6478
+ recordOutcome(host, !upstreamUnhealthy);
6161
6479
  // Enhanced HTTP error handling with specific error types
6162
6480
  if (response.status === 429) {
6163
6481
  // Check for Retry-After header
@@ -6189,6 +6507,10 @@ async function fetchWithRetry(url, options = {}, retries = 3, initialBackoff = 1
6189
6507
  error.response = response;
6190
6508
  throw error;
6191
6509
  }
6510
+ // Success — record against the breaker so a healthy upstream
6511
+ // closes any half-open state cleanly.
6512
+ if (host)
6513
+ recordOutcome(host, true);
6192
6514
  return response;
6193
6515
  }, {
6194
6516
  maxRetries: retries,
@@ -69379,6 +69701,17 @@ const adaptic = {
69379
69701
  logIfDebug: logIfDebug,
69380
69702
  fetchWithRetry: fetchWithRetry,
69381
69703
  validateMassiveApiKey: validateMassiveApiKey,
69704
+ /**
69705
+ * Force-close a stuck-open per-host circuit breaker. Operator
69706
+ * runbook utility — see {@link misc.resetCircuitBreaker}.
69707
+ */
69708
+ resetCircuitBreaker: resetCircuitBreaker,
69709
+ /**
69710
+ * Read-only snapshot of all per-host circuit-breaker states for
69711
+ * use in operational-truth endpoints. See
69712
+ * {@link misc.getCircuitBreakerSnapshot}.
69713
+ */
69714
+ getCircuitBreakerSnapshot: getCircuitBreakerSnapshot,
69382
69715
  },
69383
69716
  rateLimiter: {
69384
69717
  TokenBucketRateLimiter,
@@ -69417,6 +69750,7 @@ exports.AssetAllocationEngine = AssetAllocationEngine;
69417
69750
  exports.AuthenticationError = AuthenticationError;
69418
69751
  exports.BTC_PAIRS = BTC_PAIRS;
69419
69752
  exports.BarError = BarError;
69753
+ exports.CircuitOpenError = CircuitOpenError;
69420
69754
  exports.CryptoDataError = CryptoDataError;
69421
69755
  exports.CryptoOrderError = CryptoOrderError;
69422
69756
  exports.DEFAULT_CACHE_OPTIONS = DEFAULT_CACHE_OPTIONS;