@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/test.js CHANGED
@@ -1900,6 +1900,48 @@ class AlpacaMarketDataAPI extends EventEmitter {
1900
1900
  };
1901
1901
  reconnectAttempts = {};
1902
1902
  reconnectTimers = {};
1903
+ /**
1904
+ * Wall-clock timestamp of the most recent Alpaca app-level error code
1905
+ * 406 ("connection limit exceeded") received on each stream. Used by
1906
+ * {@link scheduleReconnect} to apply a long backoff with jitter rather
1907
+ * than the normal sub-second exponential ramp — without this, hitting
1908
+ * Alpaca's account-wide concurrent-connection cap (typical for blue/green
1909
+ * deploy rollovers where the old pod's WS slots haven't been released
1910
+ * yet) produced a 10-attempt retry storm that compounded the slot
1911
+ * pressure and consumed the per-account connection quota across the
1912
+ * organisation.
1913
+ *
1914
+ * Cleared once the long-backoff retry is scheduled so that subsequent
1915
+ * normal failures fall back to the standard sub-second exponential.
1916
+ *
1917
+ * @see CONNECTION_LIMIT_BACKOFF_MS / CONNECTION_LIMIT_BACKOFF_JITTER_MS
1918
+ */
1919
+ lastConnectionLimitAt = {};
1920
+ /**
1921
+ * Five-minute base backoff after Alpaca's app-level 406. Long enough
1922
+ * for Alpaca's server-side cleanup to release stale slots in typical
1923
+ * rollover scenarios; short enough that an operator doesn't need to
1924
+ * intervene. Mirrors the equivalent MassiveClient
1925
+ * `MAX_CONNECTIONS_RETRY_DELAY_MS` (engine v1.0.59) so both providers
1926
+ * behave identically under the same failure mode.
1927
+ */
1928
+ CONNECTION_LIMIT_BACKOFF_MS = 5 * 60_000;
1929
+ /**
1930
+ * ±30 s of uniform jitter on the connection-limit backoff. Prevents
1931
+ * a thundering-herd retry when all three streams (stock / option /
1932
+ * crypto) hit 406 simultaneously during a deploy rollover — without
1933
+ * jitter they'd all retry at the same wall-clock instant and could
1934
+ * re-trip the account cap together.
1935
+ */
1936
+ CONNECTION_LIMIT_BACKOFF_JITTER_MS = 30_000;
1937
+ /**
1938
+ * Recency window within which a 406 is considered "still applicable"
1939
+ * to a subsequent reconnect-schedule call. The 406 message handler
1940
+ * stamps {@link lastConnectionLimitAt} and the `close` handler fires
1941
+ * shortly afterwards (sub-second typically) — the window is wide
1942
+ * enough to absorb scheduling delays without false-positives.
1943
+ */
1944
+ CONNECTION_LIMIT_RECENCY_MS = 30_000;
1903
1945
  setMode(mode = "production") {
1904
1946
  if (mode === "sandbox") {
1905
1947
  // sandbox mode
@@ -2031,6 +2073,18 @@ class AlpacaMarketDataAPI extends EventEmitter {
2031
2073
  }
2032
2074
  else if (message.T === "error") {
2033
2075
  log$1(`${streamType} stream error: ${message.msg} (code: ${message.code}, raw: ${JSON.stringify(message)})`, { type: "error" });
2076
+ // Alpaca code 406: "connection limit exceeded" — account-wide
2077
+ // concurrent-WS cap reached. The Alpaca server will close the
2078
+ // socket immediately after this frame, which would normally
2079
+ // trigger our standard sub-second exponential reconnect chain
2080
+ // (1 s, 2 s, 4 s, 8 s, 16 s, 30 s × 5) — exactly the wrong
2081
+ // behaviour against a rate-limit response. Stamp the recency
2082
+ // marker so {@link scheduleReconnect} switches to the
2083
+ // 5-minute jittered backoff instead.
2084
+ if (typeof message.code === "number" &&
2085
+ message.code === 406) {
2086
+ this.lastConnectionLimitAt[streamType] = Date.now();
2087
+ }
2034
2088
  }
2035
2089
  else if (message.S) {
2036
2090
  super.emit(`${streamType}-${message.T}`, message);
@@ -2064,6 +2118,37 @@ class AlpacaMarketDataAPI extends EventEmitter {
2064
2118
  });
2065
2119
  }
2066
2120
  scheduleReconnect(streamType) {
2121
+ // 406-recovery fast path. When the most recent close was preceded
2122
+ // by an Alpaca app-level 406 ("connection limit exceeded"), the
2123
+ // standard sub-second exponential ramp is exactly wrong — it
2124
+ // hammers the rate-limit endpoint and prolongs the slot pressure.
2125
+ // Use a 5-minute jittered backoff instead and reset the normal
2126
+ // attempt counter so we don't fall off the end of maxAttempts
2127
+ // and permanently give up on a transient rollover blip.
2128
+ const connectionLimitAt = this.lastConnectionLimitAt[streamType];
2129
+ const isRecentConnectionLimit = typeof connectionLimitAt === "number" &&
2130
+ Date.now() - connectionLimitAt <= this.CONNECTION_LIMIT_RECENCY_MS;
2131
+ if (isRecentConnectionLimit) {
2132
+ const jitter = Math.floor((Math.random() - 0.5) *
2133
+ 2 *
2134
+ this.CONNECTION_LIMIT_BACKOFF_JITTER_MS);
2135
+ const delayMs = this.CONNECTION_LIMIT_BACKOFF_MS + jitter;
2136
+ // Reset normal attempt counter so the next 406 retry doesn't
2137
+ // inherit a stale exponential cap.
2138
+ this.reconnectAttempts[streamType] = 0;
2139
+ // Consume the recency marker — subsequent reconnects fall back
2140
+ // to the standard exponential path unless a new 406 arrives.
2141
+ delete this.lastConnectionLimitAt[streamType];
2142
+ log$1(`${streamType} stream: Alpaca 406 connection-limit recovery — backing off ${Math.round(delayMs / 1000)}s before retry to allow account-wide slot release`, { type: "warn" });
2143
+ if (this.reconnectTimers[streamType]) {
2144
+ clearTimeout(this.reconnectTimers[streamType]);
2145
+ }
2146
+ this.reconnectTimers[streamType] = setTimeout(() => {
2147
+ log$1(`${streamType} stream: attempting reconnect after 406-recovery backoff`, { type: "info" });
2148
+ this.connect(streamType);
2149
+ }, delayMs);
2150
+ return;
2151
+ }
2067
2152
  const attempts = this.reconnectAttempts[streamType] ?? 0;
2068
2153
  const maxAttempts = 10;
2069
2154
  if (attempts >= maxAttempts) {