@adaptic/utils 0.0.979 → 0.0.980

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
  }
@@ -5597,392 +6021,6 @@ async function createLimitOrder(auth, params = {
5597
6021
  });
5598
6022
  }
5599
6023
 
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
6024
  // Utility function for debug logging
5987
6025
  /**
5988
6026
  * Debug logging utility that respects environment debug flags.
@@ -8509,19 +8547,37 @@ const fetchPrices = async (params, options) => {
8509
8547
  catch (error) {
8510
8548
  const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
8511
8549
  const contextualMessage = `Error fetching price data for ${ticker}`;
8512
- getLogger().error(`${contextualMessage}: ${errorMessage}`, {
8550
+ const isTransient = isTransientNetworkError(error);
8551
+ const errorType = error instanceof Error && error.message.includes("AUTH_ERROR")
8552
+ ? "AUTH_ERROR"
8553
+ : error instanceof Error && error.message.includes("RATE_LIMIT")
8554
+ ? "RATE_LIMIT"
8555
+ : isTransient
8556
+ ? "NETWORK_ERROR"
8557
+ : "UNKNOWN";
8558
+ // Transient network failures (undici timeouts, socket resets, DNS
8559
+ // blips) are recoverable at the caller's next cycle — demote to
8560
+ // WARN to avoid alert noise. Non-transient classes (auth, rate
8561
+ // limits that have exhausted retries, unknown schema errors) stay
8562
+ // at ERROR because they require operator attention.
8563
+ const logMeta = {
8513
8564
  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",
8565
+ errorType,
8522
8566
  source: "MassiveAPI.fetchPrices",
8523
8567
  timestamp: new Date().toISOString(),
8524
- });
8568
+ ...(isTransient
8569
+ ? {
8570
+ transient: true,
8571
+ recoveryHint: "Upstream caller should retry on next cycle",
8572
+ }
8573
+ : {}),
8574
+ };
8575
+ if (isTransient) {
8576
+ getLogger().warn(`${contextualMessage}: ${errorMessage}`, logMeta);
8577
+ }
8578
+ else {
8579
+ getLogger().error(`${contextualMessage}: ${errorMessage}`, logMeta);
8580
+ }
8525
8581
  throw new Error(`${contextualMessage}: ${errorMessage}`);
8526
8582
  }
8527
8583
  });
@@ -68626,6 +68682,7 @@ exports.isOrderFilled = isOrderFilled;
68626
68682
  exports.isOrderOpen = isOrderOpen;
68627
68683
  exports.isOrderTerminalStatus = isOrderTerminal$1;
68628
68684
  exports.isSupportedCryptoPair = isSupportedCryptoPair;
68685
+ exports.isTransientNetworkError = isTransientNetworkError;
68629
68686
  exports.legacyApi = index$1;
68630
68687
  exports.limitBuyWithTakeProfit = limitBuyWithTakeProfit;
68631
68688
  exports.ocoOrders = ocoOrders;