@adaptic/utils 0.0.1010 → 0.0.1012
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 +776 -370
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +776 -370
- package/dist/index.mjs.map +1 -1
- package/dist/test.js +45 -45
- package/dist/test.js.map +1 -1
- package/dist/types/__tests__/alpaca-order-idempotency.test.d.ts +2 -0
- package/dist/types/__tests__/alpaca-order-idempotency.test.d.ts.map +1 -0
- package/dist/types/__tests__/retry-classification.test.d.ts +2 -0
- package/dist/types/__tests__/retry-classification.test.d.ts.map +1 -0
- package/dist/types/alpaca/index.d.ts +4 -1
- package/dist/types/alpaca/index.d.ts.map +1 -1
- package/dist/types/alpaca/trading/orders.d.ts +106 -9
- package/dist/types/alpaca/trading/orders.d.ts.map +1 -1
- package/dist/types/index.d.ts +8 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/utils/retry.d.ts +65 -0
- package/dist/types/utils/retry.d.ts.map +1 -1
- package/package.json +1 -1
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
|
-
*
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
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
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
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
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
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
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
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
|
-
|
|
2409
|
-
|
|
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: "
|
|
2412
|
-
reason: "
|
|
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:
|
|
2799
|
+
isRetryable: error.isRetryable && fullConfig.retryOnNetworkError,
|
|
2415
2800
|
};
|
|
2416
2801
|
}
|
|
2417
|
-
//
|
|
2418
|
-
// Node/undici error codes
|
|
2419
|
-
|
|
2420
|
-
|
|
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:
|
|
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
|
|
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
|
|
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 =
|
|
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(
|
|
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 =
|
|
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
|
|
57868
|
-
*
|
|
57869
|
-
*
|
|
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 {
|
|
57894
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|