@adaptic/utils 0.0.1009 → 0.0.1011

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
@@ -2177,6 +2177,247 @@ function validateAlphaVantageApiKey(apiKey) {
2177
2177
  }
2178
2178
  }
2179
2179
 
2180
+ /**
2181
+ * Structured error type hierarchy for all API integrations
2182
+ *
2183
+ * This module provides a comprehensive error handling system for external API integrations,
2184
+ * including Alpaca, Massive, and AlphaVantage services.
2185
+ */
2186
+ /**
2187
+ * Base error class for all @adaptic/utils errors
2188
+ * Extends Error with additional context about service, error code, and retry capability
2189
+ */
2190
+ class AdapticUtilsError extends Error {
2191
+ code;
2192
+ service;
2193
+ isRetryable;
2194
+ cause;
2195
+ name;
2196
+ constructor(message, code, service, isRetryable = false, cause) {
2197
+ super(message);
2198
+ this.code = code;
2199
+ this.service = service;
2200
+ this.isRetryable = isRetryable;
2201
+ this.cause = cause;
2202
+ this.name = this.constructor.name;
2203
+ // Maintains proper stack trace for where error was thrown (only available on V8)
2204
+ if (Error.captureStackTrace) {
2205
+ Error.captureStackTrace(this, this.constructor);
2206
+ }
2207
+ }
2208
+ }
2209
+ /**
2210
+ * Alpaca API specific errors
2211
+ * Handles all errors from Alpaca trading and market data APIs
2212
+ */
2213
+ class AlpacaApiError extends AdapticUtilsError {
2214
+ statusCode;
2215
+ constructor(message, code, statusCode, cause) {
2216
+ // Rate limit (429) and server errors (5xx) are retryable
2217
+ const isRetryable = statusCode === 429 || (statusCode !== undefined && statusCode >= 500);
2218
+ super(message, code, "alpaca", isRetryable, cause);
2219
+ this.statusCode = statusCode;
2220
+ }
2221
+ }
2222
+ /**
2223
+ * Massive.com API specific errors
2224
+ * Handles all errors from Massive market data API
2225
+ */
2226
+ class MassiveApiError extends AdapticUtilsError {
2227
+ statusCode;
2228
+ constructor(message, code, statusCode, cause) {
2229
+ // Rate limit (429) and server errors (5xx) are retryable
2230
+ const isRetryable = statusCode === 429 || (statusCode !== undefined && statusCode >= 500);
2231
+ super(message, code, "massive", isRetryable, cause);
2232
+ this.statusCode = statusCode;
2233
+ }
2234
+ }
2235
+ /**
2236
+ * AlphaVantage API specific errors
2237
+ * Handles all errors from AlphaVantage financial data API
2238
+ */
2239
+ class AlphaVantageError extends AdapticUtilsError {
2240
+ statusCode;
2241
+ constructor(message, code, statusCode, cause) {
2242
+ // Rate limit (429) and server errors (5xx) are retryable
2243
+ const isRetryable = statusCode === 429 || (statusCode !== undefined && statusCode >= 500);
2244
+ super(message, code, "alphavantage", isRetryable, cause);
2245
+ this.statusCode = statusCode;
2246
+ }
2247
+ }
2248
+ /**
2249
+ * Network timeout errors
2250
+ * Used when API requests exceed configured timeout limits
2251
+ * Always retryable as timeouts are often transient
2252
+ */
2253
+ class TimeoutError extends AdapticUtilsError {
2254
+ service;
2255
+ timeoutMs;
2256
+ constructor(message, service, timeoutMs, cause) {
2257
+ super(message, "TIMEOUT", service, true, // Timeouts are always retryable
2258
+ cause);
2259
+ this.service = service;
2260
+ this.timeoutMs = timeoutMs;
2261
+ }
2262
+ }
2263
+ /**
2264
+ * Input validation errors
2265
+ * Used when function inputs fail validation checks
2266
+ * Never retryable as the inputs need to be corrected
2267
+ */
2268
+ class ValidationError extends AdapticUtilsError {
2269
+ service;
2270
+ invalidField;
2271
+ constructor(message, service, invalidField, cause) {
2272
+ super(message, "VALIDATION_ERROR", service, false, // Validation errors are never retryable
2273
+ cause);
2274
+ this.service = service;
2275
+ this.invalidField = invalidField;
2276
+ }
2277
+ }
2278
+ /**
2279
+ * Authentication and authorization errors
2280
+ * Used when API credentials are invalid, expired, or lack permissions
2281
+ * Never retryable as credentials need to be updated
2282
+ */
2283
+ class AuthenticationError extends AdapticUtilsError {
2284
+ service;
2285
+ statusCode;
2286
+ constructor(message, service, statusCode, cause) {
2287
+ super(message, "AUTH_ERROR", service, false, // Auth errors are never retryable
2288
+ cause);
2289
+ this.service = service;
2290
+ this.statusCode = statusCode;
2291
+ }
2292
+ }
2293
+ /**
2294
+ * HTTP client errors (4xx)
2295
+ * Used for client-side errors that are not authentication or validation related
2296
+ * Generally not retryable unless specific status codes indicate otherwise
2297
+ */
2298
+ class HttpClientError extends AdapticUtilsError {
2299
+ service;
2300
+ statusCode;
2301
+ constructor(message, service, statusCode, cause) {
2302
+ super(message, "CLIENT_ERROR", service, false, // Client errors are generally not retryable
2303
+ cause);
2304
+ this.service = service;
2305
+ this.statusCode = statusCode;
2306
+ }
2307
+ }
2308
+ /**
2309
+ * HTTP server errors (5xx)
2310
+ * Used for server-side errors from external APIs
2311
+ * Always retryable as server issues are often transient
2312
+ */
2313
+ class HttpServerError extends AdapticUtilsError {
2314
+ service;
2315
+ statusCode;
2316
+ constructor(message, service, statusCode, cause) {
2317
+ super(message, "SERVER_ERROR", service, true, // Server errors are always retryable
2318
+ cause);
2319
+ this.service = service;
2320
+ this.statusCode = statusCode;
2321
+ }
2322
+ }
2323
+ /**
2324
+ * Rate limit errors (429)
2325
+ * Used when API rate limits are exceeded
2326
+ * Always retryable, often with retry-after header information
2327
+ */
2328
+ class RateLimitError extends AdapticUtilsError {
2329
+ service;
2330
+ retryAfterMs;
2331
+ constructor(message, service, retryAfterMs, cause) {
2332
+ super(message, "RATE_LIMIT", service, true, // Rate limit errors are always retryable
2333
+ cause);
2334
+ this.service = service;
2335
+ this.retryAfterMs = retryAfterMs;
2336
+ }
2337
+ }
2338
+ /**
2339
+ * WebSocket connection errors
2340
+ * Used for WebSocket-specific connection and communication failures
2341
+ * Retryability depends on the specific error condition
2342
+ */
2343
+ class WebSocketError extends AdapticUtilsError {
2344
+ service;
2345
+ constructor(message, service, isRetryable = true, cause) {
2346
+ super(message, "WEBSOCKET_ERROR", service, isRetryable, cause);
2347
+ this.service = service;
2348
+ }
2349
+ }
2350
+ /**
2351
+ * Network errors (connection failures, DNS issues, etc.)
2352
+ * Used for low-level network failures
2353
+ * Always retryable as network issues are often transient
2354
+ */
2355
+ class NetworkError extends AdapticUtilsError {
2356
+ service;
2357
+ constructor(message, service, cause) {
2358
+ super(message, "NETWORK_ERROR", service, true, // Network errors are always retryable
2359
+ cause);
2360
+ this.service = service;
2361
+ }
2362
+ }
2363
+ /**
2364
+ * Unsupported brokerage provider errors
2365
+ * Thrown when a broker operation is requested for a provider that has no
2366
+ * implemented integration (e.g. IBKR or COINBASE before their adapters land,
2367
+ * or an unrecognised provider string from an untyped caller).
2368
+ * Never retryable — the caller must route to a supported provider.
2369
+ */
2370
+ class UnsupportedBrokerError extends AdapticUtilsError {
2371
+ provider;
2372
+ constructor(
2373
+ /** The provider that was requested but is not supported. */
2374
+ provider, cause) {
2375
+ super(`Brokerage provider "${provider}" is not supported. Supported providers: ALPACA`, "UNSUPPORTED_BROKER", "broker", false, // Unsupported providers are never retryable
2376
+ cause);
2377
+ this.provider = provider;
2378
+ }
2379
+ }
2380
+ /**
2381
+ * Data parsing and format errors
2382
+ * Used when API responses cannot be parsed or are in unexpected format
2383
+ * Not retryable as the data format issue needs investigation
2384
+ */
2385
+ class DataFormatError extends AdapticUtilsError {
2386
+ service;
2387
+ constructor(message, service, cause) {
2388
+ super(message, "DATA_FORMAT_ERROR", service, false, // Data format errors are not retryable
2389
+ cause);
2390
+ this.service = service;
2391
+ }
2392
+ }
2393
+ /**
2394
+ * Broker-side duplicate `client_order_id` rejection (Alpaca HTTP 422,
2395
+ * "client order id must be unique").
2396
+ *
2397
+ * Thrown by the order-creation paths of `AlpacaTradingAPI` so callers can
2398
+ * distinguish "this exact order was already submitted" from a genuine order
2399
+ * rejection. When {@link wasDerived} is `false` the id was caller-supplied and
2400
+ * the caller owns idempotency semantics (a legitimate repeat needs a new
2401
+ * explicit id or an `idempotencyNonce`). When `true`, the wrapper's automatic
2402
+ * recovery (existing-order lookup, then one salted resubmit) was exhausted.
2403
+ *
2404
+ * Never retryable with the same id — resubmitting the identical
2405
+ * `client_order_id` will 422 again.
2406
+ */
2407
+ class DuplicateClientOrderIdError extends AlpacaApiError {
2408
+ clientOrderId;
2409
+ wasDerived;
2410
+ constructor(message,
2411
+ /** The `client_order_id` that collided broker-side. */
2412
+ clientOrderId,
2413
+ /** Whether the colliding id was derived by the wrapper (vs caller-supplied). */
2414
+ wasDerived, cause) {
2415
+ super(message, "DUPLICATE_CLIENT_ORDER_ID", 422, cause);
2416
+ this.clientOrderId = clientOrderId;
2417
+ this.wasDerived = wasDerived;
2418
+ }
2419
+ }
2420
+
2180
2421
  const DEFAULT_RETRY_CONFIG = {
2181
2422
  maxRetries: 3,
2182
2423
  baseDelayMs: 1000,
@@ -2351,128 +2592,224 @@ function isClientDeadlineExpiry(error) {
2351
2592
  return false;
2352
2593
  }
2353
2594
  /**
2354
- * Analyzes an error and determines if it's retryable.
2355
- * @param error - The error to analyze
2356
- * @param response - Optional Response object for HTTP errors
2357
- * @param config - Retry configuration
2358
- * @returns Structured error details
2595
+ * HTTP statuses the classifier reasons about, named rather than inlined.
2596
+ */
2597
+ const HTTP_STATUS = {
2598
+ /** Too Many Requests — retryable, honouring `Retry-After`. */
2599
+ RATE_LIMIT: 429,
2600
+ /** Unauthorized — never retryable, the credentials are wrong. */
2601
+ UNAUTHORIZED: 401,
2602
+ /** Forbidden — never retryable, the permissions are wrong. */
2603
+ FORBIDDEN: 403,
2604
+ /** Lowest status in the client-error band. */
2605
+ CLIENT_ERROR_MIN: 400,
2606
+ /** Lowest status in the server-error band. */
2607
+ SERVER_ERROR_MIN: 500,
2608
+ /** First status above the server-error band. */
2609
+ SERVER_ERROR_MAX_EXCLUSIVE: 600,
2610
+ };
2611
+ /** `Retry-After` is expressed in seconds; delays are handled in milliseconds. */
2612
+ const MILLISECONDS_PER_SECOND = 1000;
2613
+ /**
2614
+ * Narrows an unknown value to an index-signature record so its properties can
2615
+ * be probed without an `any` cast.
2616
+ * @param value - The value to test.
2617
+ * @returns true when the value is a non-null object.
2359
2618
  */
2360
- function analyzeError(error, response, config) {
2361
- // Handle Response objects with error status codes
2362
- if (response && !response.ok) {
2363
- const status = response.status;
2364
- // Rate limit errors - always retryable
2365
- if (status === 429) {
2366
- const retryAfterHeader = response.headers.get("Retry-After");
2367
- const retryAfter = retryAfterHeader
2368
- ? parseInt(retryAfterHeader, 10) * 1000
2369
- : undefined;
2370
- return {
2371
- type: "RATE_LIMIT",
2372
- reason: "Rate limit exceeded",
2373
- status,
2374
- retryAfter,
2375
- isRetryable: true,
2376
- };
2377
- }
2378
- // Authentication errors - never retry
2379
- if (status === 401 || status === 403) {
2380
- return {
2381
- type: "AUTH_ERROR",
2382
- reason: status === 401
2383
- ? "Authentication failed - invalid credentials"
2384
- : "Access forbidden - insufficient permissions",
2385
- status,
2386
- isRetryable: false,
2387
- };
2619
+ function isRecord(value) {
2620
+ return typeof value === "object" && value !== null;
2621
+ }
2622
+ /**
2623
+ * Reads a `Retry-After` header (seconds) from either a `Headers` instance or a
2624
+ * plain header map, and converts it to milliseconds.
2625
+ * @param headers - The headers carrier from a Response or an HTTP client error.
2626
+ * @returns The delay in milliseconds, or undefined when absent/unparseable.
2627
+ */
2628
+ function readRetryAfterMs(headers) {
2629
+ let raw;
2630
+ if (headers instanceof Headers) {
2631
+ raw = headers.get("Retry-After");
2632
+ }
2633
+ else if (isRecord(headers)) {
2634
+ raw = headers["retry-after"] ?? headers["Retry-After"];
2635
+ }
2636
+ if (typeof raw === "number" && Number.isFinite(raw)) {
2637
+ return raw * MILLISECONDS_PER_SECOND;
2638
+ }
2639
+ if (typeof raw === "string") {
2640
+ const seconds = Number.parseInt(raw, 10);
2641
+ if (Number.isFinite(seconds)) {
2642
+ return seconds * MILLISECONDS_PER_SECOND;
2388
2643
  }
2389
- // Server errors - check if in retryable list
2390
- if (status >= 500 && status < 600) {
2391
- return {
2392
- type: "SERVER_ERROR",
2393
- reason: `Server error (${status})`,
2394
- status,
2395
- isRetryable: config.retryableStatusCodes.includes(status),
2396
- };
2644
+ }
2645
+ return undefined;
2646
+ }
2647
+ /**
2648
+ * Reads a numeric HTTP status from an unknown property value.
2649
+ * @param value - The candidate status value.
2650
+ * @returns The status when it is a finite number, otherwise null.
2651
+ */
2652
+ function asStatus(value) {
2653
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
2654
+ }
2655
+ /**
2656
+ * Extracts the HTTP status from a **typed** carrier: a `Response`, a typed
2657
+ * {@link AdapticUtilsError}, an HTTP-client error exposing `response.status`
2658
+ * (axios/the Alpaca SDK), or an error exposing a numeric `status`/`statusCode`.
2659
+ *
2660
+ * @param error - The thrown value.
2661
+ * @returns The typed failure, or null when the value carries no status.
2662
+ */
2663
+ function extractTypedHttpFailure(error) {
2664
+ if (error instanceof Response) {
2665
+ return {
2666
+ status: error.status,
2667
+ retryAfterMs: readRetryAfterMs(error.headers),
2668
+ };
2669
+ }
2670
+ if (error instanceof AdapticUtilsError) {
2671
+ const status = asStatus(error.statusCode);
2672
+ if (status !== null) {
2673
+ return { status };
2397
2674
  }
2398
- // Other client errors - never retry
2399
- if (status >= 400 && status < 500) {
2400
- return {
2401
- type: "CLIENT_ERROR",
2402
- reason: `Client error (${status})`,
2403
- status,
2404
- isRetryable: false,
2405
- };
2675
+ return null;
2676
+ }
2677
+ if (!isRecord(error)) {
2678
+ return null;
2679
+ }
2680
+ const nested = error.response;
2681
+ if (nested instanceof Response) {
2682
+ return {
2683
+ status: nested.status,
2684
+ retryAfterMs: readRetryAfterMs(nested.headers),
2685
+ };
2686
+ }
2687
+ if (isRecord(nested)) {
2688
+ const status = asStatus(nested.status);
2689
+ if (status !== null) {
2690
+ return { status, retryAfterMs: readRetryAfterMs(nested.headers) };
2406
2691
  }
2407
2692
  }
2408
- // Handle network errors (TypeError from fetch API)
2409
- if (error instanceof TypeError && error.message.includes("fetch")) {
2693
+ const direct = asStatus(error.status) ?? asStatus(error.statusCode);
2694
+ return direct === null ? null : { status: direct };
2695
+ }
2696
+ /**
2697
+ * Maps an HTTP status to the typed retry verdict. This is the single place
2698
+ * retryability is decided for status-bearing failures.
2699
+ *
2700
+ * @param failure - The status (and any upstream-mandated delay).
2701
+ * @param config - Effective retry configuration.
2702
+ * @returns The typed classification.
2703
+ */
2704
+ function classifyByStatus(failure, config) {
2705
+ const { status } = failure;
2706
+ if (status === HTTP_STATUS.RATE_LIMIT) {
2410
2707
  return {
2411
- type: "NETWORK_ERROR",
2412
- reason: "Network connectivity issue",
2708
+ type: "RATE_LIMIT",
2709
+ reason: "Rate limit exceeded",
2710
+ status,
2711
+ retryAfter: failure.retryAfterMs,
2712
+ isRetryable: true,
2713
+ };
2714
+ }
2715
+ if (status === HTTP_STATUS.UNAUTHORIZED ||
2716
+ status === HTTP_STATUS.FORBIDDEN) {
2717
+ return {
2718
+ type: "AUTH_ERROR",
2719
+ reason: status === HTTP_STATUS.UNAUTHORIZED
2720
+ ? "Authentication failed - invalid credentials"
2721
+ : "Access forbidden - insufficient permissions",
2722
+ status,
2723
+ isRetryable: false,
2724
+ };
2725
+ }
2726
+ if (status >= HTTP_STATUS.SERVER_ERROR_MIN &&
2727
+ status < HTTP_STATUS.SERVER_ERROR_MAX_EXCLUSIVE) {
2728
+ return {
2729
+ type: "SERVER_ERROR",
2730
+ reason: `Server error (${status})`,
2731
+ status,
2732
+ retryAfter: failure.retryAfterMs,
2733
+ isRetryable: config.retryableStatusCodes.includes(status),
2734
+ };
2735
+ }
2736
+ if (status >= HTTP_STATUS.CLIENT_ERROR_MIN &&
2737
+ status < HTTP_STATUS.SERVER_ERROR_MIN) {
2738
+ return {
2739
+ type: "CLIENT_ERROR",
2740
+ reason: `Client error (${status})`,
2741
+ status,
2742
+ isRetryable: false,
2743
+ };
2744
+ }
2745
+ return {
2746
+ type: "UNKNOWN",
2747
+ reason: `Unexpected HTTP status (${status})`,
2748
+ status,
2749
+ isRetryable: false,
2750
+ };
2751
+ }
2752
+ /**
2753
+ * Classifies a failure into the typed {@link RetryErrorDetails} taxonomy.
2754
+ *
2755
+ * **An HTTP status is only ever read from a typed carrier.** There is no path
2756
+ * from the characters of an error message to a status, and therefore none to a
2757
+ * retry decision derived from a status (F-0035). A throw site that wants its
2758
+ * status honoured must attach it: throw the `Response`, set `response.status`
2759
+ * (axios / the Alpaca SDK do this), set a numeric `status` / `statusCode`, or
2760
+ * raise an {@link AdapticUtilsError}. A plain `Error` whose text mentions
2761
+ * "429", "503" or "CLIENT_ERROR: 422" is `UNKNOWN` and is **not** retried.
2762
+ *
2763
+ * Precedence, strongest evidence first:
2764
+ *
2765
+ * 1. an explicit `Response`, or a thrown `Response`;
2766
+ * 2. a **typed** carrier — {@link AdapticUtilsError} (status, else its declared
2767
+ * `isRetryable`), `error.response.status` (axios / Alpaca SDK), or a numeric
2768
+ * `error.status` / `error.statusCode`;
2769
+ * 3. transient network conditions — {@link isTransientNetworkError}, which
2770
+ * reads `error.code` / `error.name` / the `cause` chain. This decides
2771
+ * *transience*, never a status, so it can never turn a broker rejection into
2772
+ * a 5xx; a rejection that carries a typed status is already resolved above;
2773
+ * 4. otherwise `UNKNOWN`, which is **not** retryable.
2774
+ *
2775
+ * @param error - The thrown value.
2776
+ * @param response - Optional Response already in hand for this failure.
2777
+ * @param config - Optional retry-configuration overrides.
2778
+ * @returns The typed classification.
2779
+ */
2780
+ function classifyRetryError(error, response = null, config = {}) {
2781
+ const fullConfig = { ...DEFAULT_RETRY_CONFIG, ...config };
2782
+ if (response && !response.ok) {
2783
+ return classifyByStatus({
2784
+ status: response.status,
2785
+ retryAfterMs: readRetryAfterMs(response.headers),
2786
+ }, fullConfig);
2787
+ }
2788
+ const typedFailure = extractTypedHttpFailure(error);
2789
+ if (typedFailure) {
2790
+ return classifyByStatus(typedFailure, fullConfig);
2791
+ }
2792
+ // A typed error class that declares its own retryability but carries no
2793
+ // status (timeouts, validation failures, network wrappers) is authoritative.
2794
+ if (error instanceof AdapticUtilsError) {
2795
+ return {
2796
+ type: error.isRetryable ? "NETWORK_ERROR" : "CLIENT_ERROR",
2797
+ reason: error.message,
2413
2798
  status: null,
2414
- isRetryable: config.retryOnNetworkError,
2799
+ isRetryable: error.isRetryable && fullConfig.retryOnNetworkError,
2415
2800
  };
2416
2801
  }
2417
- // Handle transient network conditions: AbortError, TimeoutError,
2418
- // Node/undici error codes (ETIMEDOUT, ECONNRESET, UND_ERR_*), and
2419
- // wrapped failures exposed via error.cause. This catches the broad class
2420
- // of infrastructure flakes that the TypeError-only check above misses.
2421
- if (isTransientNetworkError(error)) {
2422
- const reason = error instanceof Error ? error.message : "Transient network error";
2802
+ // Transient network conditions: fetch TypeErrors, AbortError/TimeoutError,
2803
+ // Node/undici error codes, and failures wrapped via `error.cause`.
2804
+ if ((error instanceof TypeError && error.message.includes("fetch")) ||
2805
+ isTransientNetworkError(error)) {
2423
2806
  return {
2424
2807
  type: "NETWORK_ERROR",
2425
- reason,
2808
+ reason: error instanceof Error ? error.message : "Transient network error",
2426
2809
  status: null,
2427
- isRetryable: config.retryOnNetworkError,
2810
+ isRetryable: fullConfig.retryOnNetworkError,
2428
2811
  };
2429
2812
  }
2430
- // Handle error objects with messages
2431
- if (error instanceof Error) {
2432
- // Parse error messages that might contain status information
2433
- if (error.message.includes("429") || error.message.includes("RATE_LIMIT")) {
2434
- const match = error.message.match(/RATE_LIMIT: 429:(\d+)/);
2435
- const retryAfter = match ? parseInt(match[1], 10) : undefined;
2436
- return {
2437
- type: "RATE_LIMIT",
2438
- reason: "Rate limit exceeded",
2439
- status: 429,
2440
- retryAfter,
2441
- isRetryable: true,
2442
- };
2443
- }
2444
- if (error.message.includes("401") ||
2445
- error.message.includes("403") ||
2446
- error.message.includes("AUTH_ERROR")) {
2447
- const status = error.message.includes("401") ? 401 : 403;
2448
- return {
2449
- type: "AUTH_ERROR",
2450
- reason: `Authentication error (${status})`,
2451
- status,
2452
- isRetryable: false,
2453
- };
2454
- }
2455
- if (error.message.includes("SERVER_ERROR") ||
2456
- error.message.match(/50[0-9]/)) {
2457
- const statusMatch = error.message.match(/50[0-9]/);
2458
- const status = statusMatch ? parseInt(statusMatch[0], 10) : 500;
2459
- return {
2460
- type: "SERVER_ERROR",
2461
- reason: `Server error (${status})`,
2462
- status,
2463
- isRetryable: config.retryableStatusCodes.includes(status),
2464
- };
2465
- }
2466
- if (error.message.includes("network") ||
2467
- error.message.includes("NETWORK_ERROR")) {
2468
- return {
2469
- type: "NETWORK_ERROR",
2470
- reason: error.message,
2471
- status: null,
2472
- isRetryable: config.retryOnNetworkError,
2473
- };
2474
- }
2475
- }
2476
2813
  // Unknown error - not retryable by default for safety
2477
2814
  return {
2478
2815
  type: "UNKNOWN",
@@ -2482,13 +2819,16 @@ function analyzeError(error, response, config) {
2482
2819
  };
2483
2820
  }
2484
2821
  /**
2485
- * Calculates the delay before the next retry attempt using exponential backoff with jitter.
2822
+ * Calculates the delay before the next retry attempt using exponential backoff
2823
+ * with jitter, capped at `maxDelay`. Exported so the backoff contract (growth,
2824
+ * cap, jitter) is directly testable rather than only observable through timers.
2825
+ *
2486
2826
  * @param attempt - Current attempt number (1-indexed)
2487
2827
  * @param baseDelay - Base delay in milliseconds
2488
- * @param maxDelay - Maximum delay in milliseconds
2828
+ * @param maxDelay - Maximum delay in milliseconds (before jitter)
2489
2829
  * @returns Delay in milliseconds
2490
2830
  */
2491
- function calculateBackoff(attempt, baseDelay, maxDelay) {
2831
+ function calculateRetryBackoff(attempt, baseDelay, maxDelay) {
2492
2832
  // Exponential backoff: baseDelay * 2^(attempt-1)
2493
2833
  const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
2494
2834
  // Cap at maxDelay
@@ -2580,7 +2920,7 @@ async function withRetry(fn, config = {}, label = "unknown") {
2580
2920
  }
2581
2921
  // Analyze the error to determine if we should retry
2582
2922
  const response = error instanceof Response ? error : null;
2583
- const errorDetails = analyzeError(error, response, fullConfig);
2923
+ const errorDetails = classifyRetryError(error, response, fullConfig);
2584
2924
  // If error is not retryable, fail immediately
2585
2925
  if (!errorDetails.isRetryable) {
2586
2926
  getLogger().error(`[${label}] Non-retryable error (${errorDetails.type})`, {
@@ -2598,11 +2938,11 @@ async function withRetry(fn, config = {}, label = "unknown") {
2598
2938
  }
2599
2939
  else if (errorDetails.type === "RATE_LIMIT") {
2600
2940
  // For rate limits without Retry-After, use a longer minimum delay
2601
- delayMs = Math.max(calculateBackoff(attempt, fullConfig.baseDelayMs, fullConfig.maxDelayMs), 5000);
2941
+ delayMs = Math.max(calculateRetryBackoff(attempt, fullConfig.baseDelayMs, fullConfig.maxDelayMs), 5000);
2602
2942
  }
2603
2943
  else {
2604
2944
  // Standard exponential backoff with jitter
2605
- delayMs = calculateBackoff(attempt, fullConfig.baseDelayMs, fullConfig.maxDelayMs);
2945
+ delayMs = calculateRetryBackoff(attempt, fullConfig.baseDelayMs, fullConfig.maxDelayMs);
2606
2946
  }
2607
2947
  // Log the retry attempt
2608
2948
  getLogger().warn(`[${label}] Attempt ${attempt}/${fullConfig.maxRetries} failed: ${errorDetails.reason}. Retrying in ${delayMs}ms...`, {
@@ -2664,247 +3004,6 @@ const API_RETRY_CONFIGS = {
2664
3004
  },
2665
3005
  };
2666
3006
 
2667
- /**
2668
- * Structured error type hierarchy for all API integrations
2669
- *
2670
- * This module provides a comprehensive error handling system for external API integrations,
2671
- * including Alpaca, Massive, and AlphaVantage services.
2672
- */
2673
- /**
2674
- * Base error class for all @adaptic/utils errors
2675
- * Extends Error with additional context about service, error code, and retry capability
2676
- */
2677
- class AdapticUtilsError extends Error {
2678
- code;
2679
- service;
2680
- isRetryable;
2681
- cause;
2682
- name;
2683
- constructor(message, code, service, isRetryable = false, cause) {
2684
- super(message);
2685
- this.code = code;
2686
- this.service = service;
2687
- this.isRetryable = isRetryable;
2688
- this.cause = cause;
2689
- this.name = this.constructor.name;
2690
- // Maintains proper stack trace for where error was thrown (only available on V8)
2691
- if (Error.captureStackTrace) {
2692
- Error.captureStackTrace(this, this.constructor);
2693
- }
2694
- }
2695
- }
2696
- /**
2697
- * Alpaca API specific errors
2698
- * Handles all errors from Alpaca trading and market data APIs
2699
- */
2700
- class AlpacaApiError extends AdapticUtilsError {
2701
- statusCode;
2702
- constructor(message, code, statusCode, cause) {
2703
- // Rate limit (429) and server errors (5xx) are retryable
2704
- const isRetryable = statusCode === 429 || (statusCode !== undefined && statusCode >= 500);
2705
- super(message, code, "alpaca", isRetryable, cause);
2706
- this.statusCode = statusCode;
2707
- }
2708
- }
2709
- /**
2710
- * Massive.com API specific errors
2711
- * Handles all errors from Massive market data API
2712
- */
2713
- class MassiveApiError extends AdapticUtilsError {
2714
- statusCode;
2715
- constructor(message, code, statusCode, cause) {
2716
- // Rate limit (429) and server errors (5xx) are retryable
2717
- const isRetryable = statusCode === 429 || (statusCode !== undefined && statusCode >= 500);
2718
- super(message, code, "massive", isRetryable, cause);
2719
- this.statusCode = statusCode;
2720
- }
2721
- }
2722
- /**
2723
- * AlphaVantage API specific errors
2724
- * Handles all errors from AlphaVantage financial data API
2725
- */
2726
- class AlphaVantageError extends AdapticUtilsError {
2727
- statusCode;
2728
- constructor(message, code, statusCode, cause) {
2729
- // Rate limit (429) and server errors (5xx) are retryable
2730
- const isRetryable = statusCode === 429 || (statusCode !== undefined && statusCode >= 500);
2731
- super(message, code, "alphavantage", isRetryable, cause);
2732
- this.statusCode = statusCode;
2733
- }
2734
- }
2735
- /**
2736
- * Network timeout errors
2737
- * Used when API requests exceed configured timeout limits
2738
- * Always retryable as timeouts are often transient
2739
- */
2740
- class TimeoutError extends AdapticUtilsError {
2741
- service;
2742
- timeoutMs;
2743
- constructor(message, service, timeoutMs, cause) {
2744
- super(message, "TIMEOUT", service, true, // Timeouts are always retryable
2745
- cause);
2746
- this.service = service;
2747
- this.timeoutMs = timeoutMs;
2748
- }
2749
- }
2750
- /**
2751
- * Input validation errors
2752
- * Used when function inputs fail validation checks
2753
- * Never retryable as the inputs need to be corrected
2754
- */
2755
- class ValidationError extends AdapticUtilsError {
2756
- service;
2757
- invalidField;
2758
- constructor(message, service, invalidField, cause) {
2759
- super(message, "VALIDATION_ERROR", service, false, // Validation errors are never retryable
2760
- cause);
2761
- this.service = service;
2762
- this.invalidField = invalidField;
2763
- }
2764
- }
2765
- /**
2766
- * Authentication and authorization errors
2767
- * Used when API credentials are invalid, expired, or lack permissions
2768
- * Never retryable as credentials need to be updated
2769
- */
2770
- class AuthenticationError extends AdapticUtilsError {
2771
- service;
2772
- statusCode;
2773
- constructor(message, service, statusCode, cause) {
2774
- super(message, "AUTH_ERROR", service, false, // Auth errors are never retryable
2775
- cause);
2776
- this.service = service;
2777
- this.statusCode = statusCode;
2778
- }
2779
- }
2780
- /**
2781
- * HTTP client errors (4xx)
2782
- * Used for client-side errors that are not authentication or validation related
2783
- * Generally not retryable unless specific status codes indicate otherwise
2784
- */
2785
- class HttpClientError extends AdapticUtilsError {
2786
- service;
2787
- statusCode;
2788
- constructor(message, service, statusCode, cause) {
2789
- super(message, "CLIENT_ERROR", service, false, // Client errors are generally not retryable
2790
- cause);
2791
- this.service = service;
2792
- this.statusCode = statusCode;
2793
- }
2794
- }
2795
- /**
2796
- * HTTP server errors (5xx)
2797
- * Used for server-side errors from external APIs
2798
- * Always retryable as server issues are often transient
2799
- */
2800
- class HttpServerError extends AdapticUtilsError {
2801
- service;
2802
- statusCode;
2803
- constructor(message, service, statusCode, cause) {
2804
- super(message, "SERVER_ERROR", service, true, // Server errors are always retryable
2805
- cause);
2806
- this.service = service;
2807
- this.statusCode = statusCode;
2808
- }
2809
- }
2810
- /**
2811
- * Rate limit errors (429)
2812
- * Used when API rate limits are exceeded
2813
- * Always retryable, often with retry-after header information
2814
- */
2815
- class RateLimitError extends AdapticUtilsError {
2816
- service;
2817
- retryAfterMs;
2818
- constructor(message, service, retryAfterMs, cause) {
2819
- super(message, "RATE_LIMIT", service, true, // Rate limit errors are always retryable
2820
- cause);
2821
- this.service = service;
2822
- this.retryAfterMs = retryAfterMs;
2823
- }
2824
- }
2825
- /**
2826
- * WebSocket connection errors
2827
- * Used for WebSocket-specific connection and communication failures
2828
- * Retryability depends on the specific error condition
2829
- */
2830
- class WebSocketError extends AdapticUtilsError {
2831
- service;
2832
- constructor(message, service, isRetryable = true, cause) {
2833
- super(message, "WEBSOCKET_ERROR", service, isRetryable, cause);
2834
- this.service = service;
2835
- }
2836
- }
2837
- /**
2838
- * Network errors (connection failures, DNS issues, etc.)
2839
- * Used for low-level network failures
2840
- * Always retryable as network issues are often transient
2841
- */
2842
- class NetworkError extends AdapticUtilsError {
2843
- service;
2844
- constructor(message, service, cause) {
2845
- super(message, "NETWORK_ERROR", service, true, // Network errors are always retryable
2846
- cause);
2847
- this.service = service;
2848
- }
2849
- }
2850
- /**
2851
- * Unsupported brokerage provider errors
2852
- * Thrown when a broker operation is requested for a provider that has no
2853
- * implemented integration (e.g. IBKR or COINBASE before their adapters land,
2854
- * or an unrecognised provider string from an untyped caller).
2855
- * Never retryable — the caller must route to a supported provider.
2856
- */
2857
- class UnsupportedBrokerError extends AdapticUtilsError {
2858
- provider;
2859
- constructor(
2860
- /** The provider that was requested but is not supported. */
2861
- provider, cause) {
2862
- super(`Brokerage provider "${provider}" is not supported. Supported providers: ALPACA`, "UNSUPPORTED_BROKER", "broker", false, // Unsupported providers are never retryable
2863
- cause);
2864
- this.provider = provider;
2865
- }
2866
- }
2867
- /**
2868
- * Data parsing and format errors
2869
- * Used when API responses cannot be parsed or are in unexpected format
2870
- * Not retryable as the data format issue needs investigation
2871
- */
2872
- class DataFormatError extends AdapticUtilsError {
2873
- service;
2874
- constructor(message, service, cause) {
2875
- super(message, "DATA_FORMAT_ERROR", service, false, // Data format errors are not retryable
2876
- cause);
2877
- this.service = service;
2878
- }
2879
- }
2880
- /**
2881
- * Broker-side duplicate `client_order_id` rejection (Alpaca HTTP 422,
2882
- * "client order id must be unique").
2883
- *
2884
- * Thrown by the order-creation paths of `AlpacaTradingAPI` so callers can
2885
- * distinguish "this exact order was already submitted" from a genuine order
2886
- * rejection. When {@link wasDerived} is `false` the id was caller-supplied and
2887
- * the caller owns idempotency semantics (a legitimate repeat needs a new
2888
- * explicit id or an `idempotencyNonce`). When `true`, the wrapper's automatic
2889
- * recovery (existing-order lookup, then one salted resubmit) was exhausted.
2890
- *
2891
- * Never retryable with the same id — resubmitting the identical
2892
- * `client_order_id` will 422 again.
2893
- */
2894
- class DuplicateClientOrderIdError extends AlpacaApiError {
2895
- clientOrderId;
2896
- wasDerived;
2897
- constructor(message,
2898
- /** The `client_order_id` that collided broker-side. */
2899
- clientOrderId,
2900
- /** Whether the colliding id was derived by the wrapper (vs caller-supplied). */
2901
- wasDerived, cause) {
2902
- super(message, "DUPLICATE_CLIENT_ORDER_ID", 422, cause);
2903
- this.clientOrderId = clientOrderId;
2904
- this.wasDerived = wasDerived;
2905
- }
2906
- }
2907
-
2908
3007
  /**
2909
3008
  * Token bucket rate limiter for external API integrations
2910
3009
  *
@@ -4644,9 +4743,9 @@ const CLIENT_ORDER_ID_WINDOW_MS = 300_000;
4644
4743
  * used both "client_order_id must be unique" and "client order id must be
4645
4744
  * unique" across API revisions, so separators are matched loosely.
4646
4745
  */
4647
- const DUPLICATE_CLIENT_ORDER_ID_PATTERN = /client[\s_-]?order[\s_-]?id must be unique/i;
4746
+ const DUPLICATE_CLIENT_ORDER_ID_PATTERN$1 = /client[\s_-]?order[\s_-]?id must be unique/i;
4648
4747
  /** HTTP status Alpaca uses for duplicate `client_order_id` rejections. */
4649
- const DUPLICATE_CLIENT_ORDER_ID_STATUS = 422;
4748
+ const DUPLICATE_CLIENT_ORDER_ID_STATUS$1 = 422;
4650
4749
  /**
4651
4750
  * Order statuses in which a previously-submitted order can never execute.
4652
4751
  * A derived-id duplicate colliding with an order in one of these states is a
@@ -4654,7 +4753,7 @@ const DUPLICATE_CLIENT_ORDER_ID_STATUS = 422;
4654
4753
  * stop) and is resubmitted with a fresh salt; any other status means the
4655
4754
  * original order is live or executed, so it is returned as idempotent success.
4656
4755
  */
4657
- const TERMINAL_DEAD_ORDER_STATUSES = new Set([
4756
+ const TERMINAL_DEAD_ORDER_STATUSES$1 = new Set([
4658
4757
  "canceled",
4659
4758
  "expired",
4660
4759
  "rejected",
@@ -4788,8 +4887,8 @@ class AlpacaTradingAPI {
4788
4887
  isDuplicateClientOrderIdRejection(error) {
4789
4888
  if (!(error instanceof Error))
4790
4889
  return false;
4791
- return (error.message.includes(`(${DUPLICATE_CLIENT_ORDER_ID_STATUS})`) &&
4792
- DUPLICATE_CLIENT_ORDER_ID_PATTERN.test(error.message));
4890
+ return (error.message.includes(`(${DUPLICATE_CLIENT_ORDER_ID_STATUS$1})`) &&
4891
+ DUPLICATE_CLIENT_ORDER_ID_PATTERN$1.test(error.message));
4793
4892
  }
4794
4893
  /**
4795
4894
  * Look up an order by its `client_order_id` (Alpaca
@@ -4866,7 +4965,7 @@ class AlpacaTradingAPI {
4866
4965
  : String(lookupError)}`, { symbol: options.logSymbol, type: "error" });
4867
4966
  throw new DuplicateClientOrderIdError(`Duplicate client_order_id "${clientOrderId}" rejected by Alpaca and the existing-order lookup failed; refusing to resubmit (possible live duplicate)`, clientOrderId, true, lookupError);
4868
4967
  }
4869
- if (existing && !TERMINAL_DEAD_ORDER_STATUSES.has(existing.status)) {
4968
+ if (existing && !TERMINAL_DEAD_ORDER_STATUSES$1.has(existing.status)) {
4870
4969
  this.idempotentDuplicateReturns++;
4871
4970
  this.log(`Derived client_order_id ${clientOrderId} already submitted (status=${existing.status}); returning existing order ${existing.id} as idempotent success`, {
4872
4971
  symbol: options.logSymbol,
@@ -57852,6 +57951,10 @@ var optionOrders = /*#__PURE__*/Object.freeze({
57852
57951
  validateMultiLegOrder: validateMultiLegOrder
57853
57952
  });
57854
57953
 
57954
+ /**
57955
+ * Alpaca Order Management Module
57956
+ * Provides functions for creating, managing, and canceling orders using the official SDK
57957
+ */
57855
57958
  const LOG_SOURCE$4 = "AlpacaOrders";
57856
57959
  /**
57857
57960
  * Internal logging helper with consistent source
@@ -57859,23 +57962,295 @@ const LOG_SOURCE$4 = "AlpacaOrders";
57859
57962
  const log$6 = (message, options = { type: "info" }) => {
57860
57963
  log$m(message, { ...options, source: LOG_SOURCE$4 });
57861
57964
  };
57965
+ /**
57966
+ * Maximum length Alpaca accepts for a `client_order_id`.
57967
+ */
57968
+ const MAX_CLIENT_ORDER_ID_LENGTH = 128;
57969
+ /**
57970
+ * Characters Alpaca does **not** accept inside a `client_order_id`. Anything
57971
+ * matching is rewritten before submission.
57972
+ */
57973
+ const UNSAFE_CLIENT_ORDER_ID_CHARS = /[^A-Za-z0-9._:-]/g;
57974
+ /**
57975
+ * Hex characters of the SHA-256 digest appended when an idempotency key had to
57976
+ * be rewritten (unsafe characters or over-length). 16 hex chars = 64 bits,
57977
+ * which keeps two keys that sanitise to the same head distinguishable.
57978
+ */
57979
+ const CLIENT_ORDER_ID_DIGEST_LENGTH = 16;
57980
+ /** Separator between the sanitised head and the digest tag. */
57981
+ const CLIENT_ORDER_ID_DIGEST_SEPARATOR = "-";
57982
+ /** HTTP status Alpaca uses for a duplicate `client_order_id`. */
57983
+ const DUPLICATE_CLIENT_ORDER_ID_STATUS = 422;
57984
+ /** HTTP status Alpaca returns when no order carries the given id. */
57985
+ const ORDER_NOT_FOUND_STATUS = 404;
57986
+ /**
57987
+ * Matches Alpaca's duplicate-idempotency-key rejection text. Alpaca has used
57988
+ * both "client_order_id must be unique" and "client order id must be unique"
57989
+ * across API revisions, so the separator is matched loosely.
57990
+ */
57991
+ const DUPLICATE_CLIENT_ORDER_ID_PATTERN = /client[\s_-]?order[\s_-]?id must be unique/i;
57992
+ /**
57993
+ * Order statuses in which a previously-submitted order can never execute. A
57994
+ * duplicate colliding with an order in one of these states is NOT an idempotent
57995
+ * success — the caller asked for an order that cannot exist under that id, so
57996
+ * the typed duplicate error is raised instead of a silent resubmission.
57997
+ */
57998
+ const TERMINAL_DEAD_ORDER_STATUSES = new Set([
57999
+ "canceled",
58000
+ "expired",
58001
+ "rejected",
58002
+ "replaced",
58003
+ "done_for_day",
58004
+ ]);
58005
+ /**
58006
+ * Prefix marking a `client_order_id` this module minted because the caller
58007
+ * supplied no identity of its own. It makes the un-migrated call sites
58008
+ * greppable broker-side and in fill telemetry, and it can never collide with
58009
+ * the engine's `trade.id` convention.
58010
+ */
58011
+ const GENERATED_IDEMPOTENCY_KEY_PREFIX = "adptc-auto";
58012
+ /**
58013
+ * Mints a per-submission idempotency key for a caller that supplied none.
58014
+ *
58015
+ * The key is a fresh random UUID, so it is unique to **this call** and is held
58016
+ * constant for the whole of it — which is precisely what makes the transport
58017
+ * retry inside {@link AlpacaClient.executeWithRateLimit} idempotent: a POST
58018
+ * that already landed is refused broker-side on the re-send instead of filling
58019
+ * twice (F-0035). It deliberately carries no other meaning. It is **not**
58020
+ * derived from the order's contents or from the clock, because either would
58021
+ * claim a de-duplication across separate calls that a generated key cannot
58022
+ * honestly provide: content-derived keys would silently swallow a legitimate
58023
+ * repeat order, and clock-derived keys would de-duplicate or not depending on
58024
+ * which side of a bucket boundary the second call fell.
58025
+ *
58026
+ * @returns A broker-safe, unique key for a single submission.
58027
+ */
58028
+ function generateSubmissionIdempotencyKey() {
58029
+ return `${GENERATED_IDEMPOTENCY_KEY_PREFIX}-${node_crypto.randomUUID()}`;
58030
+ }
58031
+ /**
58032
+ * Validates a caller-supplied idempotency key.
58033
+ * @param idempotencyKey - The key to validate.
58034
+ * @returns The trimmed key.
58035
+ * @throws Error when the key is empty or whitespace-only.
58036
+ */
58037
+ function requireIdempotencyKey(idempotencyKey) {
58038
+ const key = idempotencyKey.trim();
58039
+ if (key.length === 0) {
58040
+ throw new Error("Order submission requires a non-empty idempotency key identifying the logical order");
58041
+ }
58042
+ return key;
58043
+ }
58044
+ /**
58045
+ * Derives the broker-side `client_order_id` from a caller-supplied idempotency
58046
+ * key. Deterministic: the same key always yields the same id, so a retry of the
58047
+ * same logical order collides broker-side instead of creating a second order.
58048
+ *
58049
+ * A key that is already broker-safe is used **verbatim**, which preserves the
58050
+ * engine's `client_order_id === trade.id` convention (and keeps every
58051
+ * reconciliation path that joins on `trade.id` working). A key needing
58052
+ * sanitisation or truncation is rewritten and tagged with a SHA-256 digest of
58053
+ * the original, so two distinct keys can never collapse onto one id.
58054
+ *
58055
+ * @param idempotencyKey - Identity of the logical order.
58056
+ * @returns An Alpaca-safe, length-bounded `client_order_id`.
58057
+ * @throws Error when the key is empty or whitespace-only.
58058
+ */
58059
+ function deriveClientOrderId(idempotencyKey) {
58060
+ const key = requireIdempotencyKey(idempotencyKey);
58061
+ const sanitized = key.replace(UNSAFE_CLIENT_ORDER_ID_CHARS, "-");
58062
+ if (sanitized === key && key.length <= MAX_CLIENT_ORDER_ID_LENGTH) {
58063
+ return key;
58064
+ }
58065
+ const digest = node_crypto.createHash("sha256")
58066
+ .update(key)
58067
+ .digest("hex")
58068
+ .slice(0, CLIENT_ORDER_ID_DIGEST_LENGTH);
58069
+ const headLength = MAX_CLIENT_ORDER_ID_LENGTH -
58070
+ digest.length -
58071
+ CLIENT_ORDER_ID_DIGEST_SEPARATOR.length;
58072
+ return `${sanitized.slice(0, headLength)}${CLIENT_ORDER_ID_DIGEST_SEPARATOR}${digest}`;
58073
+ }
58074
+ /**
58075
+ * Validates a `client_order_id` a caller supplied **explicitly**, instead of
58076
+ * letting it be derived from the idempotency key.
58077
+ *
58078
+ * An explicit id bypasses {@link deriveClientOrderId}, so it must satisfy the
58079
+ * same broker contract on its own: non-empty, within Alpaca's length bound, and
58080
+ * free of characters Alpaca rejects. A blank or malformed id is refused here
58081
+ * rather than submitted — a submission the broker ignores or rejects leaves the
58082
+ * POST non-idempotent, which is the F-0035 failure this contract exists to
58083
+ * close. It is never silently rewritten: rewriting a caller-chosen id would
58084
+ * break every reconciliation path that joins on it.
58085
+ *
58086
+ * @param clientOrderId - The explicitly supplied broker id.
58087
+ * @returns The id, unchanged.
58088
+ * @throws Error when the id is blank, over-length, or contains characters
58089
+ * Alpaca does not accept.
58090
+ */
58091
+ function requireExplicitClientOrderId(clientOrderId) {
58092
+ if (clientOrderId.trim().length === 0) {
58093
+ throw new Error("Order submission was given a blank client_order_id; supply a non-empty id or omit it and let the idempotency key derive one");
58094
+ }
58095
+ if (clientOrderId.length > MAX_CLIENT_ORDER_ID_LENGTH) {
58096
+ throw new Error(`Order submission client_order_id exceeds Alpaca's ${MAX_CLIENT_ORDER_ID_LENGTH}-character limit (${clientOrderId.length})`);
58097
+ }
58098
+ if (clientOrderId.replace(UNSAFE_CLIENT_ORDER_ID_CHARS, "-") !== clientOrderId) {
58099
+ throw new Error(`Order submission client_order_id "${clientOrderId}" contains characters Alpaca does not accept`);
58100
+ }
58101
+ return clientOrderId;
58102
+ }
58103
+ /**
58104
+ * Renders everything the broker said about a rejection: the error message plus
58105
+ * the vendor payload, which is where Alpaca puts the actual reason (the SDK's
58106
+ * own message is only "Request failed with status code NNN").
58107
+ *
58108
+ * @param error - The thrown value.
58109
+ * @returns A single human-readable description.
58110
+ */
58111
+ function describeRejection(error) {
58112
+ const message = error instanceof Error ? error.message : String(error);
58113
+ if (typeof error !== "object" || error === null) {
58114
+ return message;
58115
+ }
58116
+ const response = error.response;
58117
+ if (typeof response !== "object" || response === null) {
58118
+ return message;
58119
+ }
58120
+ const data = response.data;
58121
+ if (data === undefined || data === null) {
58122
+ return message;
58123
+ }
58124
+ const rendered = typeof data === "string" ? data : JSON.stringify(data);
58125
+ return `${message}: ${rendered}`;
58126
+ }
58127
+ /**
58128
+ * Whether a rejection is Alpaca's duplicate-`client_order_id` refusal — decided
58129
+ * from the typed HTTP status plus the vendor payload, never from a status-shaped
58130
+ * number found loose in the text.
58131
+ *
58132
+ * @param error - The thrown value.
58133
+ * @returns true when the broker refused the order as a duplicate.
58134
+ */
58135
+ function isDuplicateClientOrderIdRejection(error) {
58136
+ if (classifyRetryError(error).status !== DUPLICATE_CLIENT_ORDER_ID_STATUS) {
58137
+ return false;
58138
+ }
58139
+ return DUPLICATE_CLIENT_ORDER_ID_PATTERN.test(describeRejection(error));
58140
+ }
58141
+ /**
58142
+ * Looks up an order by its `client_order_id`.
58143
+ *
58144
+ * @param client - The AlpacaClient instance
58145
+ * @param clientOrderId - The idempotency key the order was submitted with
58146
+ * @returns The order, or null when the broker holds no order for that id
58147
+ * @throws The underlying error when the lookup fails for any reason other than
58148
+ * "not found" — an unverifiable lookup must never be read as "no order".
58149
+ *
58150
+ * @example
58151
+ * const existing = await getOrderByClientOrderId(client, trade.id);
58152
+ */
58153
+ async function getOrderByClientOrderId(client, clientOrderId) {
58154
+ try {
58155
+ const sdk = client.getSDK();
58156
+ return await client.executeWithRateLimit(() => sdk.getOrderByClientId(clientOrderId), `getOrderByClientOrderId ${clientOrderId}`);
58157
+ }
58158
+ catch (error) {
58159
+ if (classifyRetryError(error).status === ORDER_NOT_FOUND_STATUS) {
58160
+ return null;
58161
+ }
58162
+ throw error;
58163
+ }
58164
+ }
58165
+ /**
58166
+ * Resolves a duplicate-`client_order_id` rejection **without ever issuing a
58167
+ * second POST**.
58168
+ *
58169
+ * - Colliding order live or filled → returned as idempotent success. This is
58170
+ * the retry-after-network-failure case the key exists to de-duplicate.
58171
+ * - Colliding order terminally dead, absent, or unverifiable → typed
58172
+ * {@link DuplicateClientOrderIdError}. Fails **closed**: the 422 proves an
58173
+ * order with this id exists, so resubmitting could double-fill.
58174
+ *
58175
+ * @param client - The AlpacaClient instance
58176
+ * @param clientOrderId - The id the broker refused as a duplicate
58177
+ * @param symbol - Symbol, for log attribution
58178
+ * @param cause - The original duplicate rejection
58179
+ * @returns The already-submitted order when it is live or filled
58180
+ */
58181
+ async function resolveDuplicateSubmission(client, clientOrderId, symbol, cause) {
58182
+ let existing;
58183
+ try {
58184
+ existing = await getOrderByClientOrderId(client, clientOrderId);
58185
+ }
58186
+ catch (lookupError) {
58187
+ const reason = lookupError instanceof Error ? lookupError.message : String(lookupError);
58188
+ log$6(`Duplicate-order lookup failed for ${clientOrderId}; failing closed (no resubmit): ${reason}`, { type: "error", symbol, metadata: { clientOrderId } });
58189
+ throw new DuplicateClientOrderIdError(`Duplicate client_order_id "${clientOrderId}" rejected by Alpaca and the existing-order lookup failed; refusing to resubmit (possible live duplicate)`, clientOrderId, false, lookupError);
58190
+ }
58191
+ if (existing && !TERMINAL_DEAD_ORDER_STATUSES.has(existing.status)) {
58192
+ log$6(`client_order_id ${clientOrderId} already submitted (status=${existing.status}); returning existing order ${existing.id} as idempotent success`, {
58193
+ type: "warn",
58194
+ symbol,
58195
+ metadata: {
58196
+ outcome: "idempotent_return",
58197
+ clientOrderId,
58198
+ orderId: existing.id,
58199
+ status: existing.status,
58200
+ },
58201
+ });
58202
+ return existing;
58203
+ }
58204
+ throw new DuplicateClientOrderIdError(existing
58205
+ ? `Duplicate client_order_id "${clientOrderId}" collided with a terminal (${existing.status}) order; a new logical order needs a new idempotency key`
58206
+ : `Duplicate client_order_id "${clientOrderId}" rejected by Alpaca but no order carries that id; refusing to resubmit`, clientOrderId, false, cause);
58207
+ }
57862
58208
  /**
57863
58209
  * Creates a new order using the Alpaca SDK.
57864
58210
  * Supports market, limit, stop, and stop_limit order types.
57865
58211
  *
58212
+ * Every submission carries a `client_order_id`, so the retry the underlying
58213
+ * client performs on transient network failure can never fill the same order
58214
+ * twice: the re-sent POST is refused broker-side and the order that landed is
58215
+ * returned (F-0035).
58216
+ *
58217
+ * Where that id comes from, in precedence order:
58218
+ *
58219
+ * 1. an explicit `params.client_order_id` — validated (non-empty, length- and
58220
+ * charset-safe) and used verbatim, never blanked, never silently rewritten;
58221
+ * 2. `params.idempotencyKey` — the identity of the **logical** order, from
58222
+ * which {@link deriveClientOrderId} derives the id deterministically, so a
58223
+ * resubmission of the same logical order collides at the broker instead of
58224
+ * creating a second position;
58225
+ * 3. neither — a key is minted for this submission alone. **This is the
58226
+ * deprecated path.** It closes the transport-retry double-fill and nothing
58227
+ * more: two `createOrder` calls for the same logical order are two distinct
58228
+ * ids and therefore two live orders. It exists because published callers
58229
+ * that carry no order identity would otherwise fail to compile, it logs a
58230
+ * warning with `outcome: "generated_idempotency_key"` on every submission,
58231
+ * and it is removed once those callers pass their own identity.
58232
+ *
57866
58233
  * @param client - The AlpacaClient instance
57867
- * @param params - Order parameters including symbol, qty, side, type, and time_in_force
57868
- * @returns The created order object
57869
- * @throws Error if order creation fails
58234
+ * @param params - Order parameters (symbol, qty, side, type, time_in_force);
58235
+ * supply `idempotencyKey` (or an explicit `client_order_id`) to get
58236
+ * caller-level idempotency rather than transport-level only
58237
+ * @returns The created order, or the already-submitted order when the broker
58238
+ * refused the submission as a duplicate of a live/filled order
58239
+ * @throws DuplicateClientOrderIdError when the id collides with an order that
58240
+ * cannot be treated as this submission's success
58241
+ * @throws Error when a supplied idempotency key is blank, when an explicitly
58242
+ * supplied `client_order_id` is blank/over-length/unsafe, or if order
58243
+ * creation fails
57870
58244
  *
57871
58245
  * @example
57872
- * // Create a market order
58246
+ * // Create a market order, keyed on the originating trade
57873
58247
  * const order = await createOrder(client, {
57874
58248
  * symbol: 'AAPL',
57875
58249
  * qty: '10',
57876
58250
  * side: 'buy',
57877
58251
  * type: 'market',
57878
58252
  * time_in_force: 'day',
58253
+ * idempotencyKey: trade.id,
57879
58254
  * });
57880
58255
  *
57881
58256
  * @example
@@ -57887,22 +58262,47 @@ const log$6 = (message, options = { type: "info" }) => {
57887
58262
  * type: 'limit',
57888
58263
  * limit_price: '150.00',
57889
58264
  * time_in_force: 'gtc',
58265
+ * idempotencyKey: `${trade.id}-limit`,
57890
58266
  * });
57891
58267
  */
57892
58268
  async function createOrder(client, params) {
57893
- const { symbol, qty, side, type } = params;
57894
- log$6(`Creating ${type} order: ${side} ${qty || params.notional} ${symbol}`, {
58269
+ const { idempotencyKey, ...orderParams } = params;
58270
+ // A key that is present must be well-formed even when an explicit
58271
+ // `client_order_id` would have won: a blank key is a caller defect, not a
58272
+ // silent fall-through to some other identity.
58273
+ const suppliedKey = idempotencyKey === undefined ? null : requireIdempotencyKey(idempotencyKey);
58274
+ const isGeneratedKey = suppliedKey === null && orderParams.client_order_id === undefined;
58275
+ // `??` would let `client_order_id: ""` through: the broker would then mint its
58276
+ // own id and the submission would be non-idempotent again despite a valid key
58277
+ // having been supplied. An explicit id is validated, never defaulted past.
58278
+ const clientOrderId = orderParams.client_order_id === undefined
58279
+ ? deriveClientOrderId(suppliedKey ?? generateSubmissionIdempotencyKey())
58280
+ : requireExplicitClientOrderId(orderParams.client_order_id);
58281
+ const submission = {
58282
+ ...orderParams,
58283
+ client_order_id: clientOrderId,
58284
+ };
58285
+ const { symbol, qty, side, type } = submission;
58286
+ if (isGeneratedKey) {
58287
+ log$6(`Order submitted with no caller idempotency key; minted ${clientOrderId} for this submission only — the transport retry is de-duplicated, a caller-level resubmission is NOT`, {
58288
+ type: "warn",
58289
+ symbol,
58290
+ metadata: { outcome: "generated_idempotency_key", clientOrderId },
58291
+ });
58292
+ }
58293
+ log$6(`Creating ${type} order: ${side} ${qty || submission.notional} ${symbol} (client_order_id=${clientOrderId})`, {
57895
58294
  type: "info",
57896
58295
  symbol,
57897
58296
  });
57898
58297
  try {
57899
58298
  const sdk = client.getSDK();
57900
- const order = await client.executeWithRateLimit(() => sdk.createOrder(params), `createOrder ${symbol}`);
58299
+ const order = (await client.executeWithRateLimit(() => sdk.createOrder(submission), `createOrder ${symbol}`));
57901
58300
  log$6(`Order created successfully: ${order.id}`, {
57902
58301
  type: "info",
57903
58302
  symbol,
57904
58303
  metadata: {
57905
58304
  orderId: order.id,
58305
+ clientOrderId,
57906
58306
  status: order.status,
57907
58307
  type: order.type,
57908
58308
  side: order.side,
@@ -57911,11 +58311,14 @@ async function createOrder(client, params) {
57911
58311
  return order;
57912
58312
  }
57913
58313
  catch (error) {
57914
- const errorMessage = error instanceof Error ? error.message : "Unknown error";
58314
+ if (isDuplicateClientOrderIdRejection(error)) {
58315
+ return await resolveDuplicateSubmission(client, clientOrderId, symbol, error);
58316
+ }
58317
+ const errorMessage = describeRejection(error);
57915
58318
  log$6(`Failed to create order for ${symbol}: ${errorMessage}`, {
57916
58319
  type: "error",
57917
58320
  symbol,
57918
- metadata: { params },
58321
+ metadata: { params: submission },
57919
58322
  });
57920
58323
  throw new Error(`Failed to create ${type} order for ${symbol}: ${errorMessage}`);
57921
58324
  }
@@ -58253,12 +58656,15 @@ async function getOrderByClientId(client, clientOrderId) {
58253
58656
 
58254
58657
  var trading = /*#__PURE__*/Object.freeze({
58255
58658
  __proto__: null,
58659
+ MAX_CLIENT_ORDER_ID_LENGTH: MAX_CLIENT_ORDER_ID_LENGTH,
58256
58660
  cancelAllOrders: cancelAllOrders,
58257
58661
  cancelOrder: cancelOrder,
58258
58662
  createOrder: createOrder,
58663
+ deriveClientOrderId: deriveClientOrderId,
58259
58664
  getOpenOrders: getOpenOrders,
58260
58665
  getOrder: getOrder,
58261
58666
  getOrderByClientId: getOrderByClientId,
58667
+ getOrderByClientOrderId: getOrderByClientOrderId,
58262
58668
  getOrders: getOrders,
58263
58669
  isOrderCancelable: isOrderCancelable,
58264
58670
  isOrderTerminal: isOrderTerminal,
@@ -63781,6 +64187,29 @@ class StampedeProtectedCache {
63781
64187
  has(key) {
63782
64188
  return this.cache.has(key);
63783
64189
  }
64190
+ /**
64191
+ * Synchronous peek at a cached value without triggering a loader.
64192
+ *
64193
+ * @description Returns the cached value when present and FRESH (inside its
64194
+ * per-entry TTL), `undefined` otherwise — never coalesces onto or starts a
64195
+ * load, never serves stale-while-revalidate data, and counts a hit only
64196
+ * when a fresh value is returned (mirroring the read-path hit semantics so
64197
+ * hitRatio stays truthful). Added 2026-08-08 for the engine cache
64198
+ * consolidation: its fork exposed peek() and consumers rely on the
64199
+ * no-load contract on hot paths.
64200
+ *
64201
+ * @param key - Cache key to inspect.
64202
+ * @returns The fresh cached value, or `undefined` when absent or expired.
64203
+ */
64204
+ peek(key) {
64205
+ const entry = this.cache.get(key);
64206
+ if (entry && Date.now() < entry.expiresAt) {
64207
+ this.stats.hits++;
64208
+ this.emitEvent("hit", key);
64209
+ return entry.value;
64210
+ }
64211
+ return undefined;
64212
+ }
63784
64213
  /**
63785
64214
  * Delete a specific key from the cache
63786
64215
  *