@adaptic/utils 0.0.979 → 0.0.981

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 CHANGED
@@ -3801,6 +3801,419 @@ class AlpacaMarketDataAPI extends require$$0$1.EventEmitter {
3801
3801
  // Export the singleton instance
3802
3802
  const marketDataAPI = AlpacaMarketDataAPI.getInstance();
3803
3803
 
3804
+ const DEFAULT_RETRY_CONFIG = {
3805
+ maxRetries: 3,
3806
+ baseDelayMs: 1000,
3807
+ maxDelayMs: 30000,
3808
+ retryableStatusCodes: [429, 500, 502, 503, 504],
3809
+ retryOnNetworkError: true,
3810
+ };
3811
+ /**
3812
+ * Node.js / undici / system error codes that represent transient network
3813
+ * conditions. Present on `error.code` for net/http/dns/undici errors.
3814
+ */
3815
+ const RETRYABLE_ERROR_CODES = new Set([
3816
+ "ETIMEDOUT",
3817
+ "ESOCKETTIMEDOUT",
3818
+ "ECONNRESET",
3819
+ "ECONNREFUSED",
3820
+ "EHOSTUNREACH",
3821
+ "ENETUNREACH",
3822
+ "EAI_AGAIN",
3823
+ "EPIPE",
3824
+ "ECONNABORTED",
3825
+ "ENOTFOUND",
3826
+ "UND_ERR_CONNECT_TIMEOUT",
3827
+ "UND_ERR_HEADERS_TIMEOUT",
3828
+ "UND_ERR_BODY_TIMEOUT",
3829
+ "UND_ERR_SOCKET",
3830
+ "UND_ERR_CLOSED",
3831
+ "UND_ERR_REQ_CONTENT_LENGTH_MISMATCH",
3832
+ ]);
3833
+ /**
3834
+ * Error constructor names / `error.name` values that indicate transient
3835
+ * abort / timeout conditions.
3836
+ */
3837
+ const RETRYABLE_ERROR_NAMES = new Set([
3838
+ "AbortError",
3839
+ "TimeoutError",
3840
+ "FetchError",
3841
+ "RequestTimeoutError",
3842
+ "ConnectTimeoutError",
3843
+ "HeadersTimeoutError",
3844
+ "BodyTimeoutError",
3845
+ ]);
3846
+ /**
3847
+ * Message-pattern fallback for libraries that discard error codes/names but
3848
+ * preserve text (e.g., some Apollo/axios wrappers).
3849
+ */
3850
+ const RETRYABLE_MESSAGE_PATTERNS = [
3851
+ /aborted/i,
3852
+ /timeout/i,
3853
+ /timed out/i,
3854
+ /network error/i,
3855
+ /socket hang up/i,
3856
+ /connection (reset|refused|closed)/i,
3857
+ /ECONNRESET/,
3858
+ /ETIMEDOUT/,
3859
+ /ECONNREFUSED/,
3860
+ /EAI_AGAIN/,
3861
+ /UND_ERR_/,
3862
+ ];
3863
+ /**
3864
+ * Walks the `error.cause` chain (capped to avoid cycles) and tests whether
3865
+ * any link along the chain looks like a transient network error. Modern APIs
3866
+ * (undici, fetch, Apollo Client 3.8+) wrap the root network failure as a
3867
+ * `.cause`, so the surface `Error` may report a generic message while the
3868
+ * actionable signal lives one or more levels deeper.
3869
+ *
3870
+ * Exported for use by downstream consumers (engine services, per-call catch
3871
+ * blocks, application-level loggers) that need to demote recoverable
3872
+ * transient errors from ERROR to WARN. Aligns the whole stack on a single
3873
+ * canonical classifier so MassiveAPI, AlpacaAPI, and application-layer
3874
+ * retry handlers all treat the same network blips identically.
3875
+ */
3876
+ function isTransientNetworkError(error) {
3877
+ const MAX_CAUSE_DEPTH = 6;
3878
+ let current = error;
3879
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && current; depth++) {
3880
+ if (current instanceof Error || typeof current === "object") {
3881
+ const err = current;
3882
+ if (typeof err.name === "string" && RETRYABLE_ERROR_NAMES.has(err.name)) {
3883
+ return true;
3884
+ }
3885
+ if (typeof err.code === "string" && RETRYABLE_ERROR_CODES.has(err.code)) {
3886
+ return true;
3887
+ }
3888
+ if (typeof err.message === "string") {
3889
+ for (const pattern of RETRYABLE_MESSAGE_PATTERNS) {
3890
+ if (pattern.test(err.message)) {
3891
+ return true;
3892
+ }
3893
+ }
3894
+ }
3895
+ current = err.cause;
3896
+ }
3897
+ else {
3898
+ break;
3899
+ }
3900
+ }
3901
+ return false;
3902
+ }
3903
+ /**
3904
+ * Analyzes an error and determines if it's retryable.
3905
+ * @param error - The error to analyze
3906
+ * @param response - Optional Response object for HTTP errors
3907
+ * @param config - Retry configuration
3908
+ * @returns Structured error details
3909
+ */
3910
+ function analyzeError(error, response, config) {
3911
+ // Handle Response objects with error status codes
3912
+ if (response && !response.ok) {
3913
+ const status = response.status;
3914
+ // Rate limit errors - always retryable
3915
+ if (status === 429) {
3916
+ const retryAfterHeader = response.headers.get("Retry-After");
3917
+ const retryAfter = retryAfterHeader
3918
+ ? parseInt(retryAfterHeader, 10) * 1000
3919
+ : undefined;
3920
+ return {
3921
+ type: "RATE_LIMIT",
3922
+ reason: "Rate limit exceeded",
3923
+ status,
3924
+ retryAfter,
3925
+ isRetryable: true,
3926
+ };
3927
+ }
3928
+ // Authentication errors - never retry
3929
+ if (status === 401 || status === 403) {
3930
+ return {
3931
+ type: "AUTH_ERROR",
3932
+ reason: status === 401
3933
+ ? "Authentication failed - invalid credentials"
3934
+ : "Access forbidden - insufficient permissions",
3935
+ status,
3936
+ isRetryable: false,
3937
+ };
3938
+ }
3939
+ // Server errors - check if in retryable list
3940
+ if (status >= 500 && status < 600) {
3941
+ return {
3942
+ type: "SERVER_ERROR",
3943
+ reason: `Server error (${status})`,
3944
+ status,
3945
+ isRetryable: config.retryableStatusCodes.includes(status),
3946
+ };
3947
+ }
3948
+ // Other client errors - never retry
3949
+ if (status >= 400 && status < 500) {
3950
+ return {
3951
+ type: "CLIENT_ERROR",
3952
+ reason: `Client error (${status})`,
3953
+ status,
3954
+ isRetryable: false,
3955
+ };
3956
+ }
3957
+ }
3958
+ // Handle network errors (TypeError from fetch API)
3959
+ if (error instanceof TypeError && error.message.includes("fetch")) {
3960
+ return {
3961
+ type: "NETWORK_ERROR",
3962
+ reason: "Network connectivity issue",
3963
+ status: null,
3964
+ isRetryable: config.retryOnNetworkError,
3965
+ };
3966
+ }
3967
+ // Handle transient network conditions: AbortError, TimeoutError,
3968
+ // Node/undici error codes (ETIMEDOUT, ECONNRESET, UND_ERR_*), and
3969
+ // wrapped failures exposed via error.cause. This catches the broad class
3970
+ // of infrastructure flakes that the TypeError-only check above misses.
3971
+ if (isTransientNetworkError(error)) {
3972
+ const reason = error instanceof Error ? error.message : "Transient network error";
3973
+ return {
3974
+ type: "NETWORK_ERROR",
3975
+ reason,
3976
+ status: null,
3977
+ isRetryable: config.retryOnNetworkError,
3978
+ };
3979
+ }
3980
+ // Handle error objects with messages
3981
+ if (error instanceof Error) {
3982
+ // Parse error messages that might contain status information
3983
+ if (error.message.includes("429") || error.message.includes("RATE_LIMIT")) {
3984
+ const match = error.message.match(/RATE_LIMIT: 429:(\d+)/);
3985
+ const retryAfter = match ? parseInt(match[1], 10) : undefined;
3986
+ return {
3987
+ type: "RATE_LIMIT",
3988
+ reason: "Rate limit exceeded",
3989
+ status: 429,
3990
+ retryAfter,
3991
+ isRetryable: true,
3992
+ };
3993
+ }
3994
+ if (error.message.includes("401") ||
3995
+ error.message.includes("403") ||
3996
+ error.message.includes("AUTH_ERROR")) {
3997
+ const status = error.message.includes("401") ? 401 : 403;
3998
+ return {
3999
+ type: "AUTH_ERROR",
4000
+ reason: `Authentication error (${status})`,
4001
+ status,
4002
+ isRetryable: false,
4003
+ };
4004
+ }
4005
+ if (error.message.includes("SERVER_ERROR") ||
4006
+ error.message.match(/50[0-9]/)) {
4007
+ const statusMatch = error.message.match(/50[0-9]/);
4008
+ const status = statusMatch ? parseInt(statusMatch[0], 10) : 500;
4009
+ return {
4010
+ type: "SERVER_ERROR",
4011
+ reason: `Server error (${status})`,
4012
+ status,
4013
+ isRetryable: config.retryableStatusCodes.includes(status),
4014
+ };
4015
+ }
4016
+ if (error.message.includes("network") ||
4017
+ error.message.includes("NETWORK_ERROR")) {
4018
+ return {
4019
+ type: "NETWORK_ERROR",
4020
+ reason: error.message,
4021
+ status: null,
4022
+ isRetryable: config.retryOnNetworkError,
4023
+ };
4024
+ }
4025
+ }
4026
+ // Unknown error - not retryable by default for safety
4027
+ return {
4028
+ type: "UNKNOWN",
4029
+ reason: error instanceof Error ? error.message : String(error),
4030
+ status: null,
4031
+ isRetryable: false,
4032
+ };
4033
+ }
4034
+ /**
4035
+ * Calculates the delay before the next retry attempt using exponential backoff with jitter.
4036
+ * @param attempt - Current attempt number (1-indexed)
4037
+ * @param baseDelay - Base delay in milliseconds
4038
+ * @param maxDelay - Maximum delay in milliseconds
4039
+ * @returns Delay in milliseconds
4040
+ */
4041
+ function calculateBackoff(attempt, baseDelay, maxDelay) {
4042
+ // Exponential backoff: baseDelay * 2^(attempt-1)
4043
+ const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
4044
+ // Cap at maxDelay
4045
+ const cappedDelay = Math.min(exponentialDelay, maxDelay);
4046
+ // Add jitter (random value between 0% and 25% of the delay)
4047
+ const jitter = Math.random() * cappedDelay * 0.25;
4048
+ return Math.floor(cappedDelay + jitter);
4049
+ }
4050
+ /**
4051
+ * Wraps an async function with retry logic and exponential backoff.
4052
+ *
4053
+ * This utility handles transient errors in external API calls by automatically retrying
4054
+ * failed requests with intelligent backoff strategies. It respects rate limit headers,
4055
+ * fails fast on non-retryable errors, and provides detailed logging.
4056
+ *
4057
+ * @template T - The return type of the wrapped function
4058
+ * @param fn - The async function to wrap with retry logic
4059
+ * @param config - Retry configuration (merged with defaults)
4060
+ * @param label - A descriptive label for logging (e.g., 'Massive.fetchTickerInfo')
4061
+ * @returns A promise that resolves to the function's return value
4062
+ * @throws The last error encountered if all retries are exhausted
4063
+ *
4064
+ * @example
4065
+ * ```typescript
4066
+ * // Basic usage with defaults
4067
+ * const data = await withRetry(
4068
+ * async () => fetch('https://api.example.com/data'),
4069
+ * {},
4070
+ * 'ExampleAPI.fetchData'
4071
+ * );
4072
+ *
4073
+ * // Custom configuration for rate-limited API
4074
+ * const result = await withRetry(
4075
+ * async () => alphaVantageAPI.getQuote(symbol),
4076
+ * {
4077
+ * maxRetries: 5,
4078
+ * baseDelayMs: 5000,
4079
+ * maxDelayMs: 60000,
4080
+ * onRetry: (attempt, error) => {
4081
+ * getLogger().info(`Retry ${attempt} after error:`, error);
4082
+ * }
4083
+ * },
4084
+ * 'AlphaVantage.getQuote'
4085
+ * );
4086
+ * ```
4087
+ */
4088
+ async function withRetry(fn, config = {}, label = "unknown") {
4089
+ const fullConfig = { ...DEFAULT_RETRY_CONFIG, ...config };
4090
+ let lastError;
4091
+ for (let attempt = 1; attempt <= fullConfig.maxRetries; attempt++) {
4092
+ try {
4093
+ const result = await fn();
4094
+ // If we succeeded after retries, log it
4095
+ if (attempt > 1) {
4096
+ getLogger().info(`[${label}] Succeeded on attempt ${attempt}/${fullConfig.maxRetries}`);
4097
+ }
4098
+ return result;
4099
+ }
4100
+ catch (error) {
4101
+ lastError = error;
4102
+ // If this is the last attempt, throw the error.
4103
+ // Transient network classes (undici/fetch timeouts, ECONNRESET,
4104
+ // AbortError, etc.) are self-healing at the upstream retry layer —
4105
+ // the caller re-invokes on the next refresh/poll tick. Logging them
4106
+ // at ERROR produces alert noise that does not represent actionable
4107
+ // failures. Demote the transient class to WARN with a recovery hint;
4108
+ // reserve ERROR for non-transient final failures (auth, schema,
4109
+ // contract violations, unknown classes).
4110
+ if (attempt === fullConfig.maxRetries) {
4111
+ const isTransient = isTransientNetworkError(error);
4112
+ const logMeta = {
4113
+ error: error instanceof Error ? error.message : String(error),
4114
+ attempts: fullConfig.maxRetries,
4115
+ timestamp: new Date().toISOString(),
4116
+ ...(isTransient
4117
+ ? {
4118
+ transient: true,
4119
+ recoveryHint: "Upstream caller should retry on next cycle",
4120
+ }
4121
+ : {}),
4122
+ };
4123
+ if (isTransient) {
4124
+ getLogger().warn(`[${label}] Failed after ${fullConfig.maxRetries} attempts (transient)`, logMeta);
4125
+ }
4126
+ else {
4127
+ getLogger().error(`[${label}] Failed after ${fullConfig.maxRetries} attempts`, logMeta);
4128
+ }
4129
+ throw error;
4130
+ }
4131
+ // Analyze the error to determine if we should retry
4132
+ const response = error instanceof Response ? error : null;
4133
+ const errorDetails = analyzeError(error, response, fullConfig);
4134
+ // If error is not retryable, fail immediately
4135
+ if (!errorDetails.isRetryable) {
4136
+ getLogger().error(`[${label}] Non-retryable error (${errorDetails.type})`, {
4137
+ reason: errorDetails.reason,
4138
+ status: errorDetails.status,
4139
+ timestamp: new Date().toISOString(),
4140
+ });
4141
+ throw error;
4142
+ }
4143
+ // Calculate delay for next retry
4144
+ let delayMs;
4145
+ if (errorDetails.type === "RATE_LIMIT" && errorDetails.retryAfter) {
4146
+ // Use Retry-After header if available
4147
+ delayMs = errorDetails.retryAfter;
4148
+ }
4149
+ else if (errorDetails.type === "RATE_LIMIT") {
4150
+ // For rate limits without Retry-After, use a longer minimum delay
4151
+ delayMs = Math.max(calculateBackoff(attempt, fullConfig.baseDelayMs, fullConfig.maxDelayMs), 5000);
4152
+ }
4153
+ else {
4154
+ // Standard exponential backoff with jitter
4155
+ delayMs = calculateBackoff(attempt, fullConfig.baseDelayMs, fullConfig.maxDelayMs);
4156
+ }
4157
+ // Log the retry attempt
4158
+ getLogger().warn(`[${label}] Attempt ${attempt}/${fullConfig.maxRetries} failed: ${errorDetails.reason}. Retrying in ${delayMs}ms...`, {
4159
+ attemptNumber: attempt,
4160
+ totalRetries: fullConfig.maxRetries,
4161
+ errorType: errorDetails.type,
4162
+ httpStatus: errorDetails.status,
4163
+ retryDelay: delayMs,
4164
+ timestamp: new Date().toISOString(),
4165
+ });
4166
+ // Call the optional retry callback
4167
+ if (fullConfig.onRetry) {
4168
+ fullConfig.onRetry(attempt, error);
4169
+ }
4170
+ // Wait before retrying
4171
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
4172
+ }
4173
+ }
4174
+ // This should never be reached due to the throw in the last attempt,
4175
+ // but TypeScript needs this to satisfy the return type
4176
+ throw lastError;
4177
+ }
4178
+ /**
4179
+ * API-specific retry configurations for different external services.
4180
+ * These configurations are tuned based on each API's rate limits and characteristics.
4181
+ */
4182
+ const API_RETRY_CONFIGS = {
4183
+ /** Massive.com API - 5 requests/second rate limit */
4184
+ MASSIVE: {
4185
+ maxRetries: 3,
4186
+ baseDelayMs: 1000,
4187
+ maxDelayMs: 30000,
4188
+ retryableStatusCodes: [429, 500, 502, 503, 504],
4189
+ retryOnNetworkError: true,
4190
+ },
4191
+ /** Alpha Vantage API - 5 requests/minute rate limit (more strict) */
4192
+ ALPHA_VANTAGE: {
4193
+ maxRetries: 5,
4194
+ baseDelayMs: 5000,
4195
+ maxDelayMs: 60000,
4196
+ retryableStatusCodes: [429, 500, 502, 503, 504],
4197
+ retryOnNetworkError: true,
4198
+ },
4199
+ /** Alpaca API - generally reliable, shorter retry window */
4200
+ ALPACA: {
4201
+ maxRetries: 3,
4202
+ baseDelayMs: 1000,
4203
+ maxDelayMs: 30000,
4204
+ retryableStatusCodes: [429, 500, 502, 503, 504],
4205
+ retryOnNetworkError: true,
4206
+ },
4207
+ /** Generic crypto API configuration */
4208
+ CRYPTO: {
4209
+ maxRetries: 3,
4210
+ baseDelayMs: 1000,
4211
+ maxDelayMs: 30000,
4212
+ retryableStatusCodes: [429, 500, 502, 503, 504],
4213
+ retryOnNetworkError: true,
4214
+ },
4215
+ };
4216
+
3804
4217
  const limitPriceSlippagePercent100 = 0.1; // 0.1%
3805
4218
  /**
3806
4219
  Websocket example
@@ -4111,9 +4524,20 @@ class AlpacaTradingAPI {
4111
4524
  }
4112
4525
  catch (err) {
4113
4526
  const error = err;
4527
+ const isTransient = isTransientNetworkError(err);
4528
+ // Transient fetch failures (timeouts, connection resets, undici
4529
+ // aborts) are recoverable at the caller's next cycle; log at WARN
4530
+ // and annotate for observability filters. Non-transient errors
4531
+ // (4xx/auth/schema) stay at ERROR for operator attention.
4114
4532
  this.log(`Error in makeRequest: ${error.message}. Url: ${url}`, {
4115
4533
  source: "AlpacaAPI",
4116
- type: "error",
4534
+ type: isTransient ? "warn" : "error",
4535
+ metadata: isTransient
4536
+ ? {
4537
+ transient: true,
4538
+ recoveryHint: "Upstream caller should retry on next cycle",
4539
+ }
4540
+ : undefined,
4117
4541
  });
4118
4542
  throw error;
4119
4543
  }
@@ -5422,7 +5846,30 @@ async function getOrders$1(auth, params = {}) {
5422
5846
  return allOrders;
5423
5847
  }
5424
5848
  catch (error) {
5425
- getLogger().error("Error in getOrders:", error);
5849
+ const isTransient = isTransientNetworkError(error);
5850
+ const errMsg = error instanceof Error ? error.message : String(error);
5851
+ const logMeta = {
5852
+ source: "AlpacaLegacy.getOrders",
5853
+ error: errMsg,
5854
+ ...(isTransient
5855
+ ? {
5856
+ transient: true,
5857
+ recoveryHint: "Upstream caller should retry on next cycle",
5858
+ }
5859
+ : {}),
5860
+ };
5861
+ // Transient network errors (fetch timeouts, ECONNRESET) are
5862
+ // recoverable; log at WARN. Non-transient failures (4xx/auth)
5863
+ // stay at ERROR. Previous call also passed the raw Error as the
5864
+ // second argument, which Pino treats as context not as `err`,
5865
+ // producing empty `err` fields in production logs — fix by
5866
+ // serializing to an explicit string message.
5867
+ if (isTransient) {
5868
+ getLogger().warn(`Error in getOrders: ${errMsg}`, logMeta);
5869
+ }
5870
+ else {
5871
+ getLogger().error(`Error in getOrders: ${errMsg}`, logMeta);
5872
+ }
5426
5873
  throw error;
5427
5874
  }
5428
5875
  }
@@ -5597,392 +6044,6 @@ async function createLimitOrder(auth, params = {
5597
6044
  });
5598
6045
  }
5599
6046
 
5600
- const DEFAULT_RETRY_CONFIG = {
5601
- maxRetries: 3,
5602
- baseDelayMs: 1000,
5603
- maxDelayMs: 30000,
5604
- retryableStatusCodes: [429, 500, 502, 503, 504],
5605
- retryOnNetworkError: true,
5606
- };
5607
- /**
5608
- * Node.js / undici / system error codes that represent transient network
5609
- * conditions. Present on `error.code` for net/http/dns/undici errors.
5610
- */
5611
- const RETRYABLE_ERROR_CODES = new Set([
5612
- "ETIMEDOUT",
5613
- "ESOCKETTIMEDOUT",
5614
- "ECONNRESET",
5615
- "ECONNREFUSED",
5616
- "EHOSTUNREACH",
5617
- "ENETUNREACH",
5618
- "EAI_AGAIN",
5619
- "EPIPE",
5620
- "ECONNABORTED",
5621
- "ENOTFOUND",
5622
- "UND_ERR_CONNECT_TIMEOUT",
5623
- "UND_ERR_HEADERS_TIMEOUT",
5624
- "UND_ERR_BODY_TIMEOUT",
5625
- "UND_ERR_SOCKET",
5626
- "UND_ERR_CLOSED",
5627
- "UND_ERR_REQ_CONTENT_LENGTH_MISMATCH",
5628
- ]);
5629
- /**
5630
- * Error constructor names / `error.name` values that indicate transient
5631
- * abort / timeout conditions.
5632
- */
5633
- const RETRYABLE_ERROR_NAMES = new Set([
5634
- "AbortError",
5635
- "TimeoutError",
5636
- "FetchError",
5637
- "RequestTimeoutError",
5638
- "ConnectTimeoutError",
5639
- "HeadersTimeoutError",
5640
- "BodyTimeoutError",
5641
- ]);
5642
- /**
5643
- * Message-pattern fallback for libraries that discard error codes/names but
5644
- * preserve text (e.g., some Apollo/axios wrappers).
5645
- */
5646
- const RETRYABLE_MESSAGE_PATTERNS = [
5647
- /aborted/i,
5648
- /timeout/i,
5649
- /timed out/i,
5650
- /network error/i,
5651
- /socket hang up/i,
5652
- /connection (reset|refused|closed)/i,
5653
- /ECONNRESET/,
5654
- /ETIMEDOUT/,
5655
- /ECONNREFUSED/,
5656
- /EAI_AGAIN/,
5657
- /UND_ERR_/,
5658
- ];
5659
- /**
5660
- * Walks the `error.cause` chain (capped to avoid cycles) and tests whether
5661
- * any link along the chain looks like a transient network error. Modern APIs
5662
- * (undici, fetch, Apollo Client 3.8+) wrap the root network failure as a
5663
- * `.cause`, so the surface `Error` may report a generic message while the
5664
- * actionable signal lives one or more levels deeper.
5665
- */
5666
- function isTransientNetworkError(error) {
5667
- const MAX_CAUSE_DEPTH = 6;
5668
- let current = error;
5669
- for (let depth = 0; depth < MAX_CAUSE_DEPTH && current; depth++) {
5670
- if (current instanceof Error || typeof current === "object") {
5671
- const err = current;
5672
- if (typeof err.name === "string" && RETRYABLE_ERROR_NAMES.has(err.name)) {
5673
- return true;
5674
- }
5675
- if (typeof err.code === "string" && RETRYABLE_ERROR_CODES.has(err.code)) {
5676
- return true;
5677
- }
5678
- if (typeof err.message === "string") {
5679
- for (const pattern of RETRYABLE_MESSAGE_PATTERNS) {
5680
- if (pattern.test(err.message)) {
5681
- return true;
5682
- }
5683
- }
5684
- }
5685
- current = err.cause;
5686
- }
5687
- else {
5688
- break;
5689
- }
5690
- }
5691
- return false;
5692
- }
5693
- /**
5694
- * Analyzes an error and determines if it's retryable.
5695
- * @param error - The error to analyze
5696
- * @param response - Optional Response object for HTTP errors
5697
- * @param config - Retry configuration
5698
- * @returns Structured error details
5699
- */
5700
- function analyzeError(error, response, config) {
5701
- // Handle Response objects with error status codes
5702
- if (response && !response.ok) {
5703
- const status = response.status;
5704
- // Rate limit errors - always retryable
5705
- if (status === 429) {
5706
- const retryAfterHeader = response.headers.get("Retry-After");
5707
- const retryAfter = retryAfterHeader
5708
- ? parseInt(retryAfterHeader, 10) * 1000
5709
- : undefined;
5710
- return {
5711
- type: "RATE_LIMIT",
5712
- reason: "Rate limit exceeded",
5713
- status,
5714
- retryAfter,
5715
- isRetryable: true,
5716
- };
5717
- }
5718
- // Authentication errors - never retry
5719
- if (status === 401 || status === 403) {
5720
- return {
5721
- type: "AUTH_ERROR",
5722
- reason: status === 401
5723
- ? "Authentication failed - invalid credentials"
5724
- : "Access forbidden - insufficient permissions",
5725
- status,
5726
- isRetryable: false,
5727
- };
5728
- }
5729
- // Server errors - check if in retryable list
5730
- if (status >= 500 && status < 600) {
5731
- return {
5732
- type: "SERVER_ERROR",
5733
- reason: `Server error (${status})`,
5734
- status,
5735
- isRetryable: config.retryableStatusCodes.includes(status),
5736
- };
5737
- }
5738
- // Other client errors - never retry
5739
- if (status >= 400 && status < 500) {
5740
- return {
5741
- type: "CLIENT_ERROR",
5742
- reason: `Client error (${status})`,
5743
- status,
5744
- isRetryable: false,
5745
- };
5746
- }
5747
- }
5748
- // Handle network errors (TypeError from fetch API)
5749
- if (error instanceof TypeError && error.message.includes("fetch")) {
5750
- return {
5751
- type: "NETWORK_ERROR",
5752
- reason: "Network connectivity issue",
5753
- status: null,
5754
- isRetryable: config.retryOnNetworkError,
5755
- };
5756
- }
5757
- // Handle transient network conditions: AbortError, TimeoutError,
5758
- // Node/undici error codes (ETIMEDOUT, ECONNRESET, UND_ERR_*), and
5759
- // wrapped failures exposed via error.cause. This catches the broad class
5760
- // of infrastructure flakes that the TypeError-only check above misses.
5761
- if (isTransientNetworkError(error)) {
5762
- const reason = error instanceof Error ? error.message : "Transient network error";
5763
- return {
5764
- type: "NETWORK_ERROR",
5765
- reason,
5766
- status: null,
5767
- isRetryable: config.retryOnNetworkError,
5768
- };
5769
- }
5770
- // Handle error objects with messages
5771
- if (error instanceof Error) {
5772
- // Parse error messages that might contain status information
5773
- if (error.message.includes("429") || error.message.includes("RATE_LIMIT")) {
5774
- const match = error.message.match(/RATE_LIMIT: 429:(\d+)/);
5775
- const retryAfter = match ? parseInt(match[1], 10) : undefined;
5776
- return {
5777
- type: "RATE_LIMIT",
5778
- reason: "Rate limit exceeded",
5779
- status: 429,
5780
- retryAfter,
5781
- isRetryable: true,
5782
- };
5783
- }
5784
- if (error.message.includes("401") ||
5785
- error.message.includes("403") ||
5786
- error.message.includes("AUTH_ERROR")) {
5787
- const status = error.message.includes("401") ? 401 : 403;
5788
- return {
5789
- type: "AUTH_ERROR",
5790
- reason: `Authentication error (${status})`,
5791
- status,
5792
- isRetryable: false,
5793
- };
5794
- }
5795
- if (error.message.includes("SERVER_ERROR") ||
5796
- error.message.match(/50[0-9]/)) {
5797
- const statusMatch = error.message.match(/50[0-9]/);
5798
- const status = statusMatch ? parseInt(statusMatch[0], 10) : 500;
5799
- return {
5800
- type: "SERVER_ERROR",
5801
- reason: `Server error (${status})`,
5802
- status,
5803
- isRetryable: config.retryableStatusCodes.includes(status),
5804
- };
5805
- }
5806
- if (error.message.includes("network") ||
5807
- error.message.includes("NETWORK_ERROR")) {
5808
- return {
5809
- type: "NETWORK_ERROR",
5810
- reason: error.message,
5811
- status: null,
5812
- isRetryable: config.retryOnNetworkError,
5813
- };
5814
- }
5815
- }
5816
- // Unknown error - not retryable by default for safety
5817
- return {
5818
- type: "UNKNOWN",
5819
- reason: error instanceof Error ? error.message : String(error),
5820
- status: null,
5821
- isRetryable: false,
5822
- };
5823
- }
5824
- /**
5825
- * Calculates the delay before the next retry attempt using exponential backoff with jitter.
5826
- * @param attempt - Current attempt number (1-indexed)
5827
- * @param baseDelay - Base delay in milliseconds
5828
- * @param maxDelay - Maximum delay in milliseconds
5829
- * @returns Delay in milliseconds
5830
- */
5831
- function calculateBackoff(attempt, baseDelay, maxDelay) {
5832
- // Exponential backoff: baseDelay * 2^(attempt-1)
5833
- const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
5834
- // Cap at maxDelay
5835
- const cappedDelay = Math.min(exponentialDelay, maxDelay);
5836
- // Add jitter (random value between 0% and 25% of the delay)
5837
- const jitter = Math.random() * cappedDelay * 0.25;
5838
- return Math.floor(cappedDelay + jitter);
5839
- }
5840
- /**
5841
- * Wraps an async function with retry logic and exponential backoff.
5842
- *
5843
- * This utility handles transient errors in external API calls by automatically retrying
5844
- * failed requests with intelligent backoff strategies. It respects rate limit headers,
5845
- * fails fast on non-retryable errors, and provides detailed logging.
5846
- *
5847
- * @template T - The return type of the wrapped function
5848
- * @param fn - The async function to wrap with retry logic
5849
- * @param config - Retry configuration (merged with defaults)
5850
- * @param label - A descriptive label for logging (e.g., 'Massive.fetchTickerInfo')
5851
- * @returns A promise that resolves to the function's return value
5852
- * @throws The last error encountered if all retries are exhausted
5853
- *
5854
- * @example
5855
- * ```typescript
5856
- * // Basic usage with defaults
5857
- * const data = await withRetry(
5858
- * async () => fetch('https://api.example.com/data'),
5859
- * {},
5860
- * 'ExampleAPI.fetchData'
5861
- * );
5862
- *
5863
- * // Custom configuration for rate-limited API
5864
- * const result = await withRetry(
5865
- * async () => alphaVantageAPI.getQuote(symbol),
5866
- * {
5867
- * maxRetries: 5,
5868
- * baseDelayMs: 5000,
5869
- * maxDelayMs: 60000,
5870
- * onRetry: (attempt, error) => {
5871
- * getLogger().info(`Retry ${attempt} after error:`, error);
5872
- * }
5873
- * },
5874
- * 'AlphaVantage.getQuote'
5875
- * );
5876
- * ```
5877
- */
5878
- async function withRetry(fn, config = {}, label = "unknown") {
5879
- const fullConfig = { ...DEFAULT_RETRY_CONFIG, ...config };
5880
- let lastError;
5881
- for (let attempt = 1; attempt <= fullConfig.maxRetries; attempt++) {
5882
- try {
5883
- const result = await fn();
5884
- // If we succeeded after retries, log it
5885
- if (attempt > 1) {
5886
- getLogger().info(`[${label}] Succeeded on attempt ${attempt}/${fullConfig.maxRetries}`);
5887
- }
5888
- return result;
5889
- }
5890
- catch (error) {
5891
- lastError = error;
5892
- // If this is the last attempt, throw the error
5893
- if (attempt === fullConfig.maxRetries) {
5894
- getLogger().error(`[${label}] Failed after ${fullConfig.maxRetries} attempts`, {
5895
- error: error instanceof Error ? error.message : String(error),
5896
- timestamp: new Date().toISOString(),
5897
- });
5898
- throw error;
5899
- }
5900
- // Analyze the error to determine if we should retry
5901
- const response = error instanceof Response ? error : null;
5902
- const errorDetails = analyzeError(error, response, fullConfig);
5903
- // If error is not retryable, fail immediately
5904
- if (!errorDetails.isRetryable) {
5905
- getLogger().error(`[${label}] Non-retryable error (${errorDetails.type})`, {
5906
- reason: errorDetails.reason,
5907
- status: errorDetails.status,
5908
- timestamp: new Date().toISOString(),
5909
- });
5910
- throw error;
5911
- }
5912
- // Calculate delay for next retry
5913
- let delayMs;
5914
- if (errorDetails.type === "RATE_LIMIT" && errorDetails.retryAfter) {
5915
- // Use Retry-After header if available
5916
- delayMs = errorDetails.retryAfter;
5917
- }
5918
- else if (errorDetails.type === "RATE_LIMIT") {
5919
- // For rate limits without Retry-After, use a longer minimum delay
5920
- delayMs = Math.max(calculateBackoff(attempt, fullConfig.baseDelayMs, fullConfig.maxDelayMs), 5000);
5921
- }
5922
- else {
5923
- // Standard exponential backoff with jitter
5924
- delayMs = calculateBackoff(attempt, fullConfig.baseDelayMs, fullConfig.maxDelayMs);
5925
- }
5926
- // Log the retry attempt
5927
- getLogger().warn(`[${label}] Attempt ${attempt}/${fullConfig.maxRetries} failed: ${errorDetails.reason}. Retrying in ${delayMs}ms...`, {
5928
- attemptNumber: attempt,
5929
- totalRetries: fullConfig.maxRetries,
5930
- errorType: errorDetails.type,
5931
- httpStatus: errorDetails.status,
5932
- retryDelay: delayMs,
5933
- timestamp: new Date().toISOString(),
5934
- });
5935
- // Call the optional retry callback
5936
- if (fullConfig.onRetry) {
5937
- fullConfig.onRetry(attempt, error);
5938
- }
5939
- // Wait before retrying
5940
- await new Promise((resolve) => setTimeout(resolve, delayMs));
5941
- }
5942
- }
5943
- // This should never be reached due to the throw in the last attempt,
5944
- // but TypeScript needs this to satisfy the return type
5945
- throw lastError;
5946
- }
5947
- /**
5948
- * API-specific retry configurations for different external services.
5949
- * These configurations are tuned based on each API's rate limits and characteristics.
5950
- */
5951
- const API_RETRY_CONFIGS = {
5952
- /** Massive.com API - 5 requests/second rate limit */
5953
- MASSIVE: {
5954
- maxRetries: 3,
5955
- baseDelayMs: 1000,
5956
- maxDelayMs: 30000,
5957
- retryableStatusCodes: [429, 500, 502, 503, 504],
5958
- retryOnNetworkError: true,
5959
- },
5960
- /** Alpha Vantage API - 5 requests/minute rate limit (more strict) */
5961
- ALPHA_VANTAGE: {
5962
- maxRetries: 5,
5963
- baseDelayMs: 5000,
5964
- maxDelayMs: 60000,
5965
- retryableStatusCodes: [429, 500, 502, 503, 504],
5966
- retryOnNetworkError: true,
5967
- },
5968
- /** Alpaca API - generally reliable, shorter retry window */
5969
- ALPACA: {
5970
- maxRetries: 3,
5971
- baseDelayMs: 1000,
5972
- maxDelayMs: 30000,
5973
- retryableStatusCodes: [429, 500, 502, 503, 504],
5974
- retryOnNetworkError: true,
5975
- },
5976
- /** Generic crypto API configuration */
5977
- CRYPTO: {
5978
- maxRetries: 3,
5979
- baseDelayMs: 1000,
5980
- maxDelayMs: 30000,
5981
- retryableStatusCodes: [429, 500, 502, 503, 504],
5982
- retryOnNetworkError: true,
5983
- },
5984
- };
5985
-
5986
6047
  // Utility function for debug logging
5987
6048
  /**
5988
6049
  * Debug logging utility that respects environment debug flags.
@@ -8509,19 +8570,37 @@ const fetchPrices = async (params, options) => {
8509
8570
  catch (error) {
8510
8571
  const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
8511
8572
  const contextualMessage = `Error fetching price data for ${ticker}`;
8512
- getLogger().error(`${contextualMessage}: ${errorMessage}`, {
8573
+ const isTransient = isTransientNetworkError(error);
8574
+ const errorType = error instanceof Error && error.message.includes("AUTH_ERROR")
8575
+ ? "AUTH_ERROR"
8576
+ : error instanceof Error && error.message.includes("RATE_LIMIT")
8577
+ ? "RATE_LIMIT"
8578
+ : isTransient
8579
+ ? "NETWORK_ERROR"
8580
+ : "UNKNOWN";
8581
+ // Transient network failures (undici timeouts, socket resets, DNS
8582
+ // blips) are recoverable at the caller's next cycle — demote to
8583
+ // WARN to avoid alert noise. Non-transient classes (auth, rate
8584
+ // limits that have exhausted retries, unknown schema errors) stay
8585
+ // at ERROR because they require operator attention.
8586
+ const logMeta = {
8513
8587
  ticker,
8514
- errorType: error instanceof Error && error.message.includes("AUTH_ERROR")
8515
- ? "AUTH_ERROR"
8516
- : error instanceof Error && error.message.includes("RATE_LIMIT")
8517
- ? "RATE_LIMIT"
8518
- : error instanceof Error &&
8519
- error.message.includes("NETWORK_ERROR")
8520
- ? "NETWORK_ERROR"
8521
- : "UNKNOWN",
8588
+ errorType,
8522
8589
  source: "MassiveAPI.fetchPrices",
8523
8590
  timestamp: new Date().toISOString(),
8524
- });
8591
+ ...(isTransient
8592
+ ? {
8593
+ transient: true,
8594
+ recoveryHint: "Upstream caller should retry on next cycle",
8595
+ }
8596
+ : {}),
8597
+ };
8598
+ if (isTransient) {
8599
+ getLogger().warn(`${contextualMessage}: ${errorMessage}`, logMeta);
8600
+ }
8601
+ else {
8602
+ getLogger().error(`${contextualMessage}: ${errorMessage}`, logMeta);
8603
+ }
8525
8604
  throw new Error(`${contextualMessage}: ${errorMessage}`);
8526
8605
  }
8527
8606
  });
@@ -68626,6 +68705,7 @@ exports.isOrderFilled = isOrderFilled;
68626
68705
  exports.isOrderOpen = isOrderOpen;
68627
68706
  exports.isOrderTerminalStatus = isOrderTerminal$1;
68628
68707
  exports.isSupportedCryptoPair = isSupportedCryptoPair;
68708
+ exports.isTransientNetworkError = isTransientNetworkError;
68629
68709
  exports.legacyApi = index$1;
68630
68710
  exports.limitBuyWithTakeProfit = limitBuyWithTakeProfit;
68631
68711
  exports.ocoOrders = ocoOrders;