nenavi 0.1.0-alpha.1 → 0.1.0-alpha.2

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
@@ -30,12 +30,14 @@ Every supported operation of the hosted API has a method. Three helpers cover th
30
30
  const answer = await client.ingest({ content: 'Jo is learning Portuguese.', user_id: 'user-1' });
31
31
  const status = await client.events.waitForExtraction(answer.event_id, { timeout: 300_000 }); // complete or failed
32
32
  const run = await client.reingest.run({ user_id: 'user-1' }); // starts a run and polls it every 5 seconds
33
- for await (const node of client.nodes.scanAll({ user_id: 'user-1' })) console.log(node.id); // every page
33
+ for await (const node of client.nodes.scanAll({ user_id: 'user-1', limit: 1000 })) {
34
+ console.log(node.id); // every page, 1 000 nodes each
35
+ }
34
36
  ```
35
37
 
36
38
  `reingest.preview()` estimates a re-ingest without starting one; only `reingest.start()` and `reingest.run()` start a real run. A run takes at most `limit` memories, so a `complete` run can leave some for later: while `run.remaining` is above 0, call `reingest.run()` again with the same parameters to continue it.
37
39
 
38
- The helpers' `timeout` and `interval` are in milliseconds, and aborting their `signal` stops them and any pending poll. A helper that runs out of time throws `WaitTimeoutError` with the last status as `lastStatus`; `timeout: 0` polls once, and `reingest.run()` still starts the run. Every poll and every scan page counts against the key's request rate limit like any other call, so for a large scan pass a larger `limit` (up to 1 000 nodes a page), and run background jobs with a longer `interval` or their own key. The helpers are on the client only, not on `withRawResponse`.
40
+ The helpers' `timeout` and `interval` are in milliseconds, and aborting their `signal` stops them and any pending poll. A helper that runs out of time throws `WaitTimeoutError` with the last status as `lastStatus`, and a poll that fails inside `reingest.run()` throws its own error with `lastStatus` set the same way, so the run's `run_id` is not lost; `timeout: 0` polls once, and `reingest.run()` still starts the run. Every poll and every scan page counts against the key's request rate limit (60 a minute by default) like any other call, and `store()`, `ingest()` and `retrieve()` are never retried on a `429`. So for a large scan pass a larger `limit` (up to 1 000 nodes a page), and run background jobs with a longer `interval` or their own key. The helpers are on the client only, not on `withRawResponse`.
39
41
 
40
42
  ## Options
41
43
 
@@ -55,7 +57,7 @@ Request and response fields keep the API's snake_case names (`user_id`, `event_i
55
57
 
56
58
  Every error the SDK throws is a `NenaviError`, and every class works with `instanceof`: `APIError` and its subclasses (`AuthenticationError`, `NotFoundError`, `RateLimitError`, `QuotaExceededError`, `ServiceUnavailableError` and the others) carry `status`, `reasonCode`, `detail` and `requestId`. `APIConnectionError` and `APITimeoutError` mean no response arrived. `ResponseValidationError` means a `2xx` answer had a body the SDK could not read as the operation's type: the call may have taken effect, so read its `body` (for example the `event_id` of a `store()`) and `requestId` before calling again. `body` can hold stored memory text, and `console.error(error)` or `JSON.stringify(error)` prints it: log the message and `requestId` instead.
57
59
 
58
- Only `GET` methods and `ingestFast()` retry by themselves, on a `429 rate_limited` (after `Retry-After`), a `503 database_unavailable` or `lease_unavailable`, or a connection error. `store()`, `ingest()` and `retrieve()` never retry: a retried store stores the text again and counts twice against the monthly limits. `ingestFast()` sends one `Idempotency-Key` on every retry of a call; to retry it by hand, pass the error's `idempotencyKey`.
60
+ Only `GET` methods and `ingestFast()` retry by themselves, on a `429 rate_limited` (after `Retry-After`), a `503 database_unavailable` or `lease_unavailable`, or a connection error. `store()`, `ingest()` and `retrieve()` never retry: a retried store stores the text again and counts twice against the monthly limits. `ingestFast()` sends one `Idempotency-Key` on every retry of a call. A timeout is never retried, so to retry it by hand, pass the error's `idempotencyKey`. If you wrap `ingestFast()` in your own retry loop, make one key before the first attempt and pass it on every attempt: a call without a key gets a new one, and the service stores and meters the batch again.
59
61
 
60
62
  ## Development
61
63
 
@@ -12,9 +12,11 @@ declare class Resource<Raw extends boolean> {
12
12
  /** A helper's options (sdks/CONTRACT.md, Helpers). */
13
13
  export interface HelperOptions {
14
14
  /**
15
- * Milliseconds after which the helper stops waiting: it polls no more once they run
16
- * out. A poll already sent finishes first, with its own per-attempt timeout and retries.
17
- * 0 polls once (`run()` still starts the run); `Infinity` waits without a limit.
15
+ * Milliseconds after which the helper stops: a deadline. Each poll's attempt gets no
16
+ * more than the time left (at least 1 000 milliseconds), and a retry that would wait
17
+ * past it is not made; a poll the deadline stops this way throws `WaitTimeoutError`
18
+ * too, with the poll's error as its `cause`. 0 polls once (`run()` still starts the
19
+ * run, with the client's own timeout); `Infinity` waits without a limit.
18
20
  */
19
21
  timeout?: number;
20
22
  /** Milliseconds between polls. */
@@ -185,12 +187,14 @@ export declare class Learning<Raw extends boolean = false> extends Resource<Raw>
185
187
  /** `client.events`: the event routes and `waitForExtraction()`. */
186
188
  export declare class EventsWithHelpers extends Events<false> {
187
189
  /**
188
- * Poll the event's extraction status until it is `complete` or `failed`.
190
+ * Poll the event's extraction status until it is no longer `pending` or `running`.
189
191
  *
190
192
  * Polls `GET /v1/events/{event_id}/extraction-status` at once and then every
191
- * `interval` milliseconds (2 000), and returns the last status; a `failed` status is
192
- * returned, not thrown. When `timeout` milliseconds (300 000) run out first, throws
193
- * `WaitTimeoutError` with the last status as `lastStatus`. Aborting `signal` stops
193
+ * `interval` milliseconds (2 000), and returns the first status that is not `pending`
194
+ * or `running`: `complete`, `failed` (returned, not thrown), or a status this SDK does
195
+ * not know yet. When `timeout` milliseconds (300 000) run out first, throws
196
+ * `WaitTimeoutError` with the last status as `lastStatus`; `timeout` is a deadline that
197
+ * also bounds each poll and its retries (see `HelperOptions`). Aborting `signal` stops
194
198
  * the helper and any pending poll or retry. Each poll is an ordinary call, with the
195
199
  * client's retries and errors, and counts against the key's request rate limit like any
196
200
  * other call, so many waits at once can slow the key's other calls. A job can stay
@@ -211,24 +215,30 @@ export declare class NodesWithHelpers extends Nodes<false> {
211
215
  * `NenaviError`, so a scan never ends early without saying so.
212
216
  *
213
217
  * @example
214
- * for await (const node of client.nodes.scanAll({ user_id: 'user-1' })) console.log(node.id);
218
+ * for await (const node of client.nodes.scanAll({ user_id: 'user-1', limit: 1000 })) {
219
+ * console.log(node.id);
220
+ * }
215
221
  */
216
222
  scanAll(params?: NodesScanParams, options?: RequestOptions): AsyncGenerator<NodeResponse, void, undefined>;
217
223
  }
218
224
  /** `client.reingest`: the re-ingest routes and `run()`. */
219
225
  export declare class ReingestWithHelpers extends Reingest<false> {
220
226
  /**
221
- * Start a re-ingest run and poll it until its `state` is `complete` or `failed`.
227
+ * Start a re-ingest run and poll it until its `state` is final.
222
228
  *
223
229
  * Sends `start()` (`dry_run: false`), then polls `get()` every `interval` milliseconds
224
- * (5 000) and returns the last status; a `failed` run is returned, not thrown. When
225
- * nothing needs re-deriving, the service's `200` answer is returned as it is. A `409`
226
- * `reingest_running` throws `ConflictError`. When `timeout` milliseconds (3 600 000)
227
- * run out first, throws `WaitTimeoutError` with the last status as `lastStatus`; the
228
- * run itself goes on. The timeout never prevents the start. Aborting `signal` stops the
229
- * helper and any pending poll, not the run. A failed poll throws that call's error while
230
- * the run goes on; to keep the run's id across such failures, call `start()` and poll
231
- * `get()` yourself.
230
+ * (5 000) and returns the first status whose `state` is not `pending` or `running`:
231
+ * `complete`, `failed` (returned, not thrown), or a state this SDK does not know yet.
232
+ * When nothing needs re-deriving, the service's `200` answer is returned as it is. A
233
+ * `409` `reingest_running` throws `ConflictError`. When `timeout` milliseconds
234
+ * (3 600 000) run out first, throws `WaitTimeoutError` with the last status as
235
+ * `lastStatus`; the run itself goes on. `timeout` is a deadline that also bounds each
236
+ * poll and its retries (see `HelperOptions`); it never prevents or cuts short the start,
237
+ * which has the client's own timeout. Aborting `signal` stops the helper and any pending
238
+ * poll, not the run. A poll that fails throws that call's error while the run goes on,
239
+ * with the last status read before it as `lastStatus`: its `run_id` lets the caller go
240
+ * on polling with `get()` instead of calling `run()` again, which throws `ConflictError`
241
+ * (`reingest_running`) until the run ends.
232
242
  *
233
243
  * A run takes at most `limit` memories, so a `complete` run can leave some for later
234
244
  * (`remaining` above 0): calling `run()` again with the same parameters continues it.
@@ -26,8 +26,24 @@ class Resource {
26
26
  return this._core.request(call, this._raw);
27
27
  }
28
28
  }
29
- /** The states after which a helper stops polling. */
30
- const FINISHED = new Set(['complete', 'failed']);
29
+ /**
30
+ * The states a helper keeps polling through. Any other state, one the service adds later
31
+ * included, is final and returned.
32
+ */
33
+ const WORKING = new Set(['pending', 'running']);
34
+ /** `GET /v1/events/{event_id}/extraction-status`, for the method and its helper. */
35
+ function extractionStatusCall(eventId, options) {
36
+ return {
37
+ method: 'GET',
38
+ path: `/v1/events/${(0, core_js_1.pathParam)(eventId)}/extraction-status`,
39
+ retrySafe: true,
40
+ options,
41
+ };
42
+ }
43
+ /** `GET /v1/reingest/{run_id}`, for the method and its helper. */
44
+ function reingestGetCall(runId, options) {
45
+ return { method: 'GET', path: `/v1/reingest/${(0, core_js_1.pathParam)(runId)}`, retrySafe: true, options };
46
+ }
31
47
  /** A caller's `Idempotency-Key`, or null; an empty value counts as not given. */
32
48
  function callerKey(options) {
33
49
  const key = (0, core_js_1.ownProperties)(options).idempotencyKey;
@@ -73,12 +89,7 @@ class Events extends Resource {
73
89
  }
74
90
  /** `GET /v1/events/{event_id}/extraction-status`. */
75
91
  extractionStatus(eventId, options) {
76
- return this._request(() => ({
77
- method: 'GET',
78
- path: `/v1/events/${(0, core_js_1.pathParam)(eventId)}/extraction-status`,
79
- retrySafe: true,
80
- options,
81
- }));
92
+ return this._request(() => extractionStatusCall(eventId, options));
82
93
  }
83
94
  /** `POST /v1/events/{event_id}/retry-extraction`. Not retried automatically. */
84
95
  retryExtraction(eventId, params, options) {
@@ -244,12 +255,7 @@ class Reingest extends Resource {
244
255
  }
245
256
  /** `GET /v1/reingest/{run_id}`: the run's state, counts and usage. */
246
257
  get(runId, options) {
247
- return this._request(() => ({
248
- method: 'GET',
249
- path: `/v1/reingest/${(0, core_js_1.pathParam)(runId)}`,
250
- retrySafe: true,
251
- options,
252
- }));
258
+ return this._request(() => reingestGetCall(runId, options));
253
259
  }
254
260
  }
255
261
  exports.Reingest = Reingest;
@@ -594,12 +600,14 @@ exports.Learning = Learning;
594
600
  /** `client.events`: the event routes and `waitForExtraction()`. */
595
601
  class EventsWithHelpers extends Events {
596
602
  /**
597
- * Poll the event's extraction status until it is `complete` or `failed`.
603
+ * Poll the event's extraction status until it is no longer `pending` or `running`.
598
604
  *
599
605
  * Polls `GET /v1/events/{event_id}/extraction-status` at once and then every
600
- * `interval` milliseconds (2 000), and returns the last status; a `failed` status is
601
- * returned, not thrown. When `timeout` milliseconds (300 000) run out first, throws
602
- * `WaitTimeoutError` with the last status as `lastStatus`. Aborting `signal` stops
606
+ * `interval` milliseconds (2 000), and returns the first status that is not `pending`
607
+ * or `running`: `complete`, `failed` (returned, not thrown), or a status this SDK does
608
+ * not know yet. When `timeout` milliseconds (300 000) run out first, throws
609
+ * `WaitTimeoutError` with the last status as `lastStatus`; `timeout` is a deadline that
610
+ * also bounds each poll and its retries (see `HelperOptions`). Aborting `signal` stops
603
611
  * the helper and any pending poll or retry. Each poll is an ordinary call, with the
604
612
  * client's retries and errors, and counts against the key's request rate limit like any
605
613
  * other call, so many waits at once can slow the key's other calls. A job can stay
@@ -609,10 +617,26 @@ class EventsWithHelpers extends Events {
609
617
  const { timeout = 300_000, interval = 2_000, signal = null } = (0, core_js_1.ownProperties)(options);
610
618
  const core = this._core;
611
619
  const deadline = (0, core_js_1.helperDeadline)(core.now(), timeout, interval);
620
+ let lastStatus = null;
612
621
  for (;;) {
613
- const status = await this.extractionStatus(eventId, { signal });
614
- if (FINISHED.has(status.status))
622
+ let status;
623
+ try {
624
+ status = await this._request(() => ({
625
+ ...extractionStatusCall(eventId, { signal }),
626
+ deadline,
627
+ }));
628
+ }
629
+ catch (error) {
630
+ // Only a poll the deadline stopped throws WaitTimeoutError; its own error is the cause.
631
+ if (!(error instanceof errors_js_1.WaitTimeoutError))
632
+ throw error;
633
+ const last = lastStatus === null ? 'none' : JSON.stringify(lastStatus.status);
634
+ throw (0, core_js_1.withCause)(new errors_js_1.WaitTimeoutError(`The extraction did not finish within ${timeout} ms: ${error.message}. The last ` +
635
+ `status read was ${last}.`, { lastStatus }), error.cause);
636
+ }
637
+ if (!WORKING.has(status.status))
615
638
  return status;
639
+ lastStatus = status;
616
640
  const wait = (0, core_js_1.helperWait)(core.now(), deadline, interval);
617
641
  if (wait === null) {
618
642
  throw new errors_js_1.WaitTimeoutError(`The extraction did not finish within ${timeout} ms; its last status was ` +
@@ -636,7 +660,9 @@ class NodesWithHelpers extends Nodes {
636
660
  * `NenaviError`, so a scan never ends early without saying so.
637
661
  *
638
662
  * @example
639
- * for await (const node of client.nodes.scanAll({ user_id: 'user-1' })) console.log(node.id);
663
+ * for await (const node of client.nodes.scanAll({ user_id: 'user-1', limit: 1000 })) {
664
+ * console.log(node.id);
665
+ * }
640
666
  */
641
667
  async *scanAll(params, options) {
642
668
  const given = (0, core_js_1.ownProperties)(params);
@@ -663,17 +689,21 @@ exports.NodesWithHelpers = NodesWithHelpers;
663
689
  /** `client.reingest`: the re-ingest routes and `run()`. */
664
690
  class ReingestWithHelpers extends Reingest {
665
691
  /**
666
- * Start a re-ingest run and poll it until its `state` is `complete` or `failed`.
692
+ * Start a re-ingest run and poll it until its `state` is final.
667
693
  *
668
694
  * Sends `start()` (`dry_run: false`), then polls `get()` every `interval` milliseconds
669
- * (5 000) and returns the last status; a `failed` run is returned, not thrown. When
670
- * nothing needs re-deriving, the service's `200` answer is returned as it is. A `409`
671
- * `reingest_running` throws `ConflictError`. When `timeout` milliseconds (3 600 000)
672
- * run out first, throws `WaitTimeoutError` with the last status as `lastStatus`; the
673
- * run itself goes on. The timeout never prevents the start. Aborting `signal` stops the
674
- * helper and any pending poll, not the run. A failed poll throws that call's error while
675
- * the run goes on; to keep the run's id across such failures, call `start()` and poll
676
- * `get()` yourself.
695
+ * (5 000) and returns the first status whose `state` is not `pending` or `running`:
696
+ * `complete`, `failed` (returned, not thrown), or a state this SDK does not know yet.
697
+ * When nothing needs re-deriving, the service's `200` answer is returned as it is. A
698
+ * `409` `reingest_running` throws `ConflictError`. When `timeout` milliseconds
699
+ * (3 600 000) run out first, throws `WaitTimeoutError` with the last status as
700
+ * `lastStatus`; the run itself goes on. `timeout` is a deadline that also bounds each
701
+ * poll and its retries (see `HelperOptions`); it never prevents or cuts short the start,
702
+ * which has the client's own timeout. Aborting `signal` stops the helper and any pending
703
+ * poll, not the run. A poll that fails throws that call's error while the run goes on,
704
+ * with the last status read before it as `lastStatus`: its `run_id` lets the caller go
705
+ * on polling with `get()` instead of calling `run()` again, which throws `ConflictError`
706
+ * (`reingest_running`) until the run ends.
677
707
  *
678
708
  * A run takes at most `limit` memories, so a `complete` run can leave some for later
679
709
  * (`remaining` above 0): calling `run()` again with the same parameters continues it.
@@ -683,14 +713,32 @@ class ReingestWithHelpers extends Reingest {
683
713
  const core = this._core;
684
714
  const deadline = (0, core_js_1.helperDeadline)(core.now(), timeout, interval);
685
715
  let status = await this.start(params, { signal });
686
- while (status.run_id !== null && status.run_id !== undefined && !FINISHED.has(status.state)) {
716
+ while (status.run_id !== null && status.run_id !== undefined && WORKING.has(status.state)) {
687
717
  const wait = (0, core_js_1.helperWait)(core.now(), deadline, interval);
688
718
  if (wait === null) {
689
719
  throw new errors_js_1.WaitTimeoutError(`The re-ingest run ${status.run_id} did not finish within ${timeout} ms; its ` +
690
720
  `last state was ${JSON.stringify(status.state)}.`, { lastStatus: status });
691
721
  }
692
722
  await core.sleep(wait, signal);
693
- status = await this.get(status.run_id, { signal });
723
+ const runId = status.run_id;
724
+ try {
725
+ status = await this._request(() => ({
726
+ ...reingestGetCall(runId, { signal }),
727
+ deadline,
728
+ }));
729
+ }
730
+ catch (error) {
731
+ // A poll the deadline stopped throws WaitTimeoutError; its own error is the cause.
732
+ if (error instanceof errors_js_1.WaitTimeoutError) {
733
+ throw (0, core_js_1.withCause)(new errors_js_1.WaitTimeoutError(`The re-ingest run ${runId} did not finish within ${timeout} ms: ` +
734
+ `${error.message}. Its last state was ${JSON.stringify(status.state)}.`, { lastStatus: status }), error.cause);
735
+ }
736
+ // The run goes on; without its id the caller could only call run() again, which
737
+ // the service refuses with 409 until the run ends. An abort keeps its own reason.
738
+ if (error instanceof errors_js_1.NenaviError && !signal?.aborted)
739
+ error.lastStatus = status;
740
+ throw error;
741
+ }
694
742
  }
695
743
  return status;
696
744
  }
@@ -1,3 +1,4 @@
1
+ import { NenaviError } from './errors.js';
1
2
  /** The `fetch` the SDK calls: the runtime's own, or one the caller passes. */
2
3
  export type Fetch = (input: string, init: RequestInit) => Promise<Response>;
3
4
  export interface ClientOptions {
@@ -46,6 +47,11 @@ export interface Call {
46
47
  options?: RequestOptions | undefined;
47
48
  /** The JSON type of the answer: an object (the default), an array, or an object or null. */
48
49
  answer?: 'object' | 'array' | 'nullable';
50
+ /**
51
+ * A helper's deadline on `Core.now`: it caps each attempt's timeout and stops a retry
52
+ * whose wait would end past it. Unset for an ordinary call.
53
+ */
54
+ deadline?: number;
49
55
  }
50
56
  /** Encode one path parameter, so a value cannot add or change path segments. */
51
57
  export declare function pathParam(value: string): string;
@@ -57,6 +63,8 @@ export declare function helperDeadline(now: number, timeout: number, interval: n
57
63
  export declare function helperWait(now: number, deadline: number, interval: number): number | null;
58
64
  /** Wait `ms` milliseconds, or reject with the signal's reason when it is aborted first. */
59
65
  export declare function sleep(ms: number, signal?: AbortSignal | null): Promise<void>;
66
+ /** Set `cause` as the `Error` constructor does: an own property that is not enumerable. */
67
+ export declare function withCause<E extends Error>(error: E, cause: NenaviError): E;
60
68
  /** Holds the configuration and sends the calls. The key is a private field. */
61
69
  export declare class Core {
62
70
  #private;
package/dist/cjs/core.js CHANGED
@@ -10,6 +10,7 @@ exports.ownProperties = ownProperties;
10
10
  exports.helperDeadline = helperDeadline;
11
11
  exports.helperWait = helperWait;
12
12
  exports.sleep = sleep;
13
+ exports.withCause = withCause;
13
14
  const errors_js_1 = require("./errors.js");
14
15
  const version_js_1 = require("./version.js");
15
16
  const DEFAULT_BASE_URL = 'https://api.prismemory.com';
@@ -20,6 +21,11 @@ const DEFAULT_MAX_RETRIES = 2;
20
21
  const MAX_RETRY_AFTER = 60;
21
22
  const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
22
23
  const RETRYABLE_503 = new Set(['database_unavailable', 'lease_unavailable']);
24
+ /**
25
+ * Milliseconds: the shortest attempt timeout a helper's poll gets near its deadline, so the
26
+ * poll at the deadline (and the one poll of `timeout: 0`) can still be answered.
27
+ */
28
+ const MIN_POLL_TIMEOUT = 1_000;
23
29
  /** The longest delay setTimeout keeps: a longer one fires at once. */
24
30
  const MAX_TIMEOUT = 2_147_483_647;
25
31
  // Visible ASCII only: what an HTTP header can carry without encoding. This is not a check
@@ -201,6 +207,21 @@ function retryWait(error, retryNumber) {
201
207
  }
202
208
  return null;
203
209
  }
210
+ /**
211
+ * The error for a helper's poll that its deadline stopped; the helper adds its status. The
212
+ * poll's own error becomes its `cause`.
213
+ */
214
+ function deadlineError(error, retrySkipped) {
215
+ const reason = retrySkipped
216
+ ? `the last poll threw ${error.name}, and its retry would have waited past the deadline`
217
+ : `the last poll got no answer before the deadline (${error.name})`;
218
+ return withCause(new errors_js_1.WaitTimeoutError(reason, { idempotencyKey: error.idempotencyKey }), error);
219
+ }
220
+ /** Set `cause` as the `Error` constructor does: an own property that is not enumerable. */
221
+ function withCause(error, cause) {
222
+ Object.defineProperty(error, 'cause', { value: cause, writable: true, configurable: true });
223
+ return error;
224
+ }
204
225
  /** The name, and the system error code when there is one, of what fetch threw. */
205
226
  function failureName(error) {
206
227
  const raw = error instanceof Error ? error.name : typeof error;
@@ -264,7 +285,8 @@ class Core {
264
285
  if (auth && this.#apiKey === null) {
265
286
  throw new errors_js_1.ConfigurationError('No API key: pass apiKey or set NENAVI_API_KEY. Only health() works without a key.');
266
287
  }
267
- const timeout = checkTimeout(options.timeout ?? this.timeout);
288
+ const callTimeout = checkTimeout(options.timeout ?? this.timeout);
289
+ const deadline = call.deadline ?? null;
268
290
  const maxRetries = checkMaxRetries(options.maxRetries ?? this.maxRetries);
269
291
  const idempotencyKey = call.idempotencyKey ?? null;
270
292
  const headers = {
@@ -288,6 +310,10 @@ class Core {
288
310
  const url = `${this.baseURL}${call.path}${queryString(call.query)}`;
289
311
  let retries = 0;
290
312
  for (;;) {
313
+ // A helper's poll gets only the time left.
314
+ const left = deadline === null ? Infinity : Math.max(deadline - this.now(), MIN_POLL_TIMEOUT);
315
+ const shortened = left < callTimeout;
316
+ const timeout = shortened ? left : callTimeout;
291
317
  const attempt = await this.#send(url, call.method, headers, body, timeout, signal, idempotencyKey);
292
318
  if (attempt.response !== undefined) {
293
319
  return this.#result(attempt.response, attempt.text, raw, idempotencyKey, call.answer ?? 'object');
@@ -295,11 +321,17 @@ class Core {
295
321
  if (call.retrySafe && retries < maxRetries) {
296
322
  const wait = retryWait(attempt.error, retries + 1);
297
323
  if (wait !== null) {
298
- retries += 1;
299
- await this.sleep(wait, signal);
300
- continue;
324
+ if (deadline === null || this.now() + wait < deadline) {
325
+ retries += 1;
326
+ await this.sleep(wait, signal);
327
+ continue;
328
+ }
329
+ throw deadlineError(attempt.error, true);
301
330
  }
302
331
  }
332
+ if (shortened && attempt.error instanceof errors_js_1.APITimeoutError) {
333
+ throw deadlineError(attempt.error, false);
334
+ }
303
335
  throw attempt.error;
304
336
  }
305
337
  }
@@ -1,9 +1,21 @@
1
1
  /** Base class of every error the SDK throws. */
2
2
  export declare class NenaviError extends Error {
3
+ /**
4
+ * `value instanceof` this class, also for an error from another copy of the package.
5
+ * Installed below with `Object.defineProperty`.
6
+ */
7
+ static [Symbol.hasInstance]: <T>(this: abstract new (...args: never[]) => T, value: unknown) => value is T;
3
8
  /** The `Idempotency-Key` the call sent, or null when it sent none. */
4
9
  idempotencyKey: string | null;
10
+ /**
11
+ * The last status a helper read before it threw: on `WaitTimeoutError`, and on the
12
+ * error of a poll that failed inside `reingest.run()`, where it holds the run's
13
+ * `run_id`. Null on other errors.
14
+ */
15
+ lastStatus: unknown;
5
16
  constructor(message: string, options?: {
6
17
  idempotencyKey?: string | null;
18
+ lastStatus?: unknown;
7
19
  });
8
20
  }
9
21
  /** No key for an operation that needs one, or an invalid option. */
@@ -11,12 +23,6 @@ export declare class ConfigurationError extends NenaviError {
11
23
  }
12
24
  /** A helper ran out of time before the work it waited for finished. */
13
25
  export declare class WaitTimeoutError extends NenaviError {
14
- /** The last status the helper read. */
15
- lastStatus: unknown;
16
- constructor(message: string, options?: {
17
- lastStatus?: unknown;
18
- idempotencyKey?: string | null;
19
- });
20
26
  }
21
27
  export interface ResponseValidationErrorOptions {
22
28
  status: number;
@@ -4,32 +4,79 @@
4
4
  // An error holds plain values only: the status, a copy of the response headers and the
5
5
  // parsed body. It never holds a Request, a Response or the error fetch threw, so the
6
6
  // `Authorization` header cannot reach a message, `util.inspect` or `JSON.stringify`.
7
+ //
8
+ // The package ships an ES module build and a CommonJS build, and a process that loads
9
+ // both holds two copies of every class here. So that `instanceof` still works across the
10
+ // copies, each class's prototype carries its name under a key in the global symbol
11
+ // registry, and `instanceof` falls back to matching that name along the prototype chain.
7
12
  Object.defineProperty(exports, "__esModule", { value: true });
8
13
  exports.DETAIL_TEXT_LIMIT = exports.ServiceUnavailableError = exports.InternalServerError = exports.QuotaExceededError = exports.RateLimitError = exports.UnprocessableEntityError = exports.PayloadTooLargeError = exports.ConflictError = exports.NotFoundError = exports.PermissionDeniedError = exports.AuthenticationError = exports.BadRequestError = exports.APIError = exports.APITimeoutError = exports.APIConnectionError = exports.ResponseValidationError = exports.WaitTimeoutError = exports.ConfigurationError = exports.NenaviError = void 0;
9
14
  exports.parseRetryAfter = parseRetryAfter;
10
15
  exports.errorFromResponse = errorFromResponse;
16
+ /**
17
+ * The key, shared by every copy of the package, under which a prototype names its class.
18
+ *
19
+ * Copies of different versions match each other by this key and the class name alone, so
20
+ * the key is a contract between versions. A release that changes an error class's fields
21
+ * incompatibly must change the key (for example to `nenavi.error.class.v2`), so that its
22
+ * errors stop matching older copies' classes instead of narrowing to the old shape.
23
+ */
24
+ const CLASS_NAME = Symbol.for('nenavi.error.class');
11
25
  /** Base class of every error the SDK throws. */
12
26
  class NenaviError extends Error {
13
27
  /** The `Idempotency-Key` the call sent, or null when it sent none. */
14
28
  idempotencyKey;
29
+ /**
30
+ * The last status a helper read before it threw: on `WaitTimeoutError`, and on the
31
+ * error of a poll that failed inside `reingest.run()`, where it holds the run's
32
+ * `run_id`. Null on other errors.
33
+ */
34
+ lastStatus;
15
35
  constructor(message, options = {}) {
16
36
  super(message);
17
37
  this.idempotencyKey = options.idempotencyKey ?? null;
38
+ this.lastStatus = options.lastStatus ?? null;
18
39
  }
19
40
  }
20
41
  exports.NenaviError = NenaviError;
42
+ // The ordinary check comes first. Failing that, a class of this package matches a value
43
+ // whose prototype chain holds a prototype marked with the same class name, as the other
44
+ // copy's classes are. A subclass written outside the package keeps the ordinary check.
45
+ //
46
+ // This is defined as a property rather than written as a static method, because a loose
47
+ // class transform in a consumer's build turns a static method into an assignment, and
48
+ // assigning over the read-only `Function.prototype[Symbol.hasInstance]` throws.
49
+ Object.defineProperty(NenaviError, Symbol.hasInstance, {
50
+ value: function hasInstance(value) {
51
+ if (Function.prototype[Symbol.hasInstance].call(this, value))
52
+ return true;
53
+ if ((typeof value !== 'object' && typeof value !== 'function') || value === null) {
54
+ return false;
55
+ }
56
+ const prototype = this.prototype;
57
+ if (typeof prototype !== 'object' || prototype === null)
58
+ return false;
59
+ if (!Object.hasOwn(prototype, CLASS_NAME))
60
+ return false;
61
+ const name = prototype[CLASS_NAME];
62
+ for (let link = Object.getPrototypeOf(value); link !== null; link = Object.getPrototypeOf(link)) {
63
+ if (Object.hasOwn(link, CLASS_NAME) &&
64
+ link[CLASS_NAME] === name) {
65
+ return true;
66
+ }
67
+ }
68
+ return false;
69
+ },
70
+ writable: true,
71
+ enumerable: false,
72
+ configurable: true,
73
+ });
21
74
  /** No key for an operation that needs one, or an invalid option. */
22
75
  class ConfigurationError extends NenaviError {
23
76
  }
24
77
  exports.ConfigurationError = ConfigurationError;
25
78
  /** A helper ran out of time before the work it waited for finished. */
26
79
  class WaitTimeoutError extends NenaviError {
27
- /** The last status the helper read. */
28
- lastStatus;
29
- constructor(message, options = {}) {
30
- super(message, options);
31
- this.lastStatus = options.lastStatus ?? null;
32
- }
33
80
  }
34
81
  exports.WaitTimeoutError = WaitTimeoutError;
35
82
  /**
@@ -140,7 +187,8 @@ class ServiceUnavailableError extends APIError {
140
187
  }
141
188
  exports.ServiceUnavailableError = ServiceUnavailableError;
142
189
  // Each class's name, written out so that `error.name` survives a minifier that renames
143
- // classes. It sits on the prototype, so it is not an own property of an error.
190
+ // classes. It sits on the prototype, so it is not an own property of an error. The same
191
+ // name marks the prototype under CLASS_NAME for the cross-copy `instanceof` above.
144
192
  const NAMES = [
145
193
  [NenaviError, 'NenaviError'],
146
194
  [ConfigurationError, 'ConfigurationError'],
@@ -168,6 +216,12 @@ for (const [ErrorClass, name] of NAMES) {
168
216
  enumerable: false,
169
217
  configurable: true,
170
218
  });
219
+ Object.defineProperty(ErrorClass.prototype, CLASS_NAME, {
220
+ value: name,
221
+ writable: false,
222
+ enumerable: false,
223
+ configurable: false,
224
+ });
171
225
  }
172
226
  const BY_STATUS = {
173
227
  400: BadRequestError,
@@ -1,2 +1,2 @@
1
1
  /** The SDK's version, sent in the `User-Agent` header. test/client.test.ts keeps it equal to package.json. */
2
- export declare const VERSION = "0.1.0-alpha.1";
2
+ export declare const VERSION = "0.1.0-alpha.2";
@@ -2,4 +2,4 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
4
  /** The SDK's version, sent in the `User-Agent` header. test/client.test.ts keeps it equal to package.json. */
5
- exports.VERSION = '0.1.0-alpha.1';
5
+ exports.VERSION = '0.1.0-alpha.2';
@@ -12,9 +12,11 @@ declare class Resource<Raw extends boolean> {
12
12
  /** A helper's options (sdks/CONTRACT.md, Helpers). */
13
13
  export interface HelperOptions {
14
14
  /**
15
- * Milliseconds after which the helper stops waiting: it polls no more once they run
16
- * out. A poll already sent finishes first, with its own per-attempt timeout and retries.
17
- * 0 polls once (`run()` still starts the run); `Infinity` waits without a limit.
15
+ * Milliseconds after which the helper stops: a deadline. Each poll's attempt gets no
16
+ * more than the time left (at least 1 000 milliseconds), and a retry that would wait
17
+ * past it is not made; a poll the deadline stops this way throws `WaitTimeoutError`
18
+ * too, with the poll's error as its `cause`. 0 polls once (`run()` still starts the
19
+ * run, with the client's own timeout); `Infinity` waits without a limit.
18
20
  */
19
21
  timeout?: number;
20
22
  /** Milliseconds between polls. */
@@ -185,12 +187,14 @@ export declare class Learning<Raw extends boolean = false> extends Resource<Raw>
185
187
  /** `client.events`: the event routes and `waitForExtraction()`. */
186
188
  export declare class EventsWithHelpers extends Events<false> {
187
189
  /**
188
- * Poll the event's extraction status until it is `complete` or `failed`.
190
+ * Poll the event's extraction status until it is no longer `pending` or `running`.
189
191
  *
190
192
  * Polls `GET /v1/events/{event_id}/extraction-status` at once and then every
191
- * `interval` milliseconds (2 000), and returns the last status; a `failed` status is
192
- * returned, not thrown. When `timeout` milliseconds (300 000) run out first, throws
193
- * `WaitTimeoutError` with the last status as `lastStatus`. Aborting `signal` stops
193
+ * `interval` milliseconds (2 000), and returns the first status that is not `pending`
194
+ * or `running`: `complete`, `failed` (returned, not thrown), or a status this SDK does
195
+ * not know yet. When `timeout` milliseconds (300 000) run out first, throws
196
+ * `WaitTimeoutError` with the last status as `lastStatus`; `timeout` is a deadline that
197
+ * also bounds each poll and its retries (see `HelperOptions`). Aborting `signal` stops
194
198
  * the helper and any pending poll or retry. Each poll is an ordinary call, with the
195
199
  * client's retries and errors, and counts against the key's request rate limit like any
196
200
  * other call, so many waits at once can slow the key's other calls. A job can stay
@@ -211,24 +215,30 @@ export declare class NodesWithHelpers extends Nodes<false> {
211
215
  * `NenaviError`, so a scan never ends early without saying so.
212
216
  *
213
217
  * @example
214
- * for await (const node of client.nodes.scanAll({ user_id: 'user-1' })) console.log(node.id);
218
+ * for await (const node of client.nodes.scanAll({ user_id: 'user-1', limit: 1000 })) {
219
+ * console.log(node.id);
220
+ * }
215
221
  */
216
222
  scanAll(params?: NodesScanParams, options?: RequestOptions): AsyncGenerator<NodeResponse, void, undefined>;
217
223
  }
218
224
  /** `client.reingest`: the re-ingest routes and `run()`. */
219
225
  export declare class ReingestWithHelpers extends Reingest<false> {
220
226
  /**
221
- * Start a re-ingest run and poll it until its `state` is `complete` or `failed`.
227
+ * Start a re-ingest run and poll it until its `state` is final.
222
228
  *
223
229
  * Sends `start()` (`dry_run: false`), then polls `get()` every `interval` milliseconds
224
- * (5 000) and returns the last status; a `failed` run is returned, not thrown. When
225
- * nothing needs re-deriving, the service's `200` answer is returned as it is. A `409`
226
- * `reingest_running` throws `ConflictError`. When `timeout` milliseconds (3 600 000)
227
- * run out first, throws `WaitTimeoutError` with the last status as `lastStatus`; the
228
- * run itself goes on. The timeout never prevents the start. Aborting `signal` stops the
229
- * helper and any pending poll, not the run. A failed poll throws that call's error while
230
- * the run goes on; to keep the run's id across such failures, call `start()` and poll
231
- * `get()` yourself.
230
+ * (5 000) and returns the first status whose `state` is not `pending` or `running`:
231
+ * `complete`, `failed` (returned, not thrown), or a state this SDK does not know yet.
232
+ * When nothing needs re-deriving, the service's `200` answer is returned as it is. A
233
+ * `409` `reingest_running` throws `ConflictError`. When `timeout` milliseconds
234
+ * (3 600 000) run out first, throws `WaitTimeoutError` with the last status as
235
+ * `lastStatus`; the run itself goes on. `timeout` is a deadline that also bounds each
236
+ * poll and its retries (see `HelperOptions`); it never prevents or cuts short the start,
237
+ * which has the client's own timeout. Aborting `signal` stops the helper and any pending
238
+ * poll, not the run. A poll that fails throws that call's error while the run goes on,
239
+ * with the last status read before it as `lastStatus`: its `run_id` lets the caller go
240
+ * on polling with `get()` instead of calling `run()` again, which throws `ConflictError`
241
+ * (`reingest_running`) until the run ends.
232
242
  *
233
243
  * A run takes at most `limit` memories, so a `complete` run can leave some for later
234
244
  * (`remaining` above 0): calling `run()` again with the same parameters continues it.
@@ -3,7 +3,7 @@
3
3
  // Each method describes one call: its HTTP method and path, where its fields go, and
4
4
  // whether the SDK may retry it by itself. Core in core.ts sends it.
5
5
  import { NenaviError, WaitTimeoutError } from './errors.js';
6
- import { Core, helperDeadline, helperWait, ownProperties, pathParam, } from './core.js';
6
+ import { Core, helperDeadline, helperWait, ownProperties, pathParam, withCause, } from './core.js';
7
7
  class Resource {
8
8
  _core;
9
9
  _raw;
@@ -23,8 +23,24 @@ class Resource {
23
23
  return this._core.request(call, this._raw);
24
24
  }
25
25
  }
26
- /** The states after which a helper stops polling. */
27
- const FINISHED = new Set(['complete', 'failed']);
26
+ /**
27
+ * The states a helper keeps polling through. Any other state, one the service adds later
28
+ * included, is final and returned.
29
+ */
30
+ const WORKING = new Set(['pending', 'running']);
31
+ /** `GET /v1/events/{event_id}/extraction-status`, for the method and its helper. */
32
+ function extractionStatusCall(eventId, options) {
33
+ return {
34
+ method: 'GET',
35
+ path: `/v1/events/${pathParam(eventId)}/extraction-status`,
36
+ retrySafe: true,
37
+ options,
38
+ };
39
+ }
40
+ /** `GET /v1/reingest/{run_id}`, for the method and its helper. */
41
+ function reingestGetCall(runId, options) {
42
+ return { method: 'GET', path: `/v1/reingest/${pathParam(runId)}`, retrySafe: true, options };
43
+ }
28
44
  /** A caller's `Idempotency-Key`, or null; an empty value counts as not given. */
29
45
  function callerKey(options) {
30
46
  const key = ownProperties(options).idempotencyKey;
@@ -70,12 +86,7 @@ export class Events extends Resource {
70
86
  }
71
87
  /** `GET /v1/events/{event_id}/extraction-status`. */
72
88
  extractionStatus(eventId, options) {
73
- return this._request(() => ({
74
- method: 'GET',
75
- path: `/v1/events/${pathParam(eventId)}/extraction-status`,
76
- retrySafe: true,
77
- options,
78
- }));
89
+ return this._request(() => extractionStatusCall(eventId, options));
79
90
  }
80
91
  /** `POST /v1/events/{event_id}/retry-extraction`. Not retried automatically. */
81
92
  retryExtraction(eventId, params, options) {
@@ -239,12 +250,7 @@ export class Reingest extends Resource {
239
250
  }
240
251
  /** `GET /v1/reingest/{run_id}`: the run's state, counts and usage. */
241
252
  get(runId, options) {
242
- return this._request(() => ({
243
- method: 'GET',
244
- path: `/v1/reingest/${pathParam(runId)}`,
245
- retrySafe: true,
246
- options,
247
- }));
253
+ return this._request(() => reingestGetCall(runId, options));
248
254
  }
249
255
  }
250
256
  /** The claim routes: supersedences and contradictions. Not retried automatically. */
@@ -577,12 +583,14 @@ export class Learning extends Resource {
577
583
  /** `client.events`: the event routes and `waitForExtraction()`. */
578
584
  export class EventsWithHelpers extends Events {
579
585
  /**
580
- * Poll the event's extraction status until it is `complete` or `failed`.
586
+ * Poll the event's extraction status until it is no longer `pending` or `running`.
581
587
  *
582
588
  * Polls `GET /v1/events/{event_id}/extraction-status` at once and then every
583
- * `interval` milliseconds (2 000), and returns the last status; a `failed` status is
584
- * returned, not thrown. When `timeout` milliseconds (300 000) run out first, throws
585
- * `WaitTimeoutError` with the last status as `lastStatus`. Aborting `signal` stops
589
+ * `interval` milliseconds (2 000), and returns the first status that is not `pending`
590
+ * or `running`: `complete`, `failed` (returned, not thrown), or a status this SDK does
591
+ * not know yet. When `timeout` milliseconds (300 000) run out first, throws
592
+ * `WaitTimeoutError` with the last status as `lastStatus`; `timeout` is a deadline that
593
+ * also bounds each poll and its retries (see `HelperOptions`). Aborting `signal` stops
586
594
  * the helper and any pending poll or retry. Each poll is an ordinary call, with the
587
595
  * client's retries and errors, and counts against the key's request rate limit like any
588
596
  * other call, so many waits at once can slow the key's other calls. A job can stay
@@ -592,10 +600,26 @@ export class EventsWithHelpers extends Events {
592
600
  const { timeout = 300_000, interval = 2_000, signal = null } = ownProperties(options);
593
601
  const core = this._core;
594
602
  const deadline = helperDeadline(core.now(), timeout, interval);
603
+ let lastStatus = null;
595
604
  for (;;) {
596
- const status = await this.extractionStatus(eventId, { signal });
597
- if (FINISHED.has(status.status))
605
+ let status;
606
+ try {
607
+ status = await this._request(() => ({
608
+ ...extractionStatusCall(eventId, { signal }),
609
+ deadline,
610
+ }));
611
+ }
612
+ catch (error) {
613
+ // Only a poll the deadline stopped throws WaitTimeoutError; its own error is the cause.
614
+ if (!(error instanceof WaitTimeoutError))
615
+ throw error;
616
+ const last = lastStatus === null ? 'none' : JSON.stringify(lastStatus.status);
617
+ throw withCause(new WaitTimeoutError(`The extraction did not finish within ${timeout} ms: ${error.message}. The last ` +
618
+ `status read was ${last}.`, { lastStatus }), error.cause);
619
+ }
620
+ if (!WORKING.has(status.status))
598
621
  return status;
622
+ lastStatus = status;
599
623
  const wait = helperWait(core.now(), deadline, interval);
600
624
  if (wait === null) {
601
625
  throw new WaitTimeoutError(`The extraction did not finish within ${timeout} ms; its last status was ` +
@@ -618,7 +642,9 @@ export class NodesWithHelpers extends Nodes {
618
642
  * `NenaviError`, so a scan never ends early without saying so.
619
643
  *
620
644
  * @example
621
- * for await (const node of client.nodes.scanAll({ user_id: 'user-1' })) console.log(node.id);
645
+ * for await (const node of client.nodes.scanAll({ user_id: 'user-1', limit: 1000 })) {
646
+ * console.log(node.id);
647
+ * }
622
648
  */
623
649
  async *scanAll(params, options) {
624
650
  const given = ownProperties(params);
@@ -644,17 +670,21 @@ export class NodesWithHelpers extends Nodes {
644
670
  /** `client.reingest`: the re-ingest routes and `run()`. */
645
671
  export class ReingestWithHelpers extends Reingest {
646
672
  /**
647
- * Start a re-ingest run and poll it until its `state` is `complete` or `failed`.
673
+ * Start a re-ingest run and poll it until its `state` is final.
648
674
  *
649
675
  * Sends `start()` (`dry_run: false`), then polls `get()` every `interval` milliseconds
650
- * (5 000) and returns the last status; a `failed` run is returned, not thrown. When
651
- * nothing needs re-deriving, the service's `200` answer is returned as it is. A `409`
652
- * `reingest_running` throws `ConflictError`. When `timeout` milliseconds (3 600 000)
653
- * run out first, throws `WaitTimeoutError` with the last status as `lastStatus`; the
654
- * run itself goes on. The timeout never prevents the start. Aborting `signal` stops the
655
- * helper and any pending poll, not the run. A failed poll throws that call's error while
656
- * the run goes on; to keep the run's id across such failures, call `start()` and poll
657
- * `get()` yourself.
676
+ * (5 000) and returns the first status whose `state` is not `pending` or `running`:
677
+ * `complete`, `failed` (returned, not thrown), or a state this SDK does not know yet.
678
+ * When nothing needs re-deriving, the service's `200` answer is returned as it is. A
679
+ * `409` `reingest_running` throws `ConflictError`. When `timeout` milliseconds
680
+ * (3 600 000) run out first, throws `WaitTimeoutError` with the last status as
681
+ * `lastStatus`; the run itself goes on. `timeout` is a deadline that also bounds each
682
+ * poll and its retries (see `HelperOptions`); it never prevents or cuts short the start,
683
+ * which has the client's own timeout. Aborting `signal` stops the helper and any pending
684
+ * poll, not the run. A poll that fails throws that call's error while the run goes on,
685
+ * with the last status read before it as `lastStatus`: its `run_id` lets the caller go
686
+ * on polling with `get()` instead of calling `run()` again, which throws `ConflictError`
687
+ * (`reingest_running`) until the run ends.
658
688
  *
659
689
  * A run takes at most `limit` memories, so a `complete` run can leave some for later
660
690
  * (`remaining` above 0): calling `run()` again with the same parameters continues it.
@@ -664,14 +694,32 @@ export class ReingestWithHelpers extends Reingest {
664
694
  const core = this._core;
665
695
  const deadline = helperDeadline(core.now(), timeout, interval);
666
696
  let status = await this.start(params, { signal });
667
- while (status.run_id !== null && status.run_id !== undefined && !FINISHED.has(status.state)) {
697
+ while (status.run_id !== null && status.run_id !== undefined && WORKING.has(status.state)) {
668
698
  const wait = helperWait(core.now(), deadline, interval);
669
699
  if (wait === null) {
670
700
  throw new WaitTimeoutError(`The re-ingest run ${status.run_id} did not finish within ${timeout} ms; its ` +
671
701
  `last state was ${JSON.stringify(status.state)}.`, { lastStatus: status });
672
702
  }
673
703
  await core.sleep(wait, signal);
674
- status = await this.get(status.run_id, { signal });
704
+ const runId = status.run_id;
705
+ try {
706
+ status = await this._request(() => ({
707
+ ...reingestGetCall(runId, { signal }),
708
+ deadline,
709
+ }));
710
+ }
711
+ catch (error) {
712
+ // A poll the deadline stopped throws WaitTimeoutError; its own error is the cause.
713
+ if (error instanceof WaitTimeoutError) {
714
+ throw withCause(new WaitTimeoutError(`The re-ingest run ${runId} did not finish within ${timeout} ms: ` +
715
+ `${error.message}. Its last state was ${JSON.stringify(status.state)}.`, { lastStatus: status }), error.cause);
716
+ }
717
+ // The run goes on; without its id the caller could only call run() again, which
718
+ // the service refuses with 409 until the run ends. An abort keeps its own reason.
719
+ if (error instanceof NenaviError && !signal?.aborted)
720
+ error.lastStatus = status;
721
+ throw error;
722
+ }
675
723
  }
676
724
  return status;
677
725
  }
@@ -1,3 +1,4 @@
1
+ import { NenaviError } from './errors.js';
1
2
  /** The `fetch` the SDK calls: the runtime's own, or one the caller passes. */
2
3
  export type Fetch = (input: string, init: RequestInit) => Promise<Response>;
3
4
  export interface ClientOptions {
@@ -46,6 +47,11 @@ export interface Call {
46
47
  options?: RequestOptions | undefined;
47
48
  /** The JSON type of the answer: an object (the default), an array, or an object or null. */
48
49
  answer?: 'object' | 'array' | 'nullable';
50
+ /**
51
+ * A helper's deadline on `Core.now`: it caps each attempt's timeout and stops a retry
52
+ * whose wait would end past it. Unset for an ordinary call.
53
+ */
54
+ deadline?: number;
49
55
  }
50
56
  /** Encode one path parameter, so a value cannot add or change path segments. */
51
57
  export declare function pathParam(value: string): string;
@@ -57,6 +63,8 @@ export declare function helperDeadline(now: number, timeout: number, interval: n
57
63
  export declare function helperWait(now: number, deadline: number, interval: number): number | null;
58
64
  /** Wait `ms` milliseconds, or reject with the signal's reason when it is aborted first. */
59
65
  export declare function sleep(ms: number, signal?: AbortSignal | null): Promise<void>;
66
+ /** Set `cause` as the `Error` constructor does: an own property that is not enumerable. */
67
+ export declare function withCause<E extends Error>(error: E, cause: NenaviError): E;
60
68
  /** Holds the configuration and sends the calls. The key is a private field. */
61
69
  export declare class Core {
62
70
  #private;
package/dist/esm/core.js CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Everything that decides what to send, whether to retry, how long to wait and which
4
4
  // error to throw lives here. The method classes in client.ts only describe each call.
5
- import { APIConnectionError, APIError, APITimeoutError, ConfigurationError, DETAIL_TEXT_LIMIT, NenaviError, RateLimitError, ResponseValidationError, ServiceUnavailableError, errorFromResponse, parseRetryAfter, } from './errors.js';
5
+ import { APIConnectionError, APIError, APITimeoutError, ConfigurationError, DETAIL_TEXT_LIMIT, NenaviError, RateLimitError, ResponseValidationError, ServiceUnavailableError, WaitTimeoutError, errorFromResponse, parseRetryAfter, } from './errors.js';
6
6
  import { VERSION } from './version.js';
7
7
  const DEFAULT_BASE_URL = 'https://api.prismemory.com';
8
8
  /** Milliseconds per attempt. */
@@ -12,6 +12,11 @@ const DEFAULT_MAX_RETRIES = 2;
12
12
  const MAX_RETRY_AFTER = 60;
13
13
  const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
14
14
  const RETRYABLE_503 = new Set(['database_unavailable', 'lease_unavailable']);
15
+ /**
16
+ * Milliseconds: the shortest attempt timeout a helper's poll gets near its deadline, so the
17
+ * poll at the deadline (and the one poll of `timeout: 0`) can still be answered.
18
+ */
19
+ const MIN_POLL_TIMEOUT = 1_000;
15
20
  /** The longest delay setTimeout keeps: a longer one fires at once. */
16
21
  const MAX_TIMEOUT = 2_147_483_647;
17
22
  // Visible ASCII only: what an HTTP header can carry without encoding. This is not a check
@@ -193,6 +198,21 @@ function retryWait(error, retryNumber) {
193
198
  }
194
199
  return null;
195
200
  }
201
+ /**
202
+ * The error for a helper's poll that its deadline stopped; the helper adds its status. The
203
+ * poll's own error becomes its `cause`.
204
+ */
205
+ function deadlineError(error, retrySkipped) {
206
+ const reason = retrySkipped
207
+ ? `the last poll threw ${error.name}, and its retry would have waited past the deadline`
208
+ : `the last poll got no answer before the deadline (${error.name})`;
209
+ return withCause(new WaitTimeoutError(reason, { idempotencyKey: error.idempotencyKey }), error);
210
+ }
211
+ /** Set `cause` as the `Error` constructor does: an own property that is not enumerable. */
212
+ export function withCause(error, cause) {
213
+ Object.defineProperty(error, 'cause', { value: cause, writable: true, configurable: true });
214
+ return error;
215
+ }
196
216
  /** The name, and the system error code when there is one, of what fetch threw. */
197
217
  function failureName(error) {
198
218
  const raw = error instanceof Error ? error.name : typeof error;
@@ -256,7 +276,8 @@ export class Core {
256
276
  if (auth && this.#apiKey === null) {
257
277
  throw new ConfigurationError('No API key: pass apiKey or set NENAVI_API_KEY. Only health() works without a key.');
258
278
  }
259
- const timeout = checkTimeout(options.timeout ?? this.timeout);
279
+ const callTimeout = checkTimeout(options.timeout ?? this.timeout);
280
+ const deadline = call.deadline ?? null;
260
281
  const maxRetries = checkMaxRetries(options.maxRetries ?? this.maxRetries);
261
282
  const idempotencyKey = call.idempotencyKey ?? null;
262
283
  const headers = {
@@ -280,6 +301,10 @@ export class Core {
280
301
  const url = `${this.baseURL}${call.path}${queryString(call.query)}`;
281
302
  let retries = 0;
282
303
  for (;;) {
304
+ // A helper's poll gets only the time left.
305
+ const left = deadline === null ? Infinity : Math.max(deadline - this.now(), MIN_POLL_TIMEOUT);
306
+ const shortened = left < callTimeout;
307
+ const timeout = shortened ? left : callTimeout;
283
308
  const attempt = await this.#send(url, call.method, headers, body, timeout, signal, idempotencyKey);
284
309
  if (attempt.response !== undefined) {
285
310
  return this.#result(attempt.response, attempt.text, raw, idempotencyKey, call.answer ?? 'object');
@@ -287,11 +312,17 @@ export class Core {
287
312
  if (call.retrySafe && retries < maxRetries) {
288
313
  const wait = retryWait(attempt.error, retries + 1);
289
314
  if (wait !== null) {
290
- retries += 1;
291
- await this.sleep(wait, signal);
292
- continue;
315
+ if (deadline === null || this.now() + wait < deadline) {
316
+ retries += 1;
317
+ await this.sleep(wait, signal);
318
+ continue;
319
+ }
320
+ throw deadlineError(attempt.error, true);
293
321
  }
294
322
  }
323
+ if (shortened && attempt.error instanceof APITimeoutError) {
324
+ throw deadlineError(attempt.error, false);
325
+ }
295
326
  throw attempt.error;
296
327
  }
297
328
  }
@@ -1,9 +1,21 @@
1
1
  /** Base class of every error the SDK throws. */
2
2
  export declare class NenaviError extends Error {
3
+ /**
4
+ * `value instanceof` this class, also for an error from another copy of the package.
5
+ * Installed below with `Object.defineProperty`.
6
+ */
7
+ static [Symbol.hasInstance]: <T>(this: abstract new (...args: never[]) => T, value: unknown) => value is T;
3
8
  /** The `Idempotency-Key` the call sent, or null when it sent none. */
4
9
  idempotencyKey: string | null;
10
+ /**
11
+ * The last status a helper read before it threw: on `WaitTimeoutError`, and on the
12
+ * error of a poll that failed inside `reingest.run()`, where it holds the run's
13
+ * `run_id`. Null on other errors.
14
+ */
15
+ lastStatus: unknown;
5
16
  constructor(message: string, options?: {
6
17
  idempotencyKey?: string | null;
18
+ lastStatus?: unknown;
7
19
  });
8
20
  }
9
21
  /** No key for an operation that needs one, or an invalid option. */
@@ -11,12 +23,6 @@ export declare class ConfigurationError extends NenaviError {
11
23
  }
12
24
  /** A helper ran out of time before the work it waited for finished. */
13
25
  export declare class WaitTimeoutError extends NenaviError {
14
- /** The last status the helper read. */
15
- lastStatus: unknown;
16
- constructor(message: string, options?: {
17
- lastStatus?: unknown;
18
- idempotencyKey?: string | null;
19
- });
20
26
  }
21
27
  export interface ResponseValidationErrorOptions {
22
28
  status: number;
@@ -3,26 +3,73 @@
3
3
  // An error holds plain values only: the status, a copy of the response headers and the
4
4
  // parsed body. It never holds a Request, a Response or the error fetch threw, so the
5
5
  // `Authorization` header cannot reach a message, `util.inspect` or `JSON.stringify`.
6
+ //
7
+ // The package ships an ES module build and a CommonJS build, and a process that loads
8
+ // both holds two copies of every class here. So that `instanceof` still works across the
9
+ // copies, each class's prototype carries its name under a key in the global symbol
10
+ // registry, and `instanceof` falls back to matching that name along the prototype chain.
11
+ /**
12
+ * The key, shared by every copy of the package, under which a prototype names its class.
13
+ *
14
+ * Copies of different versions match each other by this key and the class name alone, so
15
+ * the key is a contract between versions. A release that changes an error class's fields
16
+ * incompatibly must change the key (for example to `nenavi.error.class.v2`), so that its
17
+ * errors stop matching older copies' classes instead of narrowing to the old shape.
18
+ */
19
+ const CLASS_NAME = Symbol.for('nenavi.error.class');
6
20
  /** Base class of every error the SDK throws. */
7
21
  export class NenaviError extends Error {
8
22
  /** The `Idempotency-Key` the call sent, or null when it sent none. */
9
23
  idempotencyKey;
24
+ /**
25
+ * The last status a helper read before it threw: on `WaitTimeoutError`, and on the
26
+ * error of a poll that failed inside `reingest.run()`, where it holds the run's
27
+ * `run_id`. Null on other errors.
28
+ */
29
+ lastStatus;
10
30
  constructor(message, options = {}) {
11
31
  super(message);
12
32
  this.idempotencyKey = options.idempotencyKey ?? null;
33
+ this.lastStatus = options.lastStatus ?? null;
13
34
  }
14
35
  }
36
+ // The ordinary check comes first. Failing that, a class of this package matches a value
37
+ // whose prototype chain holds a prototype marked with the same class name, as the other
38
+ // copy's classes are. A subclass written outside the package keeps the ordinary check.
39
+ //
40
+ // This is defined as a property rather than written as a static method, because a loose
41
+ // class transform in a consumer's build turns a static method into an assignment, and
42
+ // assigning over the read-only `Function.prototype[Symbol.hasInstance]` throws.
43
+ Object.defineProperty(NenaviError, Symbol.hasInstance, {
44
+ value: function hasInstance(value) {
45
+ if (Function.prototype[Symbol.hasInstance].call(this, value))
46
+ return true;
47
+ if ((typeof value !== 'object' && typeof value !== 'function') || value === null) {
48
+ return false;
49
+ }
50
+ const prototype = this.prototype;
51
+ if (typeof prototype !== 'object' || prototype === null)
52
+ return false;
53
+ if (!Object.hasOwn(prototype, CLASS_NAME))
54
+ return false;
55
+ const name = prototype[CLASS_NAME];
56
+ for (let link = Object.getPrototypeOf(value); link !== null; link = Object.getPrototypeOf(link)) {
57
+ if (Object.hasOwn(link, CLASS_NAME) &&
58
+ link[CLASS_NAME] === name) {
59
+ return true;
60
+ }
61
+ }
62
+ return false;
63
+ },
64
+ writable: true,
65
+ enumerable: false,
66
+ configurable: true,
67
+ });
15
68
  /** No key for an operation that needs one, or an invalid option. */
16
69
  export class ConfigurationError extends NenaviError {
17
70
  }
18
71
  /** A helper ran out of time before the work it waited for finished. */
19
72
  export class WaitTimeoutError extends NenaviError {
20
- /** The last status the helper read. */
21
- lastStatus;
22
- constructor(message, options = {}) {
23
- super(message, options);
24
- this.lastStatus = options.lastStatus ?? null;
25
- }
26
73
  }
27
74
  /**
28
75
  * A 2xx response whose body does not match the shape the operation answers with.
@@ -117,7 +164,8 @@ export class InternalServerError extends APIError {
117
164
  export class ServiceUnavailableError extends APIError {
118
165
  }
119
166
  // Each class's name, written out so that `error.name` survives a minifier that renames
120
- // classes. It sits on the prototype, so it is not an own property of an error.
167
+ // classes. It sits on the prototype, so it is not an own property of an error. The same
168
+ // name marks the prototype under CLASS_NAME for the cross-copy `instanceof` above.
121
169
  const NAMES = [
122
170
  [NenaviError, 'NenaviError'],
123
171
  [ConfigurationError, 'ConfigurationError'],
@@ -145,6 +193,12 @@ for (const [ErrorClass, name] of NAMES) {
145
193
  enumerable: false,
146
194
  configurable: true,
147
195
  });
196
+ Object.defineProperty(ErrorClass.prototype, CLASS_NAME, {
197
+ value: name,
198
+ writable: false,
199
+ enumerable: false,
200
+ configurable: false,
201
+ });
148
202
  }
149
203
  const BY_STATUS = {
150
204
  400: BadRequestError,
@@ -1,2 +1,2 @@
1
1
  /** The SDK's version, sent in the `User-Agent` header. test/client.test.ts keeps it equal to package.json. */
2
- export declare const VERSION = "0.1.0-alpha.1";
2
+ export declare const VERSION = "0.1.0-alpha.2";
@@ -1,2 +1,2 @@
1
1
  /** The SDK's version, sent in the `User-Agent` header. test/client.test.ts keeps it equal to package.json. */
2
- export const VERSION = '0.1.0-alpha.1';
2
+ export const VERSION = '0.1.0-alpha.2';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nenavi",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0-alpha.2",
4
4
  "description": "TypeScript client for the Nenavi Cloud memory API",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://nenavi.com",