@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.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
|
-
*
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
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
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
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
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
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
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
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
|
-
|
|
2407
|
-
|
|
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: "
|
|
2410
|
-
reason: "
|
|
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:
|
|
2797
|
+
isRetryable: error.isRetryable && fullConfig.retryOnNetworkError,
|
|
2413
2798
|
};
|
|
2414
2799
|
}
|
|
2415
|
-
//
|
|
2416
|
-
// Node/undici error codes
|
|
2417
|
-
|
|
2418
|
-
|
|
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:
|
|
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
|
|
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
|
|
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 =
|
|
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(
|
|
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 =
|
|
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
|
|
57866
|
-
*
|
|
57867
|
-
*
|
|
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 {
|
|
57892
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|