@cexyio/cexy 0.1.0-dev.3 → 0.1.0-dev.5

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/README.md CHANGED
@@ -60,7 +60,27 @@ await cexy.trading.cancelAll({ symbol: "BTC/USDT" }); // { symbol: null } = ever
60
60
  ```
61
61
 
62
62
  `cancelAll` requires `symbol`: the server treats a missing symbol as "every market", so the SDK makes
63
- you say so with `{ symbol: null }`. The server allows cancel-all 30 times per minute per account.
63
+ you say so with `{ symbol: null }`. An unknown symbol throws `NotFoundError`.
64
+
65
+ One call handles at most 500 orders and puts each in exactly one list: `cancelled`, `already_closed`
66
+ (it filled, was refused or was cancelled elsewhere first; not an error) or `failed`, with the reason in
67
+ `failures` (`INVALID_STATE` for an order still being placed). `has_more: true` means more orders remain.
68
+ To let the SDK repeat the call for you:
69
+
70
+ ```ts
71
+ const res = await cexy.trading.cancelAll({ symbol: null, untilDone: true });
72
+ // res.stopped: "done" | "max_rounds" | "time_budget"; res.rounds: calls made
73
+ if (res.stopped !== "done" || res.failed.length) console.warn("left over:", res.failures);
74
+ ```
75
+
76
+ `untilDone` repeats while `has_more` is true or a failure is `INVALID_STATE` / `SERVICE_UNAVAILABLE`.
77
+ After a call without progress it waits 1, 2, 4, 8, then 15 s, and it stops after `maxRounds` calls
78
+ (default 20) or before a wait would pass `timeBudgetMs` (default 120 000). Every round is exactly one
79
+ request (the loop owns the retries, so it never sends more than `maxRounds` requests): a 429 round waits its
80
+ Retry-After, which counts against the budget; another retryable error (5xx, network) takes the next backoff
81
+ step; a wait that would pass the budget ends the loop with `stopped: "time_budget"` and `last_error_code`.
82
+ A non-retryable error (e.g. a key without the trade scope) throws `CancelAllInterruptedError` with the error
83
+ and the partial result. The server allows 30 cancel-all calls per minute per account.
64
84
 
65
85
  Give both `apiKey` and `apiSecret`, or neither: passing only one throws at construction.
66
86
 
@@ -110,7 +130,7 @@ Every API failure throws a `CexyApiError` (or a subclass) with `status`, `code`,
110
130
  | `UnprocessableError` | 422: `INSUFFICIENT_FUNDS`, `MARKET_UNAVAILABLE`, ... |
111
131
  | `RateLimitError` | 429, with `retryAfterMs` |
112
132
  | `ServerError` | 5xx |
113
- | `CexyApiError` | any code this SDK version does not know yet |
133
+ | `CexyApiError` | any code this SDK version does not know yet, and `UNEXPECTED_REDIRECT` (the server answered with a 3xx; set by the SDK, see `CLIENT_ERROR_CODES`) |
114
134
 
115
135
  Local problems use `CexyConfigError`, `InvalidAmountError`, `CexyConnectionError` / `CexyTimeoutError`
116
136
  and `OrderStateUnknownError`. `ErrorCode` is a union of the known codes plus `string`, because new codes
@@ -129,10 +149,13 @@ try {
129
149
 
130
150
  - Timeout per attempt: `timeoutMs` (default 10 s). Retries: `maxRetries` (default 3), exponential backoff with full jitter.
131
151
  - Retried: network errors, timeouts and responses with `retryable: true`.
132
- - 429 waits at least `Retry-After` / `details.retry_after_seconds`.
152
+ - 429 waits at least `Retry-After` / `details.retry_after_seconds`. Server wait hints are untrusted: unusable
153
+ values are ignored, and a hint longer than 120 s (`MAX_SERVER_WAIT_MS`) is never waited: the call fails at
154
+ once with `RateLimitError`, whose `retryAfterMs` still has the server's value. The client-side rate limiter
155
+ never blocks longer than 120 s because of a server hint.
133
156
  - GETs retry freely.
134
- - **Orders:** safety rests on `client_order_id`, not on `Idempotency-Key` (the server does not honour
135
- that header on `POST /trading/orders`, order cancels or cancel-all). `placeOrder` always sends a
157
+ - **Orders:** safety rests on `client_order_id`. The server does not honour `Idempotency-Key` on
158
+ `POST /trading/orders`, order cancels or cancel-all, so the SDK does not send it there. `placeOrder` always sends a
136
159
  `client_order_id` (a UUID if you do not set one); it is unique per account and a repeat is refused
137
160
  before any funds move. After an ambiguous failure (network error, timeout, 5xx) the SDK first looks the
138
161
  order up by that id. If the order exists it is returned with `recovered: true`; only if it does not
@@ -141,9 +164,9 @@ try {
141
164
  - **Cancels:** `cancelOrder` retries network errors; if a *retry* gets `INVALID_STATE`, the first attempt
142
165
  already cancelled the order, so the SDK fetches and returns it. `cancelAll` is naturally repeatable and
143
166
  is retried the same way (a retry reports only what it cancelled).
144
- - **Pool join/exit** send an auto-generated `Idempotency-Key`, reused on every retry; the server honours
145
- it there, so they execute once. A 409 `CONCURRENT_MODIFICATION` (the same key still in flight) is
146
- retried with the same key. Pass `{ idempotencyKey }` to control it yourself.
167
+ - **Pool join/exit** are the only requests that send an `Idempotency-Key` (auto-generated, reused on every
168
+ retry); the server honours it there, so they execute once. A 409 `CONCURRENT_MODIFICATION` (the same key
169
+ still in flight) is retried with the same key. Pass `{ idempotencyKey }` to control it yourself.
147
170
  - `onRetry` lets you log retries.
148
171
 
149
172
  Every method takes a last `RequestOptions` argument: `{ signal, timeoutMs, maxRetries, idempotencyKey }`.
@@ -231,7 +254,8 @@ endpoints from a server. Browsers do not let scripts set `User-Agent`, so the SD
231
254
  `http://` / `ws://` solely for `localhost`, `127.0.0.1` or `::1` (local test servers).
232
255
  - The SDK **never follows HTTP redirects**. A 3xx answer throws a `CexyApiError` with code
233
256
  `UNEXPECTED_REDIRECT` (not retried), so credentials are never re-sent to another host and an order
234
- is never re-posted to a redirect target. If you pass your own `fetch`, it must honour `redirect: "manual"`.
257
+ is never re-posted to a redirect target. If you pass your own `fetch`, it must honour `redirect: "manual"`: one that follows redirects
258
+ anyway has already sent your credentials by the time the SDK notices.
235
259
  - The SDK redacts the secret from `toString()`, `util.inspect`, `JSON.stringify` and error messages.
236
260
  - Keep keys in environment variables or a secret manager, not in code.
237
261
 
package/dist/index.cjs CHANGED
@@ -31,6 +31,16 @@ var OrderStateUnknownError = class extends CexyError {
31
31
  this.clientOrderId = clientOrderId;
32
32
  }
33
33
  };
34
+ var CancelAllInterruptedError = class extends CexyError {
35
+ error;
36
+ partial;
37
+ constructor(error, partial) {
38
+ const what = error instanceof CexyApiError ? `${error.code}: ${error.message}` : String(error);
39
+ super(`cancelAll stopped after ${partial.rounds} round(s): ${what}`, { cause: error });
40
+ this.error = error;
41
+ this.partial = partial;
42
+ }
43
+ };
34
44
  var CexyApiError = class extends CexyError {
35
45
  status;
36
46
  code;
@@ -131,24 +141,27 @@ var KNOWN_CODES = /* @__PURE__ */ new Set([
131
141
  "UNDER_MAINTENANCE",
132
142
  "ENGINE_OVERLOADED"
133
143
  ]);
144
+ var CLIENT_ERROR_CODES = { UNEXPECTED_REDIRECT: "UNEXPECTED_REDIRECT" };
134
145
  function isKnownErrorCode(code) {
135
146
  return KNOWN_CODES.has(code);
136
147
  }
137
148
  var DEFAULT_RETRYABLE_STATUS = /* @__PURE__ */ new Set([408, 429, 500, 502, 503, 504]);
149
+ var MAX_SERVER_WAIT_MS = 12e4;
138
150
  function retryAfterMs(headers, details) {
139
151
  let best = null;
140
- const h = headers?.get("retry-after");
152
+ const h = headers?.get("retry-after")?.trim();
141
153
  if (h) {
142
- const secs = Number(h);
143
- if (Number.isFinite(secs)) best = Math.max(0, secs * 1e3);
144
- else {
154
+ if (/^\d+(\.\d+)?$/.test(h)) {
155
+ const ms = Number(h) * 1e3;
156
+ if (Number.isFinite(ms)) best = ms;
157
+ } else if (/[a-z]/i.test(h)) {
145
158
  const at = Date.parse(h);
146
- if (!Number.isNaN(at)) best = Math.max(0, at - Date.now());
159
+ if (Number.isFinite(at)) best = Math.max(0, at - Date.now());
147
160
  }
148
161
  }
149
162
  const d = details?.["retry_after_seconds"];
150
- const ds = typeof d === "number" ? d : typeof d === "string" ? Number(d) : NaN;
151
- if (Number.isFinite(ds)) best = Math.max(best ?? 0, ds * 1e3);
163
+ const ds = typeof d === "number" ? d : typeof d === "string" && /^\d+(\.\d+)?$/.test(d.trim()) ? Number(d) : NaN;
164
+ if (Number.isFinite(ds) && ds >= 0 && Number.isFinite(ds * 1e3)) best = Math.max(best ?? 0, ds * 1e3);
152
165
  return best;
153
166
  }
154
167
  function errorFromResponse(status, body, headers, redact = (t) => t) {
@@ -311,6 +324,7 @@ var OPERATIONS = {
311
324
  };
312
325
 
313
326
  // src/http.ts
327
+ var IDEMPOTENT_OPS = /* @__PURE__ */ new Set(["join_pool", "exit_pool"]);
314
328
  var BACKOFF_BASE_MS = 500;
315
329
  var BACKOFF_MAX_MS = 1e4;
316
330
  var Transport = class {
@@ -319,16 +333,15 @@ var Transport = class {
319
333
  this.config = config;
320
334
  }
321
335
  /**
322
- * Sends a request with the standard retry policy: GETs retry on retryable errors and
323
- * network failures. Mutations carry an `Idempotency-Key` reused on every attempt; the server
324
- * honours it on pool join/exit, which makes their retries safe. The other mutations routed
325
- * here (cancel-all) are naturally repeatable. `placeOrder` and `cancelOrder` use
336
+ * Sends a request with the standard retry policy: retryable errors and network failures are
337
+ * retried. Pool join/exit carry an `Idempotency-Key` reused on every attempt (the server
338
+ * honours it there, which makes their retries safe); the other mutations routed here
339
+ * (cancel-all) are naturally repeatable and send no key. `placeOrder` and `cancelOrder` use
326
340
  * `attempt()` with their own policies.
327
341
  */
328
342
  async request(spec, opts = {}) {
329
343
  const info = OPERATIONS[spec.op];
330
- const isMutation = info.method !== "GET";
331
- const idempotencyKey = isMutation ? spec.idempotencyKey ?? opts.idempotencyKey ?? newId() : void 0;
344
+ const idempotencyKey = IDEMPOTENT_OPS.has(spec.op) ? spec.idempotencyKey ?? opts.idempotencyKey ?? newId() : void 0;
332
345
  const maxRetries = opts.maxRetries ?? this.config.maxRetries;
333
346
  for (let attempt = 0; ; attempt++) {
334
347
  try {
@@ -339,8 +352,13 @@ var Transport = class {
339
352
  }
340
353
  }
341
354
  }
342
- /** Waits before retry number `attempt + 1`, honouring server hints. */
355
+ /**
356
+ * Waits before retry number `attempt + 1`, honouring server hints. A server hint longer than
357
+ * `MAX_SERVER_WAIT_MS` is not waited: `err` is thrown at once (it still carries the hint).
358
+ */
343
359
  async backoff(op2, info, attempt, err, idempotencyKey, signal) {
360
+ const hint = serverHintMs(err);
361
+ if (hint !== null && hint > MAX_SERVER_WAIT_MS) throw err;
344
362
  const delayMs = this.retryDelay(attempt, err);
345
363
  this.config.onRetry?.({
346
364
  operation: op2,
@@ -355,8 +373,8 @@ var Transport = class {
355
373
  }
356
374
  /** Full-jitter exponential backoff, or the server's hint plus a little jitter. */
357
375
  retryDelay(attempt, err) {
358
- const hint = err instanceof RateLimitError ? err.retryAfterMs : err instanceof CexyApiError ? retryAfterMs(void 0, err.details) : null;
359
- if (hint !== null && hint > 0) return Math.ceil(hint + this.config.random() * 250);
376
+ const hint = serverHintMs(err);
377
+ if (hint !== null && hint > 0 && hint <= MAX_SERVER_WAIT_MS) return Math.ceil(hint + this.config.random() * 250);
360
378
  const cap = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** attempt);
361
379
  return Math.ceil(this.config.random() * cap);
362
380
  }
@@ -371,7 +389,7 @@ var Transport = class {
371
389
  body = JSON.stringify(spec.body);
372
390
  headers.set("Content-Type", "application/json");
373
391
  }
374
- if (info.method !== "GET" && spec.idempotencyKey) headers.set("Idempotency-Key", spec.idempotencyKey);
392
+ if (IDEMPOTENT_OPS.has(spec.op) && spec.idempotencyKey) headers.set("Idempotency-Key", spec.idempotencyKey);
375
393
  if (info.auth === "api_key") {
376
394
  const auth = this.config.authenticator;
377
395
  if (!auth) {
@@ -415,7 +433,7 @@ var Transport = class {
415
433
  if (res.type === "opaqueredirect" || res.status >= 300 && res.status < 400 || res.redirected) {
416
434
  throw new CexyApiError({
417
435
  status: res.status,
418
- code: "UNEXPECTED_REDIRECT",
436
+ code: CLIENT_ERROR_CODES.UNEXPECTED_REDIRECT,
419
437
  message: `${info.method} ${info.path}: the server answered with a redirect (HTTP ${res.status}); the SDK does not follow redirects. Check baseUrl.`,
420
438
  retryable: false
421
439
  });
@@ -454,6 +472,11 @@ var Transport = class {
454
472
  return this.config.authenticator ? this.config.authenticator.redact(text) : text;
455
473
  }
456
474
  };
475
+ function serverHintMs(err) {
476
+ if (err instanceof RateLimitError) return err.retryAfterMs;
477
+ if (err instanceof CexyApiError) return retryAfterMs(void 0, err.details);
478
+ return null;
479
+ }
457
480
  function isRetryable(err) {
458
481
  if (err instanceof CexyConnectionError) return true;
459
482
  if (err instanceof CexyApiError) return err.retryable || err.code === "CONCURRENT_MODIFICATION";
@@ -485,6 +508,7 @@ function errMessage(err) {
485
508
  }
486
509
 
487
510
  // src/limiter.ts
511
+ var MAX_BLOCK_MS = 12e4;
488
512
  var RateLimiter = class {
489
513
  #rpm;
490
514
  #tokens;
@@ -518,14 +542,14 @@ var RateLimiter = class {
518
542
  return;
519
543
  }
520
544
  const msPerToken = 6e4 / this.#rpm;
521
- await this.#sleep(Math.ceil((1 - this.#tokens) * msPerToken), signal);
545
+ await this.#sleep(Math.min(MAX_BLOCK_MS, Math.ceil((1 - this.#tokens) * msPerToken)), signal);
522
546
  }
523
547
  }
524
548
  /** Adapts to the server's rate-limit headers. Never raises the configured limit. */
525
549
  update(headers) {
526
550
  this.#refill();
527
551
  const limit = num(headers.get("x-ratelimit-limit"));
528
- if (limit !== null && limit > 0 && limit < this.#rpm) {
552
+ if (limit !== null && limit >= 1 && limit < this.#rpm) {
529
553
  this.#rpm = limit;
530
554
  this.#tokens = Math.min(this.#tokens, limit);
531
555
  }
@@ -537,10 +561,13 @@ var RateLimiter = class {
537
561
  else this.blockFor(6e4 / this.#rpm);
538
562
  }
539
563
  }
540
- /** Blocks all requests for `ms` (used for 429 Retry-After). */
564
+ /**
565
+ * Blocks all requests for `ms` (used for 429 Retry-After and `X-RateLimit-Reset`). Server
566
+ * hints are untrusted: non-finite values are ignored and the block is capped at 120 s.
567
+ */
541
568
  blockFor(ms) {
542
- if (!(ms > 0)) return;
543
- this.#blockedUntil = Math.max(this.#blockedUntil, this.#now() + ms);
569
+ if (!(ms > 0) || !Number.isFinite(ms)) return;
570
+ this.#blockedUntil = Math.max(this.#blockedUntil, this.#now() + Math.min(ms, MAX_BLOCK_MS));
544
571
  }
545
572
  #refill() {
546
573
  const now = this.#now();
@@ -790,6 +817,14 @@ var WalletResource = class extends Resource {
790
817
  return this.data({ op: "deposit_address", query: params }, opts);
791
818
  }
792
819
  };
820
+ var CANCEL_RETRY_CODES = /* @__PURE__ */ new Set(["INVALID_STATE", "SERVICE_UNAVAILABLE"]);
821
+ var CANCEL_BACKOFF_S = [1, 2, 4, 8, 15];
822
+ function errorCode(err) {
823
+ if (err instanceof CexyApiError) return err.code;
824
+ if (err instanceof CexyTimeoutError) return "TIMEOUT";
825
+ if (err instanceof CexyConnectionError) return "CONNECTION_ERROR";
826
+ return "ERROR";
827
+ }
793
828
  var ORDER_AMOUNT_FIELDS = ["price", "quantity", "quote_quantity", "stop_price"];
794
829
  var TradingResource = class extends Resource {
795
830
  /** Open orders, optionally filtered by market/status. */
@@ -820,7 +855,7 @@ var TradingResource = class extends Resource {
820
855
  *
821
856
  * Retry safety rests on `client_order_id` (generated as a UUID when absent): it is unique
822
857
  * per account and the server refuses a repeat before any funds move. The server does NOT
823
- * honour `Idempotency-Key` on orders (the header is sent but gives no protection). After an
858
+ * honour `Idempotency-Key` on orders, so none is sent. After an
824
859
  * ambiguous failure (network error, timeout or 5xx) the SDK first looks the order up by
825
860
  * `client_order_id` and returns it if it exists (`recovered: true`); only if it does not
826
861
  * exist does it send the order again, with the same `client_order_id`, so a late-arriving
@@ -835,12 +870,11 @@ var TradingResource = class extends Resource {
835
870
  assertAmountFields(order, ORDER_AMOUNT_FIELDS, "placeOrder");
836
871
  const clientOrderId = order.client_order_id ?? newId();
837
872
  const body = { ...order, client_order_id: clientOrderId };
838
- const idempotencyKey = opts.idempotencyKey ?? newId();
839
873
  const maxRetries = opts.maxRetries ?? this.t.config.maxRetries;
840
874
  const info = OPERATIONS.place_order;
841
875
  for (let attempt = 0; ; attempt++) {
842
876
  try {
843
- const raw = await this.t.attempt({ op: "place_order", body, idempotencyKey }, opts);
877
+ const raw = await this.t.attempt({ op: "place_order", body }, opts);
844
878
  const data = raw.data.data;
845
879
  return { ...data, client_order_id: clientOrderId, recovered: false };
846
880
  } catch (err) {
@@ -850,11 +884,11 @@ var TradingResource = class extends Resource {
850
884
  const existing = await this.#lookup(clientOrderId, err, opts);
851
885
  if (existing) return { order: existing, fills: [], client_order_id: clientOrderId, recovered: true };
852
886
  if (duplicateAfterRetry || attempt >= maxRetries) throw err;
853
- await this.t.backoff("place_order", info, attempt, err, idempotencyKey, opts.signal);
887
+ await this.t.backoff("place_order", info, attempt, err, void 0, opts.signal);
854
888
  continue;
855
889
  }
856
890
  if (isRetryable(err) && attempt < maxRetries) {
857
- await this.t.backoff("place_order", info, attempt, err, idempotencyKey, opts.signal);
891
+ await this.t.backoff("place_order", info, attempt, err, void 0, opts.signal);
858
892
  continue;
859
893
  }
860
894
  throw err;
@@ -878,10 +912,9 @@ var TradingResource = class extends Resource {
878
912
  async cancelOrder(orderId, opts = {}) {
879
913
  const maxRetries = opts.maxRetries ?? this.t.config.maxRetries;
880
914
  const info = OPERATIONS.cancel_order;
881
- const idempotencyKey = opts.idempotencyKey ?? newId();
882
915
  for (let attempt = 0; ; attempt++) {
883
916
  try {
884
- const raw = await this.t.attempt({ op: "cancel_order", pathParams: { order_id: orderId }, idempotencyKey }, opts);
917
+ const raw = await this.t.attempt({ op: "cancel_order", pathParams: { order_id: orderId } }, opts);
885
918
  return raw.data.data;
886
919
  } catch (err) {
887
920
  if (opts.signal?.aborted) throw err;
@@ -889,23 +922,13 @@ var TradingResource = class extends Resource {
889
922
  return this.order(orderId, { signal: opts.signal, timeoutMs: opts.timeoutMs });
890
923
  }
891
924
  if (isRetryable(err) && attempt < maxRetries) {
892
- await this.t.backoff("cancel_order", info, attempt, err, idempotencyKey, opts.signal);
925
+ await this.t.backoff("cancel_order", info, attempt, err, void 0, opts.signal);
893
926
  continue;
894
927
  }
895
928
  throw err;
896
929
  }
897
930
  }
898
931
  }
899
- /**
900
- * Cancels every open order in one market: `cancelAll({ symbol: "BTC/USDT" })`.
901
- * To cancel across ALL markets, pass `symbol: null` explicitly: `cancelAll({ symbol: null })`.
902
- * Omitting `symbol` is an error, so an account-wide cancel never happens by accident
903
- * (the server itself treats `{}` as every market).
904
- *
905
- * The server limits cancel-all to 30 calls per minute per account. It is naturally
906
- * repeatable, so it is retried after network errors; a retry reports only what that retry
907
- * cancelled.
908
- */
909
932
  async cancelAll(params, opts) {
910
933
  const hasSymbol = !!params && typeof params === "object" && Object.prototype.hasOwnProperty.call(params, "symbol");
911
934
  const symbol = hasSymbol ? params.symbol : void 0;
@@ -913,12 +936,97 @@ var TradingResource = class extends Resource {
913
936
  if (typeof symbol === "string" && symbol !== "") body = { symbol };
914
937
  else if (hasSymbol && symbol === null) body = {};
915
938
  else throw new CexyConfigError('cancelAll(): pass { symbol: "BASE/QUOTE" }, or { symbol: null } to cancel in every market');
916
- return this.data({ op: "cancel_all", body }, opts);
939
+ const once = (o) => this.data({ op: "cancel_all", body }, o ?? opts);
940
+ if (params.untilDone !== true) return once();
941
+ const maxRounds = params.maxRounds ?? 20;
942
+ const budgetMs = params.timeBudgetMs ?? 12e4;
943
+ if (!(maxRounds >= 1)) throw new CexyConfigError("cancelAll(): maxRounds must be >= 1");
944
+ if (!(budgetMs > 0)) throw new CexyConfigError("cancelAll(): timeBudgetMs must be > 0");
945
+ const now = this.t.config.now ?? Date.now;
946
+ const start = now();
947
+ const state = /* @__PURE__ */ new Map();
948
+ const roundOpts = { ...opts, maxRetries: 0 };
949
+ let rounds = 0;
950
+ let idle = 0;
951
+ let hasMore = false;
952
+ let lastErrorCode;
953
+ let stopped;
954
+ const result = () => {
955
+ const pick = (list) => [...state].filter(([, v]) => v.list === list).map(([id]) => id);
956
+ return {
957
+ cancelled: pick("cancelled"),
958
+ already_closed: pick("already_closed"),
959
+ failed: pick("failed"),
960
+ failures: [...state.values()].flatMap((v) => v.list === "failed" && v.failure ? [v.failure] : []),
961
+ has_more: hasMore,
962
+ rounds,
963
+ stopped,
964
+ ...lastErrorCode !== void 0 ? { last_error_code: lastErrorCode } : {}
965
+ };
966
+ };
967
+ for (; ; ) {
968
+ let waitMs = 0;
969
+ let res;
970
+ try {
971
+ res = await once(roundOpts);
972
+ } catch (err) {
973
+ rounds++;
974
+ if (opts?.signal?.aborted) throw err;
975
+ if (!isRetryable(err)) {
976
+ stopped = "done";
977
+ throw new CancelAllInterruptedError(err, result());
978
+ }
979
+ lastErrorCode = errorCode(err);
980
+ if (rounds >= maxRounds) {
981
+ stopped = "max_rounds";
982
+ break;
983
+ }
984
+ const hint = err instanceof RateLimitError ? err.retryAfterMs : null;
985
+ if (hint !== null) {
986
+ waitMs = hint;
987
+ if (hint > MAX_SERVER_WAIT_MS) {
988
+ stopped = "time_budget";
989
+ break;
990
+ }
991
+ } else {
992
+ waitMs = (CANCEL_BACKOFF_S[Math.min(idle++, CANCEL_BACKOFF_S.length - 1)] ?? 15) * 1e3;
993
+ }
994
+ }
995
+ if (res) {
996
+ rounds++;
997
+ lastErrorCode = void 0;
998
+ hasMore = res.has_more;
999
+ const failures = res.failures ?? [];
1000
+ for (const id of res.cancelled ?? []) state.set(id, { list: "cancelled" });
1001
+ for (const id of res.already_closed ?? []) state.set(id, { list: "already_closed" });
1002
+ for (const id of res.failed ?? []) {
1003
+ const failure = failures.find((f) => f.order_id === id);
1004
+ state.set(id, failure ? { list: "failed", failure } : { list: "failed" });
1005
+ }
1006
+ const progress = (res.cancelled?.length ?? 0) + (res.already_closed?.length ?? 0) > 0;
1007
+ if (!res.has_more && !failures.some((f) => CANCEL_RETRY_CODES.has(f.code))) {
1008
+ stopped = "done";
1009
+ break;
1010
+ }
1011
+ if (rounds >= maxRounds) {
1012
+ stopped = "max_rounds";
1013
+ break;
1014
+ }
1015
+ if (progress) idle = 0;
1016
+ else waitMs = (CANCEL_BACKOFF_S[Math.min(idle++, CANCEL_BACKOFF_S.length - 1)] ?? 15) * 1e3;
1017
+ }
1018
+ if (now() - start + waitMs >= budgetMs) {
1019
+ stopped = "time_budget";
1020
+ break;
1021
+ }
1022
+ if (waitMs > 0) await this.t.config.sleep(waitMs, opts?.signal);
1023
+ }
1024
+ return result();
917
1025
  }
918
1026
  };
919
1027
 
920
1028
  // src/version.ts
921
- var VERSION = "0.1.0-dev.3";
1029
+ var VERSION = "0.1.0-dev.5";
922
1030
  var USER_AGENT = `cexy-typescript/${VERSION}`;
923
1031
 
924
1032
  // src/ws/emitter.ts
@@ -1686,7 +1794,8 @@ var CexyClient = class {
1686
1794
  const sleep2 = options.sleep ?? sleep;
1687
1795
  const limiter = options.rateLimit === false ? null : new RateLimiter({
1688
1796
  requestsPerMinute: options.rateLimit?.requestsPerMinute ?? (authenticator ? DEFAULT_RPM_WITH_KEY : DEFAULT_RPM_ANONYMOUS),
1689
- sleep: sleep2
1797
+ sleep: sleep2,
1798
+ ...options.now ? { now: options.now } : {}
1690
1799
  });
1691
1800
  this.#baseUrl = baseUrl;
1692
1801
  this.#transport = new Transport({
@@ -1699,6 +1808,7 @@ var CexyClient = class {
1699
1808
  userAgent: canSetUserAgent() ? [USER_AGENT, options.userAgentSuffix].filter(Boolean).join(" ") : null,
1700
1809
  sleep: sleep2,
1701
1810
  random: options.random ?? Math.random,
1811
+ now: options.now ?? Date.now,
1702
1812
  onRetry: options.onRetry
1703
1813
  });
1704
1814
  const t = this.#transport;
@@ -1772,6 +1882,8 @@ exports.AccountResource = AccountResource;
1772
1882
  exports.ApiKeyAuthenticator = ApiKeyAuthenticator;
1773
1883
  exports.AssetsResource = AssetsResource;
1774
1884
  exports.AuthenticationError = AuthenticationError;
1885
+ exports.CLIENT_ERROR_CODES = CLIENT_ERROR_CODES;
1886
+ exports.CancelAllInterruptedError = CancelAllInterruptedError;
1775
1887
  exports.CexyApiError = CexyApiError;
1776
1888
  exports.CexyClient = CexyClient;
1777
1889
  exports.CexyConfigError = CexyConfigError;
@@ -1792,6 +1904,7 @@ exports.InvalidAmountError = InvalidAmountError;
1792
1904
  exports.JurisdictionBlockedError = JurisdictionBlockedError;
1793
1905
  exports.KNOWN_EVENT_TYPES = KNOWN_EVENT_TYPES;
1794
1906
  exports.LiveOrderBook = LiveOrderBook;
1907
+ exports.MAX_SERVER_WAIT_MS = MAX_SERVER_WAIT_MS;
1795
1908
  exports.MarketsResource = MarketsResource;
1796
1909
  exports.NetworksResource = NetworksResource;
1797
1910
  exports.NotFoundError = NotFoundError;