@adaptic/utils 0.0.965 → 0.0.966

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
@@ -640,6 +640,67 @@ class DisplayManager {
640
640
  }
641
641
  }
642
642
 
643
+ /**
644
+ * Configurable logger interface compatible with Pino and other logging libraries.
645
+ * Provides structured logging with context support.
646
+ */
647
+ /**
648
+ * Normalizes context to a format suitable for logging.
649
+ * Handles various types including primitives, objects, and errors.
650
+ */
651
+ function normalizeContext(context) {
652
+ if (context === undefined || context === null) {
653
+ return undefined;
654
+ }
655
+ if (typeof context === "object" && context !== null) {
656
+ // Handle Error objects
657
+ if (context instanceof Error) {
658
+ return {
659
+ error: {
660
+ message: context.message,
661
+ name: context.name,
662
+ stack: context.stack,
663
+ },
664
+ };
665
+ }
666
+ // Already an object, return as-is
667
+ return context;
668
+ }
669
+ // Primitive types - wrap in object
670
+ return { value: context };
671
+ }
672
+ /**
673
+ * Default logger implementation that uses console for backward compatibility.
674
+ * Formats messages in a simple readable format.
675
+ */
676
+ const defaultLogger = {
677
+ error: (msg, ctx) => console.error(msg, normalizeContext(ctx) || ""),
678
+ warn: (msg, ctx) => console.warn(msg, normalizeContext(ctx) || ""),
679
+ info: (msg, ctx) => console.info(msg, normalizeContext(ctx) || ""),
680
+ debug: (msg, ctx) => console.debug(msg, normalizeContext(ctx) || ""),
681
+ };
682
+ let currentLogger = defaultLogger;
683
+ /**
684
+ * Gets the current logger instance.
685
+ * Use this to log messages throughout the application.
686
+ *
687
+ * @returns The current logger instance
688
+ *
689
+ * @example
690
+ * ```typescript
691
+ * import { getLogger } from '@adaptic/utils';
692
+ *
693
+ * const logger = getLogger();
694
+ * logger.error('Operation failed', { userId: 123, operation: 'createOrder' });
695
+ * logger.warn('Rate limit approaching', { remaining: 10 });
696
+ * logger.info('Order created', { orderId: 'abc123', symbol: 'AAPL' });
697
+ * logger.debug('Cache hit', { key: 'user:123' });
698
+ * ```
699
+ */
700
+ function getLogger() {
701
+ return currentLogger;
702
+ }
703
+
643
704
  /**
644
705
  * Logs a message.
645
706
  *
@@ -1482,6 +1543,311 @@ function createTimeoutSignal(ms) {
1482
1543
  return AbortSignal.timeout(ms);
1483
1544
  }
1484
1545
 
1546
+ /**
1547
+ * Structured error type hierarchy for all API integrations
1548
+ *
1549
+ * This module provides a comprehensive error handling system for external API integrations,
1550
+ * including Alpaca, Massive, and AlphaVantage services.
1551
+ */
1552
+ /**
1553
+ * Base error class for all @adaptic/utils errors
1554
+ * Extends Error with additional context about service, error code, and retry capability
1555
+ */
1556
+ class AdapticUtilsError extends Error {
1557
+ code;
1558
+ service;
1559
+ isRetryable;
1560
+ cause;
1561
+ name;
1562
+ constructor(message, code, service, isRetryable = false, cause) {
1563
+ super(message);
1564
+ this.code = code;
1565
+ this.service = service;
1566
+ this.isRetryable = isRetryable;
1567
+ this.cause = cause;
1568
+ this.name = this.constructor.name;
1569
+ // Maintains proper stack trace for where error was thrown (only available on V8)
1570
+ if (Error.captureStackTrace) {
1571
+ Error.captureStackTrace(this, this.constructor);
1572
+ }
1573
+ }
1574
+ }
1575
+ /**
1576
+ * Rate limit errors (429)
1577
+ * Used when API rate limits are exceeded
1578
+ * Always retryable, often with retry-after header information
1579
+ */
1580
+ class RateLimitError extends AdapticUtilsError {
1581
+ service;
1582
+ retryAfterMs;
1583
+ constructor(message, service, retryAfterMs, cause) {
1584
+ super(message, "RATE_LIMIT", service, true, // Rate limit errors are always retryable
1585
+ cause);
1586
+ this.service = service;
1587
+ this.retryAfterMs = retryAfterMs;
1588
+ }
1589
+ }
1590
+
1591
+ /**
1592
+ * Token bucket rate limiter for external API integrations
1593
+ *
1594
+ * Implements client-side rate limiting to prevent exceeding API quotas
1595
+ * and ensure fair usage of external services like Alpaca, Massive, and AlphaVantage.
1596
+ *
1597
+ * @example
1598
+ * ```typescript
1599
+ * import { rateLimiters } from '@adaptic/utils';
1600
+ *
1601
+ * // Before making an API call
1602
+ * await rateLimiters.alpaca.acquire();
1603
+ * const result = await makeAlpacaApiCall();
1604
+ * ```
1605
+ */
1606
+ /**
1607
+ * Token bucket rate limiter implementation
1608
+ *
1609
+ * Uses the token bucket algorithm to control the rate of API requests.
1610
+ * Tokens are consumed on each request and refilled at a constant rate.
1611
+ * Requests that exceed the available tokens are queued and processed
1612
+ * when tokens become available.
1613
+ */
1614
+ class TokenBucketRateLimiter {
1615
+ config;
1616
+ tokens;
1617
+ lastRefill;
1618
+ queue = [];
1619
+ timeoutMs;
1620
+ processingQueue = false;
1621
+ /**
1622
+ * Creates a new rate limiter instance
1623
+ *
1624
+ * @param config - Rate limiter configuration
1625
+ *
1626
+ * @example
1627
+ * ```typescript
1628
+ * // Alpaca: 200 requests per minute
1629
+ * const alpacaLimiter = new TokenBucketRateLimiter({
1630
+ * maxTokens: 200,
1631
+ * refillRate: 200 / 60, // ~3.33 per second
1632
+ * label: 'alpaca',
1633
+ * timeoutMs: 60000
1634
+ * });
1635
+ * ```
1636
+ */
1637
+ constructor(config) {
1638
+ this.config = config;
1639
+ this.tokens = config.maxTokens;
1640
+ this.lastRefill = Date.now();
1641
+ this.timeoutMs = config.timeoutMs ?? 60000; // Default 60 second timeout
1642
+ }
1643
+ /**
1644
+ * Acquires a token for making an API request
1645
+ *
1646
+ * If a token is available, it is consumed immediately.
1647
+ * If no tokens are available, the request is queued and will resolve
1648
+ * when a token becomes available or reject if it times out.
1649
+ *
1650
+ * @throws {RateLimitError} If the request times out waiting for a token
1651
+ *
1652
+ * @example
1653
+ * ```typescript
1654
+ * try {
1655
+ * await limiter.acquire();
1656
+ * // Make API call
1657
+ * } catch (error) {
1658
+ * if (error instanceof RateLimitError) {
1659
+ * // Handle rate limit timeout
1660
+ * }
1661
+ * }
1662
+ * ```
1663
+ */
1664
+ async acquire() {
1665
+ const logger = getLogger();
1666
+ this.refill();
1667
+ if (this.tokens > 0) {
1668
+ this.tokens--;
1669
+ logger.debug(`Rate limit token acquired for ${this.config.label}`, {
1670
+ remainingTokens: this.tokens,
1671
+ queueLength: this.queue.length,
1672
+ });
1673
+ return;
1674
+ }
1675
+ // No tokens available, queue the request
1676
+ logger.debug(`Rate limit reached for ${this.config.label}, queuing request`, {
1677
+ queueLength: this.queue.length,
1678
+ });
1679
+ return new Promise((resolve, reject) => {
1680
+ const timeoutHandle = setTimeout(() => {
1681
+ // Remove from queue on timeout
1682
+ const index = this.queue.findIndex((req) => req.timeoutHandle === timeoutHandle);
1683
+ if (index !== -1) {
1684
+ this.queue.splice(index, 1);
1685
+ }
1686
+ const error = new RateLimitError(`Rate limit timeout for ${this.config.label} after ${this.timeoutMs}ms`, this.config.label, undefined);
1687
+ logger.warn(`Rate limit timeout for ${this.config.label}`, {
1688
+ queueLength: this.queue.length,
1689
+ timeoutMs: this.timeoutMs,
1690
+ });
1691
+ reject(error);
1692
+ }, this.timeoutMs);
1693
+ this.queue.push({ resolve, reject, timeoutHandle });
1694
+ });
1695
+ }
1696
+ /**
1697
+ * Refills tokens based on elapsed time and processes queued requests
1698
+ *
1699
+ * Tokens are refilled at the configured rate up to the maximum capacity.
1700
+ * If tokens are available after refilling, queued requests are processed.
1701
+ */
1702
+ refill() {
1703
+ const now = Date.now();
1704
+ const elapsed = (now - this.lastRefill) / 1000; // Convert to seconds
1705
+ const tokensToAdd = elapsed * this.config.refillRate;
1706
+ this.tokens = Math.min(this.config.maxTokens, this.tokens + tokensToAdd);
1707
+ this.lastRefill = now;
1708
+ // Process queued requests if we have tokens
1709
+ this.processQueue();
1710
+ }
1711
+ /**
1712
+ * Processes queued requests when tokens are available
1713
+ *
1714
+ * Prevents concurrent queue processing to ensure FIFO order.
1715
+ */
1716
+ processQueue() {
1717
+ // Prevent concurrent queue processing
1718
+ if (this.processingQueue) {
1719
+ return;
1720
+ }
1721
+ this.processingQueue = true;
1722
+ const logger = getLogger();
1723
+ try {
1724
+ while (this.queue.length > 0 && this.tokens > 0) {
1725
+ this.tokens--;
1726
+ const next = this.queue.shift();
1727
+ if (next) {
1728
+ clearTimeout(next.timeoutHandle);
1729
+ next.resolve();
1730
+ logger.debug(`Processed queued request for ${this.config.label}`, {
1731
+ remainingTokens: this.tokens,
1732
+ remainingQueue: this.queue.length,
1733
+ });
1734
+ }
1735
+ }
1736
+ }
1737
+ finally {
1738
+ this.processingQueue = false;
1739
+ }
1740
+ }
1741
+ /**
1742
+ * Gets the current number of available tokens
1743
+ *
1744
+ * @returns Number of tokens currently available
1745
+ */
1746
+ getAvailableTokens() {
1747
+ this.refill();
1748
+ return Math.floor(this.tokens);
1749
+ }
1750
+ /**
1751
+ * Gets the current queue length
1752
+ *
1753
+ * @returns Number of requests waiting for tokens
1754
+ */
1755
+ getQueueLength() {
1756
+ return this.queue.length;
1757
+ }
1758
+ /**
1759
+ * Clears all queued requests and resets the token bucket
1760
+ *
1761
+ * All queued requests will be rejected with a RateLimitError.
1762
+ * Useful for cleanup or when changing rate limit configurations.
1763
+ */
1764
+ reset() {
1765
+ const logger = getLogger();
1766
+ // Reject all queued requests
1767
+ for (const request of this.queue) {
1768
+ clearTimeout(request.timeoutHandle);
1769
+ request.reject(new RateLimitError(`Rate limiter reset for ${this.config.label}`, this.config.label, undefined));
1770
+ }
1771
+ this.queue = [];
1772
+ this.tokens = this.config.maxTokens;
1773
+ this.lastRefill = Date.now();
1774
+ this.processingQueue = false;
1775
+ logger.info(`Rate limiter reset for ${this.config.label}`, {
1776
+ maxTokens: this.config.maxTokens,
1777
+ });
1778
+ }
1779
+ }
1780
+ /**
1781
+ * Pre-configured rate limiters for common external APIs
1782
+ *
1783
+ * These limiters are configured based on the documented rate limits
1784
+ * for each service. Adjust the configurations if you have different
1785
+ * tier access or if limits change.
1786
+ *
1787
+ * @example
1788
+ * ```typescript
1789
+ * import { rateLimiters } from '@adaptic/utils';
1790
+ *
1791
+ * // Use before making API calls
1792
+ * await rateLimiters.alpaca.acquire();
1793
+ * await rateLimiters.massive.acquire();
1794
+ * await rateLimiters.alphaVantage.acquire();
1795
+ * ```
1796
+ */
1797
+ const rateLimiters = {
1798
+ /**
1799
+ * Alpaca API rate limiter
1800
+ *
1801
+ * Configured for 1000 requests per minute (paid tier).
1802
+ * The token bucket allows burst up to maxTokens, then refills at the steady rate.
1803
+ * See: https://alpaca.markets/docs/api-references/trading-api/#rate-limit
1804
+ */
1805
+ alpaca: new TokenBucketRateLimiter({
1806
+ maxTokens: 1000,
1807
+ refillRate: 1000 / 60, // 1000 requests per 60 seconds (~16.67/sec)
1808
+ label: "alpaca",
1809
+ timeoutMs: 60000,
1810
+ }),
1811
+ /**
1812
+ * Massive.com API rate limiter
1813
+ *
1814
+ * Configured generously for paid unlimited tier. The bucket exists only as a
1815
+ * safety net against runaway loops — the 1000 token burst and 500/sec refill
1816
+ * should never be hit under normal operation. If the paid plan truly has no
1817
+ * hard limit, this just prevents accidental self-DDoS.
1818
+ */
1819
+ massive: new TokenBucketRateLimiter({
1820
+ maxTokens: 1000,
1821
+ refillRate: 500, // 500 tokens/sec refill — effectively unlimited for paid tier
1822
+ label: "massive",
1823
+ timeoutMs: 30000,
1824
+ }),
1825
+ /**
1826
+ * AlphaVantage API rate limiter
1827
+ *
1828
+ * Configured for 5 requests per minute (free tier).
1829
+ * For premium tier (75/min), create a custom limiter:
1830
+ *
1831
+ * @example
1832
+ * ```typescript
1833
+ * const premiumAV = new TokenBucketRateLimiter({
1834
+ * maxTokens: 75,
1835
+ * refillRate: 75 / 60,
1836
+ * label: 'alphaVantage-premium',
1837
+ * timeoutMs: 60000,
1838
+ * });
1839
+ * ```
1840
+ *
1841
+ * See: https://www.alphavantage.co/premium/
1842
+ */
1843
+ alphaVantage: new TokenBucketRateLimiter({
1844
+ maxTokens: 5,
1845
+ refillRate: 5 / 60, // 5 requests per 60 seconds (~0.083/sec)
1846
+ label: "alphaVantage",
1847
+ timeoutMs: 60000,
1848
+ }),
1849
+ };
1850
+
1485
1851
  const log$1 = (message, options = { type: "info" }) => {
1486
1852
  log$2(message, { ...options, source: "AlpacaMarketDataAPI" });
1487
1853
  };
@@ -1888,6 +2254,11 @@ class AlpacaMarketDataAPI extends EventEmitter {
1888
2254
  }
1889
2255
  });
1890
2256
  }
2257
+ // Gate all Alpaca market-data calls through the shared 1000/min token
2258
+ // bucket so concurrent callers (bar fetches, quotes, options, snapshots)
2259
+ // can't overrun Alpaca's server-side rate limit. Prior to this, parallel
2260
+ // historical-bar fan-out produced ~125 server-side 429s per minute.
2261
+ await rateLimiters.alpaca.acquire();
1891
2262
  const response = await fetch(url.toString(), {
1892
2263
  method,
1893
2264
  headers: this.headers,