wontopos 2.2.40 → 2.2.41

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/wontopos.js CHANGED
@@ -1,4 +1,4 @@
1
- const VERSION = "2.2.40";
1
+ const VERSION = "2.2.41";
2
2
  const RUNTIME = (() => {
3
3
  const p = globalThis?.process;
4
4
  return p?.version ? ` (node/${p.version}; ${p.platform}-${p.arch})` : "";
@@ -13,12 +13,15 @@ function logDebug(msg) {
13
13
  const DEFAULT_BASE_URL = "https://api.wontopos.com";
14
14
  const DEFAULT_MODEL = "tablet-2";
15
15
  const RETRY_ALWAYS = new Set([429]);
16
+ const MAX_RETRY_WAIT_MS = 30_000;
17
+ const MAX_TIMER_MS = 2_147_483_647;
16
18
  const RETRY_IF_IDEMPOTENT = new Set([408, 502, 503, 504]);
17
19
  const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
18
20
  const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
19
21
  const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
20
22
  const MODEL_RE = /^[A-Za-z0-9._-]+$/;
21
23
  const MAX_ERR_MSG = 4096;
24
+ const MAX_ERR_DETAILS = 8192;
22
25
  const IDEMPOTENCY_KEY_RE = /^[A-Za-z0-9._:\-]{1,128}$/;
23
26
  function normalizeStoreId(id) {
24
27
  return id.toLowerCase().replace(/[^a-z0-9_]/g, "_");
@@ -73,6 +76,9 @@ export function _resetStoreIdWarnings() {
73
76
  }
74
77
  const NO_BODY_STATUS = new Set([204, 205, 304]);
75
78
  const MAX_PAGES = 20_000;
79
+ function truncatedWalk(why) {
80
+ return new Error(`${why} This is a truncated answer, not the whole store.`);
81
+ }
76
82
  export const SEARCH_LIMIT_MIN = 5;
77
83
  export const SEARCH_LIMIT_MAX = 20;
78
84
  export const CONTEXT_LIMIT_MIN = 0;
@@ -89,6 +95,26 @@ function checkContextLimit(n) {
89
95
  throw new Error(`context_limit must be an integer between ${CONTEXT_LIMIT_MIN} and ${CONTEXT_LIMIT_MAX}, got ${n}.`);
90
96
  }
91
97
  }
98
+ const PAGE_LIMIT_MIN = 5;
99
+ const PAGE_LIMIT_MAX = 20;
100
+ const LIST_LIMIT_MAX = 500;
101
+ const MAX_IMAGES_MAX = 5;
102
+ function checkRange(n, name, min, max) {
103
+ if (typeof n !== "number" || !Number.isInteger(n) || n < min || n > max) {
104
+ throw new Error(`${name} must be an integer between ${min} and ${max}, got ${String(n)}.`);
105
+ }
106
+ }
107
+ function optRange(n, name, min, max) {
108
+ if (n === undefined || n === null)
109
+ return undefined;
110
+ checkRange(n, name, min, max);
111
+ return n;
112
+ }
113
+ function checkMs(v) {
114
+ if (typeof v !== "number" || Number.isNaN(v) || v <= 0)
115
+ return undefined;
116
+ return Math.min(v, MAX_TIMER_MS);
117
+ }
92
118
  function normalizeImage(image) {
93
119
  if (!image || typeof image !== "object") {
94
120
  throw new Error("image must be an object — { data: '<base64>' }.");
@@ -118,14 +144,18 @@ const CONNECT_FAIL_CODES = new Set([
118
144
  "EADDRNOTAVAIL",
119
145
  "UND_ERR_CONNECT_TIMEOUT",
120
146
  ]);
147
+ const CONNECT_SYSCALLS = new Set(["connect", "getaddrinfo"]);
148
+ const CONNECT_ONLY_CODES = new Set(["ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT"]);
121
149
  function isConnectFailure(e) {
122
150
  const stack = [e];
123
151
  for (let steps = 0; stack.length && steps < 24; steps++) {
124
152
  const cur = stack.pop();
125
153
  if (!cur || typeof cur !== "object")
126
154
  continue;
127
- if (typeof cur.code === "string" && CONNECT_FAIL_CODES.has(cur.code))
155
+ if (typeof cur.code === "string" &&
156
+ (CONNECT_ONLY_CODES.has(cur.code) || (CONNECT_FAIL_CODES.has(cur.code) && CONNECT_SYSCALLS.has(cur.syscall)))) {
128
157
  return true;
158
+ }
129
159
  if (cur.cause)
130
160
  stack.push(cur.cause);
131
161
  if (Array.isArray(cur.errors))
@@ -133,6 +163,191 @@ function isConnectFailure(e) {
133
163
  }
134
164
  return false;
135
165
  }
166
+ function causeCode(e) {
167
+ const queue = [e];
168
+ for (let steps = 0; queue.length && steps < 24; steps++) {
169
+ const cur = queue.shift();
170
+ if (!cur || typeof cur !== "object")
171
+ continue;
172
+ if (typeof cur.code === "string" && /^[A-Z0-9_]{1,64}$/.test(cur.code))
173
+ return cur.code;
174
+ if (cur.cause)
175
+ queue.push(cur.cause);
176
+ if (Array.isArray(cur.errors))
177
+ queue.push(...cur.errors.slice(0, 8));
178
+ }
179
+ return undefined;
180
+ }
181
+ function cleanServerText(s, max = MAX_ERR_MSG) {
182
+ const clean = s.replace(/[\u0000-\u001f\u007f]/g, "");
183
+ return clean.length > max ? clean.slice(0, max) + "…(truncated)" : clean;
184
+ }
185
+ function cleanDetails(d) {
186
+ if (!d)
187
+ return undefined;
188
+ const clean = (v) => {
189
+ if (typeof v === "string")
190
+ return cleanServerText(v);
191
+ if (Array.isArray(v))
192
+ return v.map(clean);
193
+ if (v && typeof v === "object") {
194
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [cleanServerText(k), clean(x)]));
195
+ }
196
+ return v;
197
+ };
198
+ let entries;
199
+ try {
200
+ JSON.stringify(d);
201
+ entries = Object.entries(clean(d)).map(([k, v]) => [
202
+ k,
203
+ v,
204
+ JSON.stringify(k).length + JSON.stringify(v).length + 2,
205
+ ]);
206
+ }
207
+ catch {
208
+ return undefined;
209
+ }
210
+ const keep = new Set();
211
+ let size = 2;
212
+ for (const [k, , n] of [...entries].sort((a, b) => a[2] - b[2])) {
213
+ if (size + n > MAX_ERR_DETAILS)
214
+ break;
215
+ keep.add(k);
216
+ size += n;
217
+ }
218
+ if (!keep.size)
219
+ return undefined;
220
+ return Object.fromEntries(entries.filter(([k]) => keep.has(k)).map(([k, v]) => [k, v]));
221
+ }
222
+ function parseErrorText(text) {
223
+ let data;
224
+ try {
225
+ data = JSON.parse(text);
226
+ }
227
+ catch {
228
+ return { message: text };
229
+ }
230
+ if (!data || typeof data !== "object" || Array.isArray(data))
231
+ return { message: text };
232
+ const err = data.error;
233
+ if (err && typeof err === "object" && !Array.isArray(err)) {
234
+ const { message, type, request_id, ...rest } = err;
235
+ return {
236
+ message: typeof message === "string" ? message : typeof type === "string" ? type : text,
237
+ requestId: typeof request_id === "string" ? request_id : undefined,
238
+ type: typeof type === "string" ? type : undefined,
239
+ details: Object.keys(rest).length ? rest : undefined,
240
+ };
241
+ }
242
+ if (typeof err === "string")
243
+ return { message: err };
244
+ if (typeof data.message === "string")
245
+ return { message: data.message };
246
+ return { message: text };
247
+ }
248
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
249
+ const IMF_FIXDATE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) ([A-Z][a-z]{2}) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
250
+ const RFC850_DATE = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-([A-Z][a-z]{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
251
+ const ASCTIME_DATE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) ([A-Z][a-z]{2}) ([ \d]\d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/;
252
+ function retryAfterMs(v, now = Date.now()) {
253
+ if (v == null)
254
+ return undefined;
255
+ const s = v.trim();
256
+ if (/^\d+$/.test(s))
257
+ return Math.min(Number(s), Number.MAX_SAFE_INTEGER / 1000) * 1000;
258
+ let parts;
259
+ let m;
260
+ if ((m = IMF_FIXDATE.exec(s)))
261
+ parts = [m[1], m[2], Number(m[3]), m[4], m[5], m[6]];
262
+ else if ((m = RFC850_DATE.exec(s))) {
263
+ let year = 2000 + Number(m[3]);
264
+ if (year > new Date(now).getUTCFullYear() + 50)
265
+ year -= 100;
266
+ parts = [m[1], m[2], year, m[4], m[5], m[6]];
267
+ }
268
+ else if ((m = ASCTIME_DATE.exec(s)))
269
+ parts = [m[2].trim(), m[1], Number(m[6]), m[3], m[4], m[5]];
270
+ if (!parts)
271
+ return undefined;
272
+ const [day, mon, year, hh, mm, ss] = parts;
273
+ const month = MONTHS.indexOf(mon);
274
+ const d = Number(day), h = Number(hh), mi = Number(mm), se = Number(ss);
275
+ if (month < 0 || d < 1 || d > 31 || h > 23 || mi > 59 || se > 60)
276
+ return undefined;
277
+ return Math.max(0, Date.UTC(year, month, d, h, mi, se) - now);
278
+ }
279
+ const BRIEF_BODY_MS = 1_000;
280
+ class SlowBody extends Error {
281
+ }
282
+ const DEFAULT_TRANSPORTS = new WeakSet();
283
+ function defaultTransport() {
284
+ const g = globalThis.fetch;
285
+ if (typeof g !== "function")
286
+ return undefined;
287
+ const bound = g.bind(globalThis);
288
+ DEFAULT_TRANSPORTS.add(bound);
289
+ return bound;
290
+ }
291
+ function discard(res) {
292
+ res.body?.cancel().catch(() => { });
293
+ }
294
+ function tryUrl(s, base) {
295
+ try {
296
+ return base === undefined ? new URL(s) : new URL(s, base);
297
+ }
298
+ catch {
299
+ return undefined;
300
+ }
301
+ }
302
+ function pageHref() {
303
+ try {
304
+ const href = globalThis.location?.href;
305
+ return typeof href === "string" ? href : undefined;
306
+ }
307
+ catch {
308
+ return undefined;
309
+ }
310
+ }
311
+ function parseBase(base) {
312
+ let url = tryUrl(base);
313
+ if (!url) {
314
+ const page = pageHref();
315
+ if (page !== undefined)
316
+ url = tryUrl(base, page);
317
+ }
318
+ return url && (url.protocol === "http:" || url.protocol === "https:") ? url : undefined;
319
+ }
320
+ function asPageRelative(base) {
321
+ return tryUrl(base) ? undefined : tryUrl(base, "http://page.invalid/");
322
+ }
323
+ const BASE_URL_ENDS = /^\s+|\s+$/g;
324
+ function notAUrl(base) {
325
+ return `baseUrl is not a URL: ${JSON.stringify(maskBase(base))} (expected e.g. ${DEFAULT_BASE_URL})`;
326
+ }
327
+ function maskBase(raw) {
328
+ const at = raw.lastIndexOf("@");
329
+ if (at === -1)
330
+ return raw;
331
+ const from = /^(?:[a-z][a-z0-9+.-]*:)?[\\/]*/i.exec(raw)[0].length;
332
+ return `${raw.slice(0, from)}***@${raw.slice(at + 1)}`;
333
+ }
334
+ function maskUserinfo(text) {
335
+ let out = "";
336
+ let done = 0;
337
+ for (let at = text.indexOf("@"); at !== -1; at = text.indexOf("@", at + 1)) {
338
+ let start = at;
339
+ while (start > 0 && !/[\s\\/@]/.test(text[start - 1]))
340
+ start--;
341
+ const run = text.slice(start, at);
342
+ const afterSlash = start > 0 && (text[start - 1] === "/" || text[start - 1] === "\\");
343
+ if (!afterSlash && !run.includes(":"))
344
+ continue;
345
+ const scheme = afterSlash ? "" : (/^[a-z][a-z0-9+.-]*:/i.exec(run)?.[0] ?? "");
346
+ out += `${text.slice(done, start + scheme.length)}***`;
347
+ done = at;
348
+ }
349
+ return out + text.slice(done);
350
+ }
136
351
  function maskKey(key) {
137
352
  return key.length > 12 ? `${key.slice(0, 4)}...${key.slice(-4)}` : "***";
138
353
  }
@@ -183,11 +398,15 @@ function withForm(body, form, tz) {
183
398
  export class WosError extends Error {
184
399
  status;
185
400
  requestId;
186
- constructor(status, message, requestId) {
187
- super(`[${status}] ${message}${requestId ? ` (request_id: ${requestId})` : ""}`);
401
+ type;
402
+ details;
403
+ constructor(status, message, requestId, init) {
404
+ super(`[${status}] ${message}${requestId ? ` (request_id: ${requestId})` : ""}`, init?.cause === undefined ? undefined : { cause: init.cause });
188
405
  this.name = new.target.name;
189
406
  this.status = status;
190
407
  this.requestId = requestId;
408
+ this.type = init?.type;
409
+ this.details = init?.details;
191
410
  }
192
411
  }
193
412
  export class APIConnectionError extends WosError {
@@ -203,8 +422,20 @@ export class PermissionDeniedError extends WosError {
203
422
  export class NotFoundError extends WosError {
204
423
  }
205
424
  export class ConflictError extends WosError {
425
+ conflictsWith;
426
+ constructor(status, message, requestId, init) {
427
+ super(status, message, requestId, init);
428
+ const c = init?.details?.conflicts_with;
429
+ this.conflictsWith = typeof c === "string" ? c : undefined;
430
+ }
206
431
  }
207
432
  export class RateLimitError extends WosError {
433
+ retryAfter;
434
+ constructor(status, message, requestId, init) {
435
+ super(status, message, requestId, init);
436
+ const r = init?.retryAfter;
437
+ this.retryAfter = typeof r === "number" && Number.isFinite(r) && r >= 0 ? r : undefined;
438
+ }
208
439
  }
209
440
  export class ServerError extends WosError {
210
441
  }
@@ -215,13 +446,15 @@ const STATUS_ERRORS = {
215
446
  403: PermissionDeniedError,
216
447
  404: NotFoundError,
217
448
  409: ConflictError,
449
+ 413: BadRequestError,
450
+ 422: BadRequestError,
218
451
  429: RateLimitError,
219
452
  };
220
- function errorFor(status, message, requestId) {
453
+ function errorFor(status, message, requestId, init) {
221
454
  const Cls = status === 0
222
455
  ? APIConnectionError
223
456
  : STATUS_ERRORS[status] ?? (status >= 500 && status < 600 ? ServerError : WosError);
224
- return new Cls(status, message, requestId);
457
+ return new Cls(status, message, requestId, init);
225
458
  }
226
459
  const KNOWN_FILTER_KEYS = new Set([
227
460
  "categories",
@@ -240,6 +473,8 @@ const KNOWN_RECALL_KEYS = new Set(["form", "tz", "limit", "context_limit"]);
240
473
  const KNOWN_ENGRAM_KEYS = new Set(["form", "tz"]);
241
474
  function checkSearchOpts(opts) {
242
475
  checkOpts(opts, KNOWN_SEARCH_KEYS, "search");
476
+ const o = opts;
477
+ optRange(o.max_images !== undefined ? o.max_images : o.extra?.max_images, "max_images", 0, MAX_IMAGES_MAX);
243
478
  }
244
479
  function checkOpts(opts, known, what) {
245
480
  for (const k of Object.keys(opts)) {
@@ -273,24 +508,33 @@ function editWithin(a, b, max) {
273
508
  }
274
509
  return prev[b.length] <= max;
275
510
  }
276
- function warnOnUnknownFilters(filters) {
277
- if (!filters || typeof filters !== "object" || Array.isArray(filters))
511
+ function warnOnceUnknownKeys(obj, known, warned, message) {
512
+ if (!obj || typeof obj !== "object" || Array.isArray(obj))
278
513
  return;
279
- for (const k of Object.keys(filters)) {
280
- if (KNOWN_FILTER_KEYS.has(k) || warnedFilterKeys.has(k))
514
+ for (const k of Object.keys(obj)) {
515
+ if (known.has(k) || warned.has(k))
281
516
  continue;
282
- warnedFilterKeys.add(k);
283
- while (warnedFilterKeys.size > WARNED_FILTER_KEYS_MAX) {
284
- const oldest = warnedFilterKeys.values().next().value;
517
+ warned.add(k);
518
+ while (warned.size > WARNED_FILTER_KEYS_MAX) {
519
+ const oldest = warned.values().next().value;
285
520
  if (oldest === undefined)
286
521
  break;
287
- warnedFilterKeys.delete(oldest);
522
+ warned.delete(oldest);
288
523
  }
289
- console.warn(`wontopos: unknown search filter ${JSON.stringify(k)} — the API drops keys it does not know, ` +
290
- `so this filter has NO effect and the search is wider than you think. ` +
291
- `Known keys: ${[...KNOWN_FILTER_KEYS].join(", ")}.`);
524
+ console.warn(message(k));
292
525
  }
293
526
  }
527
+ function warnOnUnknownFilters(filters) {
528
+ warnOnceUnknownKeys(filters, KNOWN_FILTER_KEYS, warnedFilterKeys, (k) => `wontopos: unknown search filter ${JSON.stringify(k)} — the API drops keys it does not know, ` +
529
+ `so this filter has NO effect and the search is wider than you think. ` +
530
+ `Known keys: ${[...KNOWN_FILTER_KEYS].join(", ")}.`);
531
+ }
532
+ const KNOWN_METADATA_KEYS = new Set(["speaker", "event_date", "category", "conversation_id"]);
533
+ const warnedMetadataKeys = new Set();
534
+ function warnOnUnknownMetadata(metadata) {
535
+ warnOnceUnknownKeys(metadata, KNOWN_METADATA_KEYS, warnedMetadataKeys, (k) => `wontopos: metadata key ${JSON.stringify(k)} is not kept: the service keeps only ` +
536
+ `${[...KNOWN_METADATA_KEYS].join(", ")} and drops the rest, so this value is not stored.`);
537
+ }
294
538
  export function _resetFilterWarnings() {
295
539
  warnedFilterKeys.clear();
296
540
  }
@@ -305,6 +549,9 @@ export class Client {
305
549
  deadlineMs;
306
550
  signal;
307
551
  fetchImpl;
552
+ userinfo;
553
+ pageRelative;
554
+ customFetch;
308
555
  defaultUser;
309
556
  _rateLimit = null;
310
557
  get rateLimit() {
@@ -324,25 +571,41 @@ export class Client {
324
571
  "en dash; copy the key from a plain-text field");
325
572
  }
326
573
  this.apiKey = key;
327
- this.base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
328
- const tm = opts.timeoutMs;
329
- this.timeoutMs = typeof tm === "number" && Number.isFinite(tm) && tm > 0 ? tm : 30_000;
574
+ const rawBase = opts.baseUrl ?? DEFAULT_BASE_URL;
575
+ if (typeof rawBase !== "string")
576
+ throw new Error("baseUrl must be a string");
577
+ const trimmed = rawBase.replace(BASE_URL_ENDS, "");
578
+ if (/[\s\\\u0000-\u001f\u007f]/.test(trimmed)) {
579
+ throw new Error(`baseUrl contains whitespace, a backslash or a control character: ${JSON.stringify(maskBase(trimmed))}`);
580
+ }
581
+ this.base = trimmed.replace(/\/+$/, "");
582
+ const url = parseBase(this.base);
583
+ const parsed = url ?? (pageHref() === undefined ? asPageRelative(this.base) : undefined);
584
+ if (!parsed)
585
+ throw new Error(notAUrl(trimmed));
586
+ this.pageRelative = !url;
587
+ this.userinfo = Boolean(parsed.username || parsed.password);
588
+ this.timeoutMs = checkMs(opts.timeoutMs) ?? 30_000;
330
589
  this.model = opts.model ?? DEFAULT_MODEL;
331
590
  if (this.model && !MODEL_RE.test(this.model)) {
332
591
  throw new Error(`invalid model name: ${JSON.stringify(this.model)} (letters, digits, '.', '_', '-' only)`);
333
592
  }
593
+ if (opts && "userId" in opts && opts.userId === undefined) {
594
+ throw new Error("new Client() needs a store id in userId, or no userId option at all. It was given " +
595
+ "undefined. That is usually a lookup that found nothing. Leave userId out to use the default store.");
596
+ }
334
597
  assertUsableStoreId(opts.userId);
335
598
  this.defaultUser = opts.userId ?? DEFAULT_USER;
336
599
  const mr = opts.maxRetries ?? opts.retries;
337
600
  this.maxRetries = typeof mr === "number" && Number.isFinite(mr) ? Math.max(0, Math.floor(mr)) : 2;
338
- const dl = opts.deadlineMs;
339
- this.deadlineMs = typeof dl === "number" && Number.isFinite(dl) && dl > 0 ? dl : undefined;
601
+ this.deadlineMs = checkMs(opts.deadlineMs);
340
602
  this.signal = opts.signal;
341
603
  const f = opts.fetch;
342
604
  if (f !== undefined && typeof f !== "function") {
343
605
  throw new Error("fetch must be a function (a fetch-compatible transport)");
344
606
  }
345
- this.fetchImpl = f ?? (globalThis.fetch ? globalThis.fetch.bind(globalThis) : undefined);
607
+ this.customFetch = f !== undefined && !DEFAULT_TRANSPORTS.has(f);
608
+ this.fetchImpl = (f ?? defaultTransport());
346
609
  if (typeof this.fetchImpl !== "function") {
347
610
  throw new Error("no fetch available in this runtime — pass one as `fetch` (Node 18+ has it built in; older Node needs undici)");
348
611
  }
@@ -353,14 +616,8 @@ export class Client {
353
616
  };
354
617
  if (this.model)
355
618
  this.headers["X-WOS-Model"] = this.model;
356
- let host;
357
- try {
358
- host = new URL(this.base).hostname.toLowerCase().replace(/^\[|\]$/g, "");
359
- }
360
- catch {
361
- host = "";
362
- }
363
- if (/^http:\/\//i.test(this.base) && !LOOPBACK_HOSTS.has(host)) {
619
+ const host = url?.hostname.toLowerCase().replace(/^\[|\]$/g, "") ?? "";
620
+ if (url?.protocol === "http:" && !LOOPBACK_HOSTS.has(host)) {
364
621
  console.warn("wontopos: baseUrl uses plain HTTP on a non-local host, so the API key travels unencrypted. Use https://.");
365
622
  }
366
623
  Object.defineProperty(this, "apiKey", { enumerable: false });
@@ -374,10 +631,10 @@ export class Client {
374
631
  return new Client({ ...opts, apiKey: key });
375
632
  }
376
633
  toJSON() {
377
- return { baseUrl: this.base, model: this.model, userId: this.defaultUser, apiKey: maskKey(this.apiKey) };
634
+ return { baseUrl: maskBase(this.base), model: this.model, userId: this.defaultUser, apiKey: maskKey(this.apiKey) };
378
635
  }
379
636
  [Symbol.for("nodejs.util.inspect.custom")]() {
380
- return `Client(${this.base}, model=${this.model}, userId=${this.defaultUser}, apiKey=${maskKey(this.apiKey)})`;
637
+ return `Client(${maskBase(this.base)}, model=${this.model}, userId=${this.defaultUser}, apiKey=${maskKey(this.apiKey)})`;
381
638
  }
382
639
  uid(userId) {
383
640
  assertUsableStoreId(userId);
@@ -426,6 +683,7 @@ export class Client {
426
683
  const body = { user_id: this.uid(userId), content, metadata };
427
684
  if (opts.image !== undefined)
428
685
  body.image = normalizeImage(opts.image);
686
+ warnOnUnknownMetadata(metadata);
429
687
  return this.post("/api/v1/memory/store", body, opts.idempotencyKey);
430
688
  }
431
689
  async store(content, userId, metadata = {}, opts = {}) {
@@ -446,6 +704,7 @@ export class Client {
446
704
  async search(query, userId, limit = 10, opts = {}) {
447
705
  checkSearchOpts(opts);
448
706
  warnOnUnknownFilters(opts.filters);
707
+ limit ??= 10;
449
708
  checkCount(limit, "limit");
450
709
  const { extra, ...known } = opts;
451
710
  const r = await this.post("/api/v1/memory/search", { ...extra, ...known, user_id: this.uid(userId), query, max_results: limit });
@@ -454,6 +713,7 @@ export class Client {
454
713
  async searchSelf(query, userId, limit = 10, opts = {}) {
455
714
  checkSearchOpts(opts);
456
715
  warnOnUnknownFilters(opts.filters);
716
+ limit ??= 10;
457
717
  checkCount(limit, "limit");
458
718
  const { extra, ...known } = opts;
459
719
  const r = await this.post("/api/v1/memory/search", { ...extra, ...known, user_id: this.uid(userId), query, max_results: limit });
@@ -462,6 +722,7 @@ export class Client {
462
722
  async searchFull(query, userId, limit = 10, opts = {}) {
463
723
  checkSearchOpts(opts);
464
724
  warnOnUnknownFilters(opts.filters);
725
+ limit ??= 10;
465
726
  checkCount(limit, "limit");
466
727
  const { extra, ...known } = opts;
467
728
  const r = await this.post("/api/v1/memory/search", { ...extra, ...known, user_id: this.uid(userId), query, max_results: limit });
@@ -475,11 +736,11 @@ export class Client {
475
736
  async recall(query, userId, opts = {}) {
476
737
  checkOpts(opts, KNOWN_RECALL_KEYS, "recall");
477
738
  const body = { user_id: this.uid(userId), query };
478
- if (opts.limit !== undefined) {
739
+ if (opts.limit != null) {
479
740
  checkCount(opts.limit, "limit");
480
741
  body.limit = opts.limit;
481
742
  }
482
- if (opts.context_limit !== undefined) {
743
+ if (opts.context_limit != null) {
483
744
  checkContextLimit(opts.context_limit);
484
745
  body.context_limit = opts.context_limit;
485
746
  }
@@ -509,7 +770,8 @@ export class Client {
509
770
  return this.post("/api/v1/memory/get", { user_id: this.uid(userId), memory_id: memoryId.trim() }).then(memoryFromGet);
510
771
  }
511
772
  async listMemories(userId, opts = {}) {
512
- const body = { user_id: this.uid(userId), limit: opts.limit ?? 100 };
773
+ const limit = optRange(opts.limit, "limit", 1, LIST_LIMIT_MAX);
774
+ const body = { user_id: this.uid(userId), limit: limit ?? 100 };
513
775
  if (opts.cursor)
514
776
  body.cursor = opts.cursor;
515
777
  return this.post("/api/v1/memory/list", body);
@@ -519,16 +781,21 @@ export class Client {
519
781
  const seen = new Set();
520
782
  for (let page = 0; page < MAX_PAGES; page++) {
521
783
  const p = await this.listMemories(userId, { limit: opts.pageSize ?? 100, cursor });
522
- for (const m of asRecords(p.memories))
784
+ const rows = asRecords(p.memories);
785
+ for (const m of rows)
523
786
  yield m;
524
787
  const next = p.next_cursor ?? undefined;
525
- if (!next || seen.has(next))
788
+ if (!next)
789
+ return;
790
+ if (seen.has(next)) {
791
+ if (rows.length)
792
+ throw truncatedWalk(`stopped at page ${page + 1}: the service repeated a cursor, so the store did not end.`);
526
793
  return;
794
+ }
527
795
  seen.add(next);
528
796
  cursor = next;
529
797
  }
530
- throw new Error(`stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
531
- `answer, not the whole store.`);
798
+ throw truncatedWalk(`stopped after ${MAX_PAGES} pages — the store did not end.`);
532
799
  }
533
800
  async exportMemories(userId) {
534
801
  const out = [];
@@ -553,14 +820,16 @@ export class Client {
553
820
  throw new Error("memoryId is required — the id of the memory whose image you want removed.");
554
821
  }
555
822
  const body = { user_id: this.uid(userId), memory_id: memoryId.trim() };
556
- if (opts.preview)
557
- body.preview = true;
823
+ if (!opts.preview)
824
+ return this.remove("/api/v1/memory/image", body);
825
+ body.preview = true;
558
826
  return this.request("DELETE", "/api/v1/memory/image", body);
559
827
  }
560
828
  async listImages(userId, opts = {}) {
829
+ const limit = optRange(opts.limit, "limit", PAGE_LIMIT_MIN, PAGE_LIMIT_MAX);
561
830
  const body = { user_id: this.uid(userId) };
562
- if (opts.limit !== undefined)
563
- body.limit = opts.limit;
831
+ if (limit !== undefined)
832
+ body.limit = limit;
564
833
  if (opts.before !== undefined)
565
834
  body.before = opts.before;
566
835
  if (opts.skipIds !== undefined)
@@ -573,26 +842,30 @@ export class Client {
573
842
  const seen = new Set();
574
843
  for (let page = 0; page < MAX_PAGES; page++) {
575
844
  const p = await this.listImages(userId, { limit: opts.pageSize, before, skipIds });
576
- for (const m of asRecords(p.images))
845
+ const rows = asRecords(p.images);
846
+ for (const m of rows)
577
847
  yield m;
578
848
  if (!p.has_more || !p.next_before)
579
849
  return;
580
850
  const key = `${p.next_before}|${(Array.isArray(p.next_skip_ids) ? p.next_skip_ids : []).join(",")}`;
581
- if (seen.has(key))
851
+ if (seen.has(key)) {
852
+ if (rows.length)
853
+ throw truncatedWalk(`stopped at page ${page + 1}: the service repeated a cursor, so the store did not end.`);
582
854
  return;
855
+ }
583
856
  seen.add(key);
584
857
  before = p.next_before;
585
858
  skipIds = Array.isArray(p.next_skip_ids) ? p.next_skip_ids : undefined;
586
859
  }
587
- throw new Error(`stopped after ${MAX_PAGES} pages — the store did not end. This is a truncated ` +
588
- `answer, not the whole store.`);
860
+ throw truncatedWalk(`stopped after ${MAX_PAGES} pages — the store did not end.`);
589
861
  }
590
862
  async revisions(userId, opts = {}) {
863
+ const limit = optRange(opts.limit, "limit", PAGE_LIMIT_MIN, PAGE_LIMIT_MAX);
591
864
  const body = {};
592
865
  if (opts.include !== undefined)
593
866
  body.include = opts.include;
594
- if (opts.limit !== undefined)
595
- body.limit = opts.limit;
867
+ if (limit !== undefined)
868
+ body.limit = limit;
596
869
  if (opts.before !== undefined)
597
870
  body.before = opts.before;
598
871
  if (opts.skipIds !== undefined)
@@ -610,9 +883,10 @@ export class Client {
610
883
  if (!speaker || typeof speaker !== "string" || speaker.trim() === "") {
611
884
  throw new Error('speaker is required — "me" for the assistant, or a person\'s name.');
612
885
  }
886
+ const limit = optRange(opts.limit, "limit", PAGE_LIMIT_MIN, PAGE_LIMIT_MAX);
613
887
  const body = { user_id: this.uid(userId), speaker: speaker.trim() };
614
- if (opts.limit !== undefined)
615
- body.limit = opts.limit;
888
+ if (limit !== undefined)
889
+ body.limit = limit;
616
890
  if (opts.before !== undefined)
617
891
  body.before = opts.before;
618
892
  if (opts.skipIds !== undefined)
@@ -647,7 +921,7 @@ export class Client {
647
921
  if (typeof userId !== "string" || !userId.trim())
648
922
  throw new Error("userId is required (a non-blank string) — deleteStore never falls back to the default store.");
649
923
  warnIfStoreIdCollapses(userId);
650
- return this.request("DELETE", "/api/v1/memory/collection", { user_id: userId });
924
+ return this.remove("/api/v1/memory/collection", { user_id: userId });
651
925
  }
652
926
  async addSpeaker(speaker, userId) {
653
927
  return this.post("/api/v1/memory/speakers", { user_id: this.uid(userId), speaker });
@@ -657,7 +931,7 @@ export class Client {
657
931
  return { ...r, speakers: asRecords(r?.speakers) };
658
932
  }
659
933
  async removeSpeaker(speaker, userId) {
660
- return this.request("DELETE", "/api/v1/memory/speakers", { user_id: this.uid(userId), speaker });
934
+ return this.remove("/api/v1/memory/speakers", { user_id: this.uid(userId), speaker });
661
935
  }
662
936
  async delete(userId, memoryId) {
663
937
  if (typeof memoryId !== "string" || !memoryId.trim()) {
@@ -674,22 +948,26 @@ export class Client {
674
948
  post(path, body, idempotencyKey) {
675
949
  return this.request("POST", path, body, idempotencyKey);
676
950
  }
951
+ remove(path, body) {
952
+ return this.request("DELETE", path, body, undefined, true);
953
+ }
677
954
  deadlineAt() {
678
955
  return this.deadlineMs === undefined ? undefined : Date.now() + this.deadlineMs;
679
956
  }
680
- beginAttempt(deadlineAt) {
957
+ beginAttempt(deadlineAt, answer) {
681
958
  let why = "timeout";
682
959
  let budget = this.timeoutMs;
683
960
  if (deadlineAt !== undefined) {
684
961
  const left = deadlineAt - Date.now();
685
962
  if (left <= 0)
686
- throw new APIConnectionError(0, this.abortMessage("deadline"));
963
+ throw answer ?? new APIConnectionError(0, this.abortMessage("deadline"));
687
964
  if (left < budget) {
688
965
  budget = left;
689
966
  why = "deadline";
690
967
  }
691
968
  }
692
969
  const ctrl = new AbortController();
970
+ const endsAt = Date.now() + budget;
693
971
  const timer = setTimeout(() => ctrl.abort(), budget);
694
972
  const outer = this.signal;
695
973
  const onCaller = () => {
@@ -709,6 +987,7 @@ export class Client {
709
987
  outer?.removeEventListener("abort", onCaller);
710
988
  },
711
989
  why: () => why,
990
+ endsAt,
712
991
  };
713
992
  }
714
993
  abortMessage(why) {
@@ -718,10 +997,10 @@ export class Client {
718
997
  return `deadline of ${this.deadlineMs}ms exhausted`;
719
998
  return `request timed out after ${this.timeoutMs}ms`;
720
999
  }
721
- async backoffSleep(ms, deadlineAt) {
722
- if (deadlineAt !== undefined && ms > deadlineAt - Date.now()) {
723
- throw new APIConnectionError(0, this.abortMessage("deadline"));
724
- }
1000
+ fits(ms, deadlineAt) {
1001
+ return deadlineAt === undefined || ms <= deadlineAt - Date.now();
1002
+ }
1003
+ async pause(ms) {
725
1004
  const outer = this.signal;
726
1005
  if (!outer) {
727
1006
  await new Promise((r) => setTimeout(r, ms));
@@ -739,17 +1018,82 @@ export class Client {
739
1018
  outer.addEventListener("abort", done);
740
1019
  });
741
1020
  }
742
- async requestBytes(path, body) {
1021
+ networkError(e, path) {
1022
+ let text = typeof e?.message === "string" && e.message ? e.message : String(e);
1023
+ const q = path.indexOf("?");
1024
+ if (q !== -1) {
1025
+ const query = path.slice(q);
1026
+ text = text.split(query).join("");
1027
+ try {
1028
+ text = text.split(decodeURIComponent(query)).join("");
1029
+ }
1030
+ catch {
1031
+ }
1032
+ }
1033
+ text = cleanServerText(maskUserinfo(text), 512);
1034
+ const code = causeCode(e);
1035
+ if (code && !text.includes(code))
1036
+ text += ` (${code})`;
1037
+ const quotable = this.userinfo || q !== -1;
1038
+ return new APIConnectionError(0, `network error: ${text}`, undefined, quotable ? undefined : { cause: e });
1039
+ }
1040
+ isRedirected(res) {
1041
+ return (res.status >= 300 && res.status < 400) || res.type === "opaqueredirect" || res.redirected === true;
1042
+ }
1043
+ errorFromAnswer(res, body, retryAfter, mayBeDeleted = false, unread) {
1044
+ const e = parseErrorText(new TextDecoder().decode(body));
1045
+ let msg = cleanServerText(e.message) || `HTTP ${res.status}`;
1046
+ if (unread)
1047
+ msg += ` (the error body could not be read: ${cleanServerText(unread, 512)})`;
1048
+ const requestId = e.requestId === undefined ? undefined : cleanServerText(e.requestId, 256) || undefined;
1049
+ const err = errorFor(res.status, msg, requestId, {
1050
+ type: e.type === undefined ? undefined : cleanServerText(e.type, 256) || undefined,
1051
+ details: cleanDetails(e.details),
1052
+ retryAfter: retryAfter === undefined ? undefined : Math.ceil(retryAfter / 1000),
1053
+ });
1054
+ if (mayBeDeleted && err instanceof NotFoundError) {
1055
+ err.message += " (an earlier attempt may already have deleted it)";
1056
+ }
1057
+ return err;
1058
+ }
1059
+ async send(method, path, payload, extraHeaders, removes = false, reads = false) {
1060
+ if (this.pageRelative && !this.customFetch && !parseBase(this.base)) {
1061
+ throw new Error(`${notAUrl(this.base)}. A relative baseUrl needs a page to resolve against, and this runtime has none.`);
1062
+ }
743
1063
  const attempts = this.maxRetries + 1;
1064
+ const upper = method.toUpperCase();
1065
+ const idempotent = IDEMPOTENT_METHODS.has(upper);
1066
+ const logPath = path.split("?")[0];
744
1067
  const deadlineAt = this.deadlineAt();
1068
+ const headers = extraHeaders ? { ...this.headers, ...extraHeaders } : this.headers;
1069
+ let ambiguous = false;
1070
+ let refused;
745
1071
  for (let attempt = 0;; attempt++) {
746
- const att = this.beginAttempt(deadlineAt);
1072
+ const last = attempt + 1 >= attempts;
1073
+ const tag = `(attempt ${attempt + 1}/${attempts})`;
1074
+ const start = Date.now();
1075
+ const previous = refused;
1076
+ refused = undefined;
1077
+ const att = this.beginAttempt(deadlineAt, previous);
1078
+ const pauseWithoutAnswer = async (why) => {
1079
+ const delay = this.backoffMs(attempt);
1080
+ if (!this.fits(delay, deadlineAt))
1081
+ throw previous ?? new APIConnectionError(0, this.abortMessage("deadline"));
1082
+ logDebug(`${method} ${logPath}: ${why}. Retrying in ${Math.round(delay)}ms ${tag}`);
1083
+ await this.pause(delay);
1084
+ };
1085
+ const cutShort = () => {
1086
+ const why = att.why();
1087
+ if (why === "deadline" && (idempotent || reads) && previous)
1088
+ return previous;
1089
+ return new APIConnectionError(0, this.abortMessage(why));
1090
+ };
747
1091
  let res;
748
1092
  try {
749
1093
  res = await this.fetchImpl(`${this.base}${path}`, {
750
- method: "POST",
751
- headers: this.headers,
752
- body: JSON.stringify(body),
1094
+ method,
1095
+ headers,
1096
+ body: payload,
753
1097
  signal: att.signal,
754
1098
  redirect: "manual",
755
1099
  });
@@ -757,63 +1101,109 @@ export class Client {
757
1101
  catch (e) {
758
1102
  att.clear();
759
1103
  const timedOut = e?.name === "AbortError" || att.signal.aborted;
760
- if (!timedOut && isConnectFailure(e) && attempt + 1 < attempts) {
761
- await this.backoffSleep(this.backoffMs(attempt), deadlineAt);
1104
+ if (timedOut)
1105
+ throw cutShort();
1106
+ const connect = isConnectFailure(e);
1107
+ if ((idempotent || connect) && !last) {
1108
+ if (!connect)
1109
+ ambiguous = true;
1110
+ await pauseWithoutAnswer(e?.code ?? e?.name ?? "network error");
762
1111
  continue;
763
1112
  }
764
- throw new APIConnectionError(0, timedOut ? this.abortMessage(att.why()) : `network error: ${e?.message ?? e}`);
1113
+ throw this.networkError(e, path);
765
1114
  }
766
- try {
767
- this._rateLimit = parseRateLimit(res.headers) ?? this._rateLimit;
768
- if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
769
- void res.body?.cancel();
770
- throw errorFor(res.status, "the API answered with a redirect; refusing to follow it");
1115
+ const readBody = async () => {
1116
+ try {
1117
+ return await this.readCappedBytes(res);
771
1118
  }
772
- if (!res.ok) {
773
- if (RETRY_ALWAYS.has(res.status) && attempt + 1 < attempts) {
774
- const retryAfter = res.headers.get("Retry-After");
775
- void res.body?.cancel();
776
- att.clear();
777
- await this.backoffSleep(this.backoffMs(attempt, retryAfter), deadlineAt);
778
- continue;
779
- }
780
- let msg = await this.readCapped(res).catch(() => "");
781
- try {
782
- const data = JSON.parse(msg);
783
- const err = data?.error;
784
- if (err && typeof err === "object")
785
- msg = err.message ?? err.type ?? msg;
786
- else if (typeof err === "string")
787
- msg = err;
788
- }
789
- catch {
790
- }
791
- if (msg.length > MAX_ERR_MSG)
792
- msg = msg.slice(0, MAX_ERR_MSG) + "…(truncated)";
793
- throw errorFor(res.status, msg || `HTTP ${res.status}`);
1119
+ catch (e) {
1120
+ if (e?.name === "AbortError" || att.signal.aborted)
1121
+ throw cutShort();
1122
+ if (e instanceof WosError)
1123
+ throw e;
1124
+ if (idempotent && !last)
1125
+ return null;
1126
+ throw this.networkError(e, path);
794
1127
  }
795
- let buf;
1128
+ };
1129
+ const readErrorBody = async (withinMs) => {
796
1130
  try {
797
- buf = await this.readCappedBytes(res);
1131
+ return { body: await this.readCappedBytes(res, withinMs) };
798
1132
  }
799
1133
  catch (e) {
800
- if (e?.name === "AbortError" || att.signal.aborted) {
801
- throw new APIConnectionError(0, this.abortMessage(att.why()));
1134
+ const aborted = e?.name === "AbortError" || att.signal.aborted;
1135
+ if (aborted && att.why() === "caller")
1136
+ throw new APIConnectionError(0, this.abortMessage("caller"));
1137
+ const unread = aborted
1138
+ ? this.abortMessage(att.why())
1139
+ : e instanceof SlowBody
1140
+ ? e.message
1141
+ : (e instanceof WosError ? e : this.networkError(e, path)).message.replace(/^\[\d+\] /, "");
1142
+ return { body: new Uint8Array(0), unread };
1143
+ }
1144
+ };
1145
+ try {
1146
+ if (this.isRedirected(res)) {
1147
+ discard(res);
1148
+ throw new WosError(res.status, "unexpected redirect — refused (the API key never follows a redirect). Check baseUrl: exact host, https://.");
1149
+ }
1150
+ this._rateLimit = parseRateLimit(res.headers) ?? this._rateLimit;
1151
+ if (!res.ok) {
1152
+ const ra = retryAfterMs(res.headers.get("Retry-After"));
1153
+ let wait = RETRY_ALWAYS.has(res.status) || (RETRY_IF_IDEMPOTENT.has(res.status) && idempotent)
1154
+ ? ra ?? this.backoffMs(attempt)
1155
+ : undefined;
1156
+ const retrying = wait !== undefined && !last && wait <= MAX_RETRY_WAIT_MS && this.fits(wait, deadlineAt);
1157
+ const { body, unread } = await readErrorBody(retrying ? Math.max(0, Math.min(BRIEF_BODY_MS, att.endsAt - Date.now())) : undefined);
1158
+ if (res.status === 409) {
1159
+ const d = parseErrorText(new TextDecoder().decode(body)).details;
1160
+ const ms = d?.retry_after_ms;
1161
+ if (typeof ms === "number" && Number.isFinite(ms) && ms >= 0 && d?.conflicts_with === undefined) {
1162
+ wait = Math.max(ms, this.backoffMs(attempt));
1163
+ }
802
1164
  }
803
- if (e instanceof WosError)
804
- throw e;
805
- throw new APIConnectionError(0, `network error: ${e?.message ?? e}`);
1165
+ if (wait !== undefined && !last) {
1166
+ if (wait > MAX_RETRY_WAIT_MS) {
1167
+ logDebug(`${method} ${logPath} -> ${res.status}: asked to wait ${Math.round(wait)}ms, over the cap. Not retrying ${tag}`);
1168
+ }
1169
+ else if (!this.fits(wait, deadlineAt)) {
1170
+ logDebug(`${method} ${logPath} -> ${res.status}: a ${Math.round(wait)}ms wait does not fit the deadline. Not retrying ${tag}`);
1171
+ }
1172
+ else {
1173
+ if (RETRY_IF_IDEMPOTENT.has(res.status))
1174
+ ambiguous = true;
1175
+ refused = this.errorFromAnswer(res, body, ra, ambiguous && removes, unread);
1176
+ att.clear();
1177
+ logDebug(`${method} ${logPath} -> ${res.status}. Retrying in ${Math.round(wait)}ms ${tag}`);
1178
+ await this.pause(wait);
1179
+ continue;
1180
+ }
1181
+ }
1182
+ logDebug(`${method} ${logPath} -> ${res.status} in ${Date.now() - start}ms ${tag}`);
1183
+ throw this.errorFromAnswer(res, body, ra, ambiguous && removes, unread);
806
1184
  }
807
- if (buf.byteLength === 0) {
808
- throw new WosError(res.status, "empty image body — the service returned no bytes");
1185
+ const body = await readBody();
1186
+ if (body === null) {
1187
+ ambiguous = true;
1188
+ att.clear();
1189
+ await pauseWithoutAnswer("body dropped mid-stream");
1190
+ continue;
809
1191
  }
810
- return { bytes: buf, contentType: res.headers.get("content-type") ?? "application/octet-stream" };
1192
+ logDebug(`${method} ${logPath} -> ${res.status} in ${Date.now() - start}ms ${tag}`);
1193
+ return { res, body };
811
1194
  }
812
1195
  finally {
813
1196
  att.clear();
814
1197
  }
815
1198
  }
816
1199
  }
1200
+ async requestBytes(path, body) {
1201
+ const { res, body: buf } = await this.send("POST", path, JSON.stringify(body), undefined, false, true);
1202
+ if (buf.byteLength === 0) {
1203
+ throw new WosError(res.status, "empty image body — the service returned no bytes");
1204
+ }
1205
+ return { bytes: buf, contentType: res.headers.get("content-type") ?? "application/octet-stream" };
1206
+ }
817
1207
  idemHeader(key) {
818
1208
  if (key === undefined || key === null)
819
1209
  return undefined;
@@ -823,58 +1213,72 @@ export class Client {
823
1213
  return { "Idempotency-Key": key };
824
1214
  }
825
1215
  backoffMs(attempt, retryAfter) {
826
- if (retryAfter) {
827
- const s = Number(retryAfter);
828
- if (Number.isFinite(s) && s >= 0)
829
- return Math.min(30_000, s * 1000);
830
- const t = Date.parse(retryAfter);
831
- if (Number.isFinite(t))
832
- return Math.min(30_000, Math.max(0, t - Date.now()));
833
- }
1216
+ const ra = retryAfterMs(retryAfter ?? null);
1217
+ if (ra !== undefined)
1218
+ return ra;
834
1219
  return Math.min(8_000, 500 * 2 ** attempt) + Math.random() * 250;
835
1220
  }
836
- async readCapped(res) {
837
- return new TextDecoder().decode(await this.readCappedBytes(res));
838
- }
839
- async readCappedBytes(res) {
1221
+ async readCappedBytes(res, withinMs) {
840
1222
  const cl = res.headers.get("content-length");
841
1223
  if (cl && Number(cl) > MAX_RESPONSE_BYTES) {
1224
+ discard(res);
842
1225
  throw new WosError(res.status, `response too large (${cl} bytes) — refusing to buffer it`);
843
1226
  }
844
- if (!res.body) {
845
- const buf = new Uint8Array(await res.arrayBuffer());
846
- if (buf.byteLength > MAX_RESPONSE_BYTES) {
847
- throw new WosError(res.status, `response too large (${buf.byteLength} bytes) — refusing it`);
1227
+ const slow = () => new SlowBody(`not received within ${withinMs}ms`);
1228
+ let late = false;
1229
+ let reader;
1230
+ let stop = () => { };
1231
+ const stopped = new Promise((_, reject) => (stop = () => reject(slow())));
1232
+ stopped.catch(() => { });
1233
+ const timer = withinMs === undefined
1234
+ ? undefined
1235
+ : setTimeout(() => {
1236
+ late = true;
1237
+ stop();
1238
+ reader?.cancel().catch(() => { });
1239
+ }, withinMs);
1240
+ const within = (p) => (timer === undefined ? p : Promise.race([p, stopped]));
1241
+ try {
1242
+ if (!res.body) {
1243
+ const buf = new Uint8Array(await within(res.arrayBuffer()));
1244
+ if (buf.byteLength > MAX_RESPONSE_BYTES) {
1245
+ throw new WosError(res.status, `response too large (${buf.byteLength} bytes) — refusing it`);
1246
+ }
1247
+ return buf;
1248
+ }
1249
+ reader = res.body.getReader();
1250
+ const parts = [];
1251
+ let size = 0;
1252
+ for (;;) {
1253
+ const { done, value } = await within(reader.read());
1254
+ if (late)
1255
+ throw slow();
1256
+ if (done)
1257
+ break;
1258
+ size += value.byteLength;
1259
+ if (size > MAX_RESPONSE_BYTES) {
1260
+ reader.cancel().catch(() => { });
1261
+ throw new WosError(res.status, "response too large — refusing to buffer it");
1262
+ }
1263
+ parts.push(value);
1264
+ }
1265
+ const buf = new Uint8Array(size);
1266
+ let off = 0;
1267
+ for (const p of parts) {
1268
+ buf.set(p, off);
1269
+ off += p.byteLength;
848
1270
  }
849
1271
  return buf;
850
1272
  }
851
- const reader = res.body.getReader();
852
- const parts = [];
853
- let size = 0;
854
- for (;;) {
855
- const { done, value } = await reader.read();
856
- if (done)
857
- break;
858
- size += value.byteLength;
859
- if (size > MAX_RESPONSE_BYTES) {
860
- void reader.cancel();
861
- throw new WosError(res.status, "response too large — refusing to buffer it");
862
- }
863
- parts.push(value);
1273
+ catch (e) {
1274
+ throw late && !(e instanceof SlowBody) ? slow() : e;
864
1275
  }
865
- const buf = new Uint8Array(size);
866
- let off = 0;
867
- for (const p of parts) {
868
- buf.set(p, off);
869
- off += p.byteLength;
1276
+ finally {
1277
+ clearTimeout(timer);
870
1278
  }
871
- return buf;
872
1279
  }
873
- async request(method, path, body, idempotencyKey) {
1280
+ async request(method, path, body, idempotencyKey, removes = false) {
874
1281
  const extraHeaders = this.idemHeader(idempotencyKey);
875
- const attempts = this.maxRetries + 1;
876
- const logPath = path.split("?")[0];
877
- const deadlineAt = this.deadlineAt();
878
1282
  let payload;
879
1283
  try {
880
1284
  payload = body === undefined ? undefined : JSON.stringify(body);
@@ -883,117 +1287,28 @@ export class Client {
883
1287
  throw new Error(`request body could not be serialized: ${e instanceof Error ? e.message : String(e)}. ` +
884
1288
  "Pass plain JSON values — an ORM entity, a BigInt or a circular reference cannot be sent.");
885
1289
  }
886
- for (let attempt = 0; attempt < attempts; attempt++) {
887
- const start = Date.now();
888
- const att = this.beginAttempt(deadlineAt);
889
- let res;
890
- try {
891
- res = await this.fetchImpl(`${this.base}${path}`, {
892
- method,
893
- headers: extraHeaders ? { ...this.headers, ...extraHeaders } : this.headers,
894
- body: payload,
895
- signal: att.signal,
896
- redirect: "manual",
897
- });
898
- }
899
- catch (e) {
900
- att.clear();
901
- const timedOut = e?.name === "AbortError" || att.signal.aborted;
902
- const safe = IDEMPOTENT_METHODS.has(method.toUpperCase()) || isConnectFailure(e);
903
- if (!timedOut && safe && attempt + 1 < attempts) {
904
- const delay = this.backoffMs(attempt);
905
- logDebug(`${method} ${logPath}: ${e?.code ?? e?.name ?? "network error"} — retrying in ${delay}ms (attempt ${attempt + 1}/${attempts})`);
906
- await this.backoffSleep(delay, deadlineAt);
907
- continue;
908
- }
909
- throw new APIConnectionError(0, timedOut ? this.abortMessage(att.why()) : `network error: ${e?.message ?? e}`);
910
- }
911
- const retryable = RETRY_ALWAYS.has(res.status) ||
912
- (RETRY_IF_IDEMPOTENT.has(res.status) && IDEMPOTENT_METHODS.has(method.toUpperCase()));
913
- if (retryable && attempt + 1 < attempts) {
914
- att.clear();
915
- const retryAfter = res.headers.get("Retry-After");
916
- void res.body?.cancel();
917
- const delay = this.backoffMs(attempt, retryAfter);
918
- logDebug(`${method} ${logPath} -> ${res.status} — retrying in ${delay}ms (attempt ${attempt + 1}/${attempts})`);
919
- await this.backoffSleep(delay, deadlineAt);
920
- continue;
921
- }
922
- if ((res.status >= 300 && res.status < 400) || res.type === "opaqueredirect") {
923
- att.clear();
924
- void res.body?.cancel();
925
- throw new WosError(res.status, "unexpected redirect — refused (the API key never follows a redirect). Check baseUrl: exact host, https://.");
926
- }
927
- this._rateLimit = parseRateLimit(res.headers) ?? this._rateLimit;
928
- let text;
929
- try {
930
- text = await this.readCapped(res);
931
- }
932
- catch (e) {
933
- if (e?.name === "AbortError" || att.signal.aborted) {
934
- throw new APIConnectionError(0, this.abortMessage(att.why()));
935
- }
936
- if (e instanceof WosError)
937
- throw e;
938
- if (!(IDEMPOTENT_METHODS.has(method.toUpperCase()) && attempt + 1 < attempts)) {
939
- throw new APIConnectionError(0, `network error: ${e?.message ?? e}`);
940
- }
941
- att.clear();
942
- const delay = this.backoffMs(attempt);
943
- logDebug(`${method} ${logPath} — body dropped mid-stream, retrying in ${delay}ms (attempt ${attempt + 1}/${attempts})`);
944
- await this.backoffSleep(delay, deadlineAt);
945
- continue;
946
- }
947
- finally {
948
- att.clear();
949
- }
950
- logDebug(`${method} ${logPath} -> ${res.status} in ${Date.now() - start}ms (attempt ${attempt + 1}/${attempts})`);
951
- if (!res.ok) {
952
- let msg = text;
953
- let requestId;
954
- try {
955
- const data = JSON.parse(text);
956
- if (data && typeof data === "object") {
957
- const err = data.error;
958
- if (err && typeof err === "object") {
959
- msg = err.message ?? err.type ?? msg;
960
- if (typeof err.request_id === "string")
961
- requestId = err.request_id;
962
- }
963
- else if (typeof err === "string")
964
- msg = err;
965
- else if (typeof data.message === "string")
966
- msg = data.message;
967
- }
968
- }
969
- catch {
970
- }
971
- if (typeof msg === "string" && msg.length > MAX_ERR_MSG)
972
- msg = msg.slice(0, MAX_ERR_MSG) + "…(truncated)";
973
- throw errorFor(res.status, msg, requestId);
974
- }
975
- if (!text) {
976
- if (NO_BODY_STATUS.has(res.status))
977
- return {};
978
- throw new WosError(res.status, "empty response body — expected a JSON object");
979
- }
980
- let data;
981
- try {
982
- data = JSON.parse(text);
983
- }
984
- catch (e) {
985
- throw new WosError(res.status, `invalid JSON in response: ${e?.message ?? e}`);
986
- }
987
- if (data === null || typeof data !== "object" || Array.isArray(data)) {
988
- const got = data === null ? "null" : Array.isArray(data) ? "array" : typeof data;
989
- throw new WosError(res.status, `expected a JSON object in the response, got ${got}`);
990
- }
991
- if (res.headers.get("Idempotent-Replayed") === "true" && !("replayed" in data)) {
992
- data.replayed = true;
993
- }
994
- return data;
1290
+ const { res, body: buf } = await this.send(method, path, payload, extraHeaders, removes);
1291
+ const text = new TextDecoder().decode(buf);
1292
+ if (!text) {
1293
+ if (NO_BODY_STATUS.has(res.status))
1294
+ return {};
1295
+ throw new WosError(res.status, "empty response body — expected a JSON object");
1296
+ }
1297
+ let data;
1298
+ try {
1299
+ data = JSON.parse(text);
1300
+ }
1301
+ catch (e) {
1302
+ throw new WosError(res.status, `invalid JSON in response: ${cleanServerText(String(e?.message ?? e), 512)}`);
1303
+ }
1304
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
1305
+ const got = data === null ? "null" : Array.isArray(data) ? "array" : typeof data;
1306
+ throw new WosError(res.status, `expected a JSON object in the response, got ${got}`);
1307
+ }
1308
+ if (res.headers.get("Idempotent-Replayed") === "true" && !("replayed" in data)) {
1309
+ data.replayed = true;
995
1310
  }
996
- throw new Error("retries exhausted");
1311
+ return data;
997
1312
  }
998
1313
  }
999
1314
  export const WME = Client;