@adaptic/utils 0.0.991 → 0.0.993

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.mjs CHANGED
@@ -2689,6 +2689,48 @@ class AlpacaMarketDataAPI extends EventEmitter {
2689
2689
  };
2690
2690
  reconnectAttempts = {};
2691
2691
  reconnectTimers = {};
2692
+ /**
2693
+ * Wall-clock timestamp of the most recent Alpaca app-level error code
2694
+ * 406 ("connection limit exceeded") received on each stream. Used by
2695
+ * {@link scheduleReconnect} to apply a long backoff with jitter rather
2696
+ * than the normal sub-second exponential ramp — without this, hitting
2697
+ * Alpaca's account-wide concurrent-connection cap (typical for blue/green
2698
+ * deploy rollovers where the old pod's WS slots haven't been released
2699
+ * yet) produced a 10-attempt retry storm that compounded the slot
2700
+ * pressure and consumed the per-account connection quota across the
2701
+ * organisation.
2702
+ *
2703
+ * Cleared once the long-backoff retry is scheduled so that subsequent
2704
+ * normal failures fall back to the standard sub-second exponential.
2705
+ *
2706
+ * @see CONNECTION_LIMIT_BACKOFF_MS / CONNECTION_LIMIT_BACKOFF_JITTER_MS
2707
+ */
2708
+ lastConnectionLimitAt = {};
2709
+ /**
2710
+ * Five-minute base backoff after Alpaca's app-level 406. Long enough
2711
+ * for Alpaca's server-side cleanup to release stale slots in typical
2712
+ * rollover scenarios; short enough that an operator doesn't need to
2713
+ * intervene. Mirrors the equivalent MassiveClient
2714
+ * `MAX_CONNECTIONS_RETRY_DELAY_MS` (engine v1.0.59) so both providers
2715
+ * behave identically under the same failure mode.
2716
+ */
2717
+ CONNECTION_LIMIT_BACKOFF_MS = 5 * 60_000;
2718
+ /**
2719
+ * ±30 s of uniform jitter on the connection-limit backoff. Prevents
2720
+ * a thundering-herd retry when all three streams (stock / option /
2721
+ * crypto) hit 406 simultaneously during a deploy rollover — without
2722
+ * jitter they'd all retry at the same wall-clock instant and could
2723
+ * re-trip the account cap together.
2724
+ */
2725
+ CONNECTION_LIMIT_BACKOFF_JITTER_MS = 30_000;
2726
+ /**
2727
+ * Recency window within which a 406 is considered "still applicable"
2728
+ * to a subsequent reconnect-schedule call. The 406 message handler
2729
+ * stamps {@link lastConnectionLimitAt} and the `close` handler fires
2730
+ * shortly afterwards (sub-second typically) — the window is wide
2731
+ * enough to absorb scheduling delays without false-positives.
2732
+ */
2733
+ CONNECTION_LIMIT_RECENCY_MS = 30_000;
2692
2734
  setMode(mode = "production") {
2693
2735
  if (mode === "sandbox") {
2694
2736
  // sandbox mode
@@ -2820,6 +2862,18 @@ class AlpacaMarketDataAPI extends EventEmitter {
2820
2862
  }
2821
2863
  else if (message.T === "error") {
2822
2864
  log$l(`${streamType} stream error: ${message.msg} (code: ${message.code}, raw: ${JSON.stringify(message)})`, { type: "error" });
2865
+ // Alpaca code 406: "connection limit exceeded" — account-wide
2866
+ // concurrent-WS cap reached. The Alpaca server will close the
2867
+ // socket immediately after this frame, which would normally
2868
+ // trigger our standard sub-second exponential reconnect chain
2869
+ // (1 s, 2 s, 4 s, 8 s, 16 s, 30 s × 5) — exactly the wrong
2870
+ // behaviour against a rate-limit response. Stamp the recency
2871
+ // marker so {@link scheduleReconnect} switches to the
2872
+ // 5-minute jittered backoff instead.
2873
+ if (typeof message.code === "number" &&
2874
+ message.code === 406) {
2875
+ this.lastConnectionLimitAt[streamType] = Date.now();
2876
+ }
2823
2877
  }
2824
2878
  else if (message.S) {
2825
2879
  super.emit(`${streamType}-${message.T}`, message);
@@ -2853,6 +2907,37 @@ class AlpacaMarketDataAPI extends EventEmitter {
2853
2907
  });
2854
2908
  }
2855
2909
  scheduleReconnect(streamType) {
2910
+ // 406-recovery fast path. When the most recent close was preceded
2911
+ // by an Alpaca app-level 406 ("connection limit exceeded"), the
2912
+ // standard sub-second exponential ramp is exactly wrong — it
2913
+ // hammers the rate-limit endpoint and prolongs the slot pressure.
2914
+ // Use a 5-minute jittered backoff instead and reset the normal
2915
+ // attempt counter so we don't fall off the end of maxAttempts
2916
+ // and permanently give up on a transient rollover blip.
2917
+ const connectionLimitAt = this.lastConnectionLimitAt[streamType];
2918
+ const isRecentConnectionLimit = typeof connectionLimitAt === "number" &&
2919
+ Date.now() - connectionLimitAt <= this.CONNECTION_LIMIT_RECENCY_MS;
2920
+ if (isRecentConnectionLimit) {
2921
+ const jitter = Math.floor((Math.random() - 0.5) *
2922
+ 2 *
2923
+ this.CONNECTION_LIMIT_BACKOFF_JITTER_MS);
2924
+ const delayMs = this.CONNECTION_LIMIT_BACKOFF_MS + jitter;
2925
+ // Reset normal attempt counter so the next 406 retry doesn't
2926
+ // inherit a stale exponential cap.
2927
+ this.reconnectAttempts[streamType] = 0;
2928
+ // Consume the recency marker — subsequent reconnects fall back
2929
+ // to the standard exponential path unless a new 406 arrives.
2930
+ delete this.lastConnectionLimitAt[streamType];
2931
+ 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" });
2932
+ if (this.reconnectTimers[streamType]) {
2933
+ clearTimeout(this.reconnectTimers[streamType]);
2934
+ }
2935
+ this.reconnectTimers[streamType] = setTimeout(() => {
2936
+ log$l(`${streamType} stream: attempting reconnect after 406-recovery backoff`, { type: "info" });
2937
+ this.connect(streamType);
2938
+ }, delayMs);
2939
+ return;
2940
+ }
2856
2941
  const attempts = this.reconnectAttempts[streamType] ?? 0;
2857
2942
  const maxAttempts = 10;
2858
2943
  if (attempts >= maxAttempts) {
@@ -11530,7 +11615,6 @@ function calculateBollingerBands(priceData, { period = 20, standardDeviations =
11530
11615
  close: priceData[i].close,
11531
11616
  });
11532
11617
  }
11533
- // logIfDebug(`Calculated Bollinger Bands for ${result.length} periods`);
11534
11618
  return result;
11535
11619
  }
11536
11620
  /**
@@ -11593,7 +11677,6 @@ function calculateEMA(priceData, { period = 20, period2 = 9 } = {}) {
11593
11677
  }
11594
11678
  result.push(entry);
11595
11679
  }
11596
- // logIfDebug(`Calculated EMA for ${result.length} periods`);
11597
11680
  return result;
11598
11681
  }
11599
11682
  /**
@@ -11655,7 +11738,6 @@ function calculateFibonacciLevels(priceData, { lookbackPeriod = 20, retracementL
11655
11738
  close: priceData[i].close,
11656
11739
  });
11657
11740
  }
11658
- // logIfDebug(`Calculated Fibonacci levels for ${result.length} periods`);
11659
11741
  return result;
11660
11742
  }
11661
11743
  /**
@@ -11703,7 +11785,6 @@ function calculateMACD(priceData, { shortPeriod = 12, longPeriod = 26, signalPer
11703
11785
  close: emaLong[i].close,
11704
11786
  });
11705
11787
  }
11706
- // logIfDebug(`Calculated MACD for ${result.length} periods`);
11707
11788
  return result;
11708
11789
  }
11709
11790
  /**
@@ -11759,7 +11840,6 @@ function calculateRSI(priceData, { period = 14 } = {}) {
11759
11840
  close: priceData[i].close,
11760
11841
  });
11761
11842
  }
11762
- // logIfDebug(`Calculated RSI for ${result.length} periods`);
11763
11843
  return result;
11764
11844
  }
11765
11845
  /**
@@ -11812,7 +11892,6 @@ function calculateStochasticOscillator(priceData, { lookbackPeriod = 5, signalPe
11812
11892
  });
11813
11893
  }
11814
11894
  }
11815
- // logIfDebug(`Calculated Stochastic Oscillator for ${result.length} periods`);
11816
11895
  return result;
11817
11896
  }
11818
11897
  /**