@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.
- package/dist/index.cjs +548 -104
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +549 -106
- package/dist/index.mjs.map +1 -1
- package/dist/test.js +120 -9
- package/dist/test.js.map +1 -1
- package/dist/types/__tests__/alpaca-client-order-id.test.d.ts +2 -0
- package/dist/types/__tests__/alpaca-client-order-id.test.d.ts.map +1 -0
- package/dist/types/__tests__/alpaca-market-data-retry.test.d.ts +2 -0
- package/dist/types/__tests__/alpaca-market-data-retry.test.d.ts.map +1 -0
- package/dist/types/__tests__/performance-metrics-fees.test.d.ts +2 -0
- package/dist/types/__tests__/performance-metrics-fees.test.d.ts.map +1 -0
- package/dist/types/__tests__/price-utils-fees.test.d.ts +2 -0
- package/dist/types/__tests__/price-utils-fees.test.d.ts.map +1 -0
- package/dist/types/alpaca-market-data-api.d.ts.map +1 -1
- package/dist/types/alpaca-trading-api.d.ts +93 -8
- package/dist/types/alpaca-trading-api.d.ts.map +1 -1
- package/dist/types/cache/stampede-protected-cache.d.ts +47 -1
- package/dist/types/cache/stampede-protected-cache.d.ts.map +1 -1
- package/dist/types/errors/index.d.ts +25 -0
- package/dist/types/errors/index.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/performance-metrics.d.ts +31 -1
- package/dist/types/performance-metrics.d.ts.map +1 -1
- package/dist/types/price-utils.d.ts +12 -0
- package/dist/types/price-utils.d.ts.map +1 -1
- package/dist/types/rate-limiter.d.ts.map +1 -1
- package/dist/types/utils/retry.d.ts +14 -0
- package/dist/types/utils/retry.d.ts.map +1 -1
- 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
|
-
|
|
1775
|
-
|
|
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
|
-
|
|
1872
|
-
|
|
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
|
-
|
|
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",
|