@adaptic/utils 0.0.1007 → 0.0.1009

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.
Files changed (31) hide show
  1. package/dist/index.cjs +548 -104
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.mjs +549 -106
  4. package/dist/index.mjs.map +1 -1
  5. package/dist/test.js +120 -9
  6. package/dist/test.js.map +1 -1
  7. package/dist/types/__tests__/alpaca-client-order-id.test.d.ts +2 -0
  8. package/dist/types/__tests__/alpaca-client-order-id.test.d.ts.map +1 -0
  9. package/dist/types/__tests__/alpaca-market-data-retry.test.d.ts +2 -0
  10. package/dist/types/__tests__/alpaca-market-data-retry.test.d.ts.map +1 -0
  11. package/dist/types/__tests__/performance-metrics-fees.test.d.ts +2 -0
  12. package/dist/types/__tests__/performance-metrics-fees.test.d.ts.map +1 -0
  13. package/dist/types/__tests__/price-utils-fees.test.d.ts +2 -0
  14. package/dist/types/__tests__/price-utils-fees.test.d.ts.map +1 -0
  15. package/dist/types/alpaca-market-data-api.d.ts.map +1 -1
  16. package/dist/types/alpaca-trading-api.d.ts +93 -8
  17. package/dist/types/alpaca-trading-api.d.ts.map +1 -1
  18. package/dist/types/cache/stampede-protected-cache.d.ts +47 -1
  19. package/dist/types/cache/stampede-protected-cache.d.ts.map +1 -1
  20. package/dist/types/errors/index.d.ts +25 -0
  21. package/dist/types/errors/index.d.ts.map +1 -1
  22. package/dist/types/index.d.ts +2 -2
  23. package/dist/types/index.d.ts.map +1 -1
  24. package/dist/types/performance-metrics.d.ts +31 -1
  25. package/dist/types/performance-metrics.d.ts.map +1 -1
  26. package/dist/types/price-utils.d.ts +12 -0
  27. package/dist/types/price-utils.d.ts.map +1 -1
  28. package/dist/types/rate-limiter.d.ts.map +1 -1
  29. package/dist/types/utils/retry.d.ts +14 -0
  30. package/dist/types/utils/retry.d.ts.map +1 -1
  31. package/package.json +1 -1
package/dist/test.js CHANGED
@@ -1629,6 +1629,80 @@ function isTransientNetworkError(error) {
1629
1629
  }
1630
1630
  return false;
1631
1631
  }
1632
+ /**
1633
+ * Error names that indicate the CLIENT's own request deadline expired (an
1634
+ * `AbortSignal` timeout or an undici per-phase timeout) rather than a
1635
+ * connection-phase fault. These faults have already consumed a full request
1636
+ * timeout, so retrying them is expensive by construction.
1637
+ */
1638
+ const DEADLINE_EXPIRY_ERROR_NAMES = new Set([
1639
+ "AbortError",
1640
+ "TimeoutError",
1641
+ "RequestTimeoutError",
1642
+ "ConnectTimeoutError",
1643
+ "HeadersTimeoutError",
1644
+ "BodyTimeoutError",
1645
+ ]);
1646
+ /**
1647
+ * Error codes that indicate an expired request/phase deadline (vs a fast
1648
+ * connection-phase fault such as `ECONNRESET`/`ECONNREFUSED`).
1649
+ */
1650
+ const DEADLINE_EXPIRY_ERROR_CODES = new Set([
1651
+ "ETIMEDOUT",
1652
+ "ESOCKETTIMEDOUT",
1653
+ "ECONNABORTED",
1654
+ "UND_ERR_CONNECT_TIMEOUT",
1655
+ "UND_ERR_HEADERS_TIMEOUT",
1656
+ "UND_ERR_BODY_TIMEOUT",
1657
+ ]);
1658
+ /** Message-pattern fallback for deadline-expiry errors that lost name/code. */
1659
+ const DEADLINE_EXPIRY_MESSAGE_PATTERNS = [
1660
+ /timed out/i,
1661
+ /timeout/i,
1662
+ /aborted/i,
1663
+ ];
1664
+ /**
1665
+ * Whether an error represents the client's OWN deadline expiring (abort /
1666
+ * timeout) rather than a connection-phase network fault. Both classes are
1667
+ * "transient" per {@link isTransientNetworkError}, but they have very
1668
+ * different retry economics: a connection fault (`ECONNRESET`, `EPIPE`,
1669
+ * refused socket) settles in milliseconds and is cheap to retry, while a
1670
+ * deadline expiry has already consumed the full per-attempt timeout — blindly
1671
+ * retrying it multiplies time-to-failure exactly when the caller most needs
1672
+ * to fail fast. Walks the `error.cause` chain like the transient classifier.
1673
+ *
1674
+ * @param error - The error to classify.
1675
+ * @returns true when the fault is a client deadline/abort expiry.
1676
+ */
1677
+ function isClientDeadlineExpiry(error) {
1678
+ const MAX_CAUSE_DEPTH = 6;
1679
+ let current = error;
1680
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && current; depth++) {
1681
+ if (current instanceof Error || typeof current === "object") {
1682
+ const err = current;
1683
+ if (typeof err.name === "string" &&
1684
+ DEADLINE_EXPIRY_ERROR_NAMES.has(err.name)) {
1685
+ return true;
1686
+ }
1687
+ if (typeof err.code === "string" &&
1688
+ DEADLINE_EXPIRY_ERROR_CODES.has(err.code)) {
1689
+ return true;
1690
+ }
1691
+ if (typeof err.message === "string") {
1692
+ for (const pattern of DEADLINE_EXPIRY_MESSAGE_PATTERNS) {
1693
+ if (pattern.test(err.message)) {
1694
+ return true;
1695
+ }
1696
+ }
1697
+ }
1698
+ current = err.cause;
1699
+ }
1700
+ else {
1701
+ break;
1702
+ }
1703
+ }
1704
+ return false;
1705
+ }
1632
1706
 
1633
1707
  /**
1634
1708
  * Structured error type hierarchy for all API integrations
@@ -1771,8 +1845,11 @@ class TokenBucketRateLimiter {
1771
1845
  async acquire() {
1772
1846
  const logger = getLogger();
1773
1847
  this.refill();
1774
- if (this.tokens > 0) {
1775
- this.tokens--;
1848
+ // Require a WHOLE token: refill() accrues fractionally, and admitting on
1849
+ // any positive fraction would release a full request per accrual tick,
1850
+ // driving the bucket negative and overrunning the configured rate.
1851
+ if (this.tokens >= TOKENS_PER_REQUEST) {
1852
+ this.tokens -= TOKENS_PER_REQUEST;
1776
1853
  logger.debug(`Rate limit token acquired for ${this.config.label}`, {
1777
1854
  remainingTokens: this.tokens,
1778
1855
  queueLength: this.queue.length,
@@ -1868,8 +1945,9 @@ class TokenBucketRateLimiter {
1868
1945
  this.processingQueue = true;
1869
1946
  const logger = getLogger();
1870
1947
  try {
1871
- while (this.queue.length > 0 && this.tokens > 0) {
1872
- this.tokens--;
1948
+ // Whole-token admission — see the matching guard in acquire().
1949
+ while (this.queue.length > 0 && this.tokens >= TOKENS_PER_REQUEST) {
1950
+ this.tokens -= TOKENS_PER_REQUEST;
1873
1951
  const next = this.queue.shift();
1874
1952
  if (next) {
1875
1953
  clearTimeout(next.timeoutHandle);
@@ -2031,6 +2109,23 @@ function transientRetryDelayMs(attempt) {
2031
2109
  const ceiling = TRANSIENT_NETWORK_RETRY_BASE_MS * 2 ** attempt;
2032
2110
  return Math.floor(Math.random() * ceiling);
2033
2111
  }
2112
+ /**
2113
+ * Total-deadline multiple over the per-attempt client timeout for one
2114
+ * {@link AlpacaMarketDataAPI.makeRequest} call INCLUDING transient retries.
2115
+ * The ECONNRESET class this loop targets settles in milliseconds, so the
2116
+ * retries fit comfortably inside 1.5x the single-attempt timeout — while a
2117
+ * request whose attempts each consume the full client timeout is refused
2118
+ * further retries instead of stretching a hot-path read to a multi-minute
2119
+ * stall (worst case before this budget: 3 x (60s limiter wait + 30s fetch)).
2120
+ */
2121
+ const TRANSIENT_RETRY_BUDGET_TIMEOUT_MULTIPLE = 1.5;
2122
+ /**
2123
+ * Maximum retries for a fault classified as the client's OWN deadline expiry
2124
+ * (see {@link isClientDeadlineExpiry}): such a fault already consumed a full
2125
+ * per-attempt timeout, so it is retried at most once — and then only if the
2126
+ * total budget still allows a further full-length attempt.
2127
+ */
2128
+ const CLIENT_DEADLINE_EXPIRY_MAX_RETRIES = 1;
2034
2129
  const log$1 = (message, options = { type: "info" }) => {
2035
2130
  log$2(message, { ...options, source: "AlpacaMarketDataAPI" });
2036
2131
  };
@@ -2544,7 +2639,15 @@ class AlpacaMarketDataAPI extends EventEmitter {
2544
2639
  // Retry ONLY transient connection faults, and only on GET (every
2545
2640
  // market-data read here is idempotent). A non-2xx response is a real
2546
2641
  // answer from Alpaca and is never retried — that path still throws on
2547
- // the first attempt exactly as before.
2642
+ // the first attempt exactly as before. The whole loop is bounded by a
2643
+ // cumulative deadline so retries can never stretch a hot-path read far
2644
+ // beyond a single attempt's timeout: connection-phase faults
2645
+ // (ECONNRESET class, millisecond-scale) retry cheaply, while a fault
2646
+ // that consumed the full client timeout retries at most once and only
2647
+ // when the remaining budget still fits a full-length attempt.
2648
+ const retryLoopStartedAt = Date.now();
2649
+ const totalBudgetMs = Math.round(DEFAULT_TIMEOUTS.ALPACA_API * TRANSIENT_RETRY_BUDGET_TIMEOUT_MULTIPLE);
2650
+ let deadlineExpiryRetries = 0;
2548
2651
  let response;
2549
2652
  let lastNetworkError;
2550
2653
  for (let attempt = 0; attempt < TRANSIENT_NETWORK_RETRY_ATTEMPTS; attempt += 1) {
@@ -2558,12 +2661,21 @@ class AlpacaMarketDataAPI extends EventEmitter {
2558
2661
  }
2559
2662
  catch (networkErr) {
2560
2663
  lastNetworkError = networkErr;
2664
+ const deadlineExpiry = isClientDeadlineExpiry(networkErr);
2665
+ const nextAttemptFitsBudget = Date.now() - retryLoopStartedAt + DEFAULT_TIMEOUTS.ALPACA_API <=
2666
+ totalBudgetMs;
2561
2667
  const retryable = method === "GET" &&
2562
2668
  isTransientNetworkError(networkErr) &&
2563
- attempt < TRANSIENT_NETWORK_RETRY_ATTEMPTS - 1;
2669
+ attempt < TRANSIENT_NETWORK_RETRY_ATTEMPTS - 1 &&
2670
+ nextAttemptFitsBudget &&
2671
+ (!deadlineExpiry ||
2672
+ deadlineExpiryRetries < CLIENT_DEADLINE_EXPIRY_MAX_RETRIES);
2564
2673
  if (!retryable) {
2565
2674
  throw networkErr;
2566
2675
  }
2676
+ if (deadlineExpiry) {
2677
+ deadlineExpiryRetries += 1;
2678
+ }
2567
2679
  const delayMs = transientRetryDelayMs(attempt);
2568
2680
  log$1(`Transient network fault on ${endpoint} (attempt ${attempt + 1}/${TRANSIENT_NETWORK_RETRY_ATTEMPTS}); retrying in ${delayMs}ms`, { type: "warn" });
2569
2681
  await new Promise((resolve) => setTimeout(resolve, delayMs));
@@ -2584,8 +2696,7 @@ class AlpacaMarketDataAPI extends EventEmitter {
2584
2696
  });
2585
2697
  throw new Error(`Market Data API error (${response.status}): ${errorText}`);
2586
2698
  }
2587
- const data = await response.json();
2588
- return data;
2699
+ return (await response.json());
2589
2700
  }
2590
2701
  catch (err) {
2591
2702
  const error = err;
@@ -3272,7 +3383,7 @@ class AlpacaMarketDataAPI extends EventEmitter {
3272
3383
  });
3273
3384
  throw new Error(`Alpaca news API error (${response.status}): ${errorText}`);
3274
3385
  }
3275
- const data = await response.json();
3386
+ const data = (await response.json());
3276
3387
  if (!data.news || !Array.isArray(data.news)) {
3277
3388
  log$1(`No news data found in Alpaca response for ${symbol}`, {
3278
3389
  type: "warn",