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