@miso.ai/client-sdk-core 1.14.0-beta.2 → 1.14.0-beta.4

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.
@@ -42,7 +42,11 @@ class Ask extends _base.default {
42
42
  }
43
43
 
44
44
  /**
45
- * Retrieve multiple question-answer pairs at once.
45
+ * Retrieve multiple question-answer pairs at once: an iterable that polls
46
+ * the answers endpoint, like questions(). The id set is mutable between
47
+ * polls: `question_ids` may be a function returning the id array, resolved
48
+ * for every poll, and a fixed initial array settles out record by record
49
+ * (see Answers).
46
50
  */
47
51
  async answers(payload, options = {}) {
48
52
  if (Array.isArray(payload)) {
@@ -50,7 +54,9 @@ class Ask extends _base.default {
50
54
  question_ids: payload
51
55
  };
52
56
  }
53
- return this._run(NAME.ANSWERS, payload, options);
57
+ return new Answers(this, payload, Object.assign({
58
+ immediate: true
59
+ }, options));
54
60
  }
55
61
  async relatedQuestions(payload, options = {}) {
56
62
  return this._run(NAME.RELATED_QUESTIONS, payload, options);
@@ -95,6 +101,76 @@ class Answer extends _iterable.IdBasedIterableApiStub {
95
101
  return this._id;
96
102
  }
97
103
  }
104
+
105
+ /**
106
+ * The answer contents of a set of questions, polled until settled — the id
107
+ * set is mutable between polls, and the stream ends when it runs dry. The
108
+ * question ids may be given as a function returning the id array, resolved
109
+ * for every poll (settled ids drop out, new ones join, at the provider's
110
+ * discretion); a fixed initial id array is mutable all the same — its ids
111
+ * settle out as their records come back finished (_settle). Each fetch's
112
+ * issue order acts as the response revision, so a late, outdated response
113
+ * is discarded by the polling.
114
+ */
115
+ class Answers extends _iterable.IterableApiStub {
116
+ constructor(api, payload, options = {}) {
117
+ super(mergeCustomApiIterator(api, options));
118
+ this._api = api;
119
+ this._payload = payload;
120
+ const {
121
+ question_ids
122
+ } = payload;
123
+ this._pending = typeof question_ids === 'function' ? undefined : new Set(question_ids || []);
124
+ }
125
+ async _get(options) {
126
+ // a plain one-shot read of the current ids' answers
127
+ return this._api._run(NAME.ANSWERS, Object.assign({}, this._payload, {
128
+ question_ids: this._questionIds()
129
+ }), options);
130
+ }
131
+ _questionIds() {
132
+ return this._pending ? [...this._pending] : this._payload.question_ids();
133
+ }
134
+
135
+ // a fixed id set settles by the records themselves: a record that came
136
+ // back with its finished answer needs no further polling
137
+ _settle(response) {
138
+ if (!this._pending || !Array.isArray(response)) {
139
+ return;
140
+ }
141
+ for (const record of response) {
142
+ if (record && record.answer !== undefined && record.finished !== false) {
143
+ this._pending.delete(record.question_id);
144
+ }
145
+ }
146
+ }
147
+ _fetch() {
148
+ let index = 0;
149
+ return async ({
150
+ signal
151
+ } = {}) => {
152
+ const i = ++index; // taken at issue time: a stale response resolves with a lower revision
153
+ const question_ids = this._questionIds();
154
+ if (!question_ids || !question_ids.length) {
155
+ return [undefined, true, i]; // nothing left to fetch; the stream ends
156
+ }
157
+ // TODO: use single answer endpoint when array length = 1
158
+ const response = await this._api._run(NAME.ANSWERS, Object.assign({}, this._payload, {
159
+ question_ids
160
+ }), {
161
+ signal
162
+ });
163
+ this._settle(response);
164
+ return [response, false, i];
165
+ };
166
+ }
167
+
168
+ // any response is progress for the stall timeout: the record-diff test of
169
+ // the base class reads a single answer's shape, not an array of records
170
+ _isUpdated() {
171
+ return true;
172
+ }
173
+ }
98
174
  class SearchResult extends _iterable.IdBasedIterableApiStub {
99
175
  constructor(api, response, options = {}) {
100
176
  super(api, '_searchGet', response.question_id, mergeCustomApiIterator(api, options));
@@ -16,6 +16,18 @@ class ApiBase extends _commons.Component {
16
16
  this.context = api._client.context;
17
17
  }
18
18
  async _run(apiName, payload, options = {}) {
19
+ const apiGroup = this._apiPath;
20
+ return this.helpers.applyMiddlewares({
21
+ apiGroup,
22
+ apiName,
23
+ payload,
24
+ options
25
+ }, () => this._request(apiName, payload, options));
26
+ }
27
+
28
+ // one round of the request: the passes run per round, so a round run again
29
+ // by a middleware picks up the context as of then (e.g. a renewed token)
30
+ async _request(apiName, payload, options) {
19
31
  const {
20
32
  bulk
21
33
  } = options;
@@ -6,7 +6,8 @@ Object.defineProperty(exports, "__esModule", {
6
6
  exports.default = void 0;
7
7
  var _commons = require("@miso.ai/commons");
8
8
  var _constants = require("../constants.js");
9
- const _excluded = ["method", "headers", "timeout"];
9
+ const _excluded = ["method", "headers", "timeout", "retry"],
10
+ _excluded2 = ["method", "headers", "timeout", "body"];
10
11
  function _objectWithoutPropertiesLoose(r, e) { if (null == r) return {}; var t = {}; for (var n in r) if ({}.hasOwnProperty.call(r, n)) { if (-1 !== e.indexOf(n)) continue; t[n] = r[n]; } return t; }
11
12
  class ApiHelpers {
12
13
  constructor(client, root) {
@@ -18,18 +19,50 @@ class ApiHelpers {
18
19
  let {
19
20
  method = 'POST',
20
21
  headers,
21
- timeout
22
+ timeout,
23
+ retry
22
24
  } = _ref,
23
25
  options = _objectWithoutPropertiesLoose(_ref, _excluded);
24
26
  const {
25
- apiKey,
26
27
  request = {}
27
28
  } = this._client.options;
28
29
  timeout = timeout || request.timeout;
30
+ // retries on top of the first attempt, for failures that may be
31
+ // transient — network errors, timeouts, and server (5xx) errors;
32
+ // client (4xx) and application errors are deterministic, not retried.
33
+ // Off by default: a retried POST is not safe for non-idempotent calls
34
+ // (e.g. posting a question), so it is opted in per request via the
35
+ // request options, or client-wide via `options.request.retry`
36
+ retry = retry !== undefined ? retry : request.retry || 0;
29
37
 
30
38
  // TODO: organize arguments
31
39
  const body = method !== 'GET' && payload != undefined ? JSON.stringify(payload) : undefined;
40
+ for (let attempt = 0;; attempt++) {
41
+ try {
42
+ return await this._fetch(url, Object.assign({
43
+ method,
44
+ headers,
45
+ timeout,
46
+ body
47
+ }, options));
48
+ } catch (error) {
49
+ if (attempt >= retry || !isRetryableFetchError(error)) {
50
+ throw error;
51
+ }
52
+ }
53
+ }
54
+ }
32
55
 
56
+ // one attempt; the timeout signal is created per attempt, so a retried
57
+ // attempt gets a full window of its own
58
+ async _fetch(url, _ref2) {
59
+ let {
60
+ method,
61
+ headers,
62
+ timeout,
63
+ body
64
+ } = _ref2,
65
+ options = _objectWithoutPropertiesLoose(_ref2, _excluded2);
33
66
  // TODO: merge signal from options if any
34
67
  const signal = timeout ? AbortSignal.timeout(timeout) : undefined;
35
68
  const res = await (this._root._customFetch || fetch)(url, (0, _commons.trimObj)(Object.assign({
@@ -131,6 +164,27 @@ class ApiHelpers {
131
164
  } = this._client.options;
132
165
  return apiName === 'interactions' ? eventEndpoint : dataEndpoint;
133
166
  }
167
+
168
+ // middlewares wrap the whole request — passes, send, and postprocess —
169
+ // in registration order, the first being the outermost. Each is called
170
+ // with the request info and a `next` function that runs the rest of the
171
+ // chain, which it may defer, catch the failure of, or call again
172
+ async applyMiddlewares({
173
+ apiGroup,
174
+ apiName,
175
+ payload,
176
+ options
177
+ }, run) {
178
+ const client = this._client;
179
+ const request = Object.freeze({
180
+ client,
181
+ apiGroup,
182
+ apiName,
183
+ payload,
184
+ options
185
+ });
186
+ return this._root._apiMiddlewares.reduceRight((next, middleware) => () => middleware(request, next), run)();
187
+ }
134
188
  applyUrlPasses(component, {
135
189
  apiGroup,
136
190
  apiName,
@@ -196,4 +250,11 @@ class ApiHelpers {
196
250
  return headers;
197
251
  }
198
252
  }
199
- exports.default = ApiHelpers;
253
+
254
+ // no status: a network failure, a timeout, or an unparsable body — all
255
+ // transport-level; 5xx: the server's own failure. A 4xx or an
256
+ // application-level error (2xx with `errors`) is deterministic
257
+ exports.default = ApiHelpers;
258
+ function isRetryableFetchError(error) {
259
+ return error.status === undefined || error.status >= 500;
260
+ }
@@ -14,13 +14,13 @@ class UserHistory extends _base.default {
14
14
  constructor(api) {
15
15
  super(api, GROUP.ASK_USER_HISTORY);
16
16
  }
17
- async getThreads(options) {
18
- return this._run('threads', undefined, Object.assign({}, options, {
17
+ async getThreads(payload, options) {
18
+ return this._run('threads', payload, Object.assign({}, options, {
19
19
  method: 'GET'
20
20
  }));
21
21
  }
22
- async getThread(threadId, options) {
23
- return this._run(`threads/${threadId}`, undefined, Object.assign({}, options, {
22
+ async getThread(threadId, payload, options) {
23
+ return this._run(`threads/${threadId}`, payload, Object.assign({}, options, {
24
24
  method: 'GET'
25
25
  }));
26
26
  }
@@ -22,13 +22,7 @@ class IterableApiStub {
22
22
  this._ac.abort(reason);
23
23
  }
24
24
  [Symbol.asyncIterator]() {
25
- const fetch = async ({
26
- signal
27
- } = {}) => {
28
- // TODO: pass signal
29
- const response = await this.get();
30
- return [response, isFinished(response), revisionOf(response)];
31
- };
25
+ const fetch = this._fetch();
32
26
  if (typeof this._options.customIterator === 'function') {
33
27
  return this._options.customIterator.call(this, fetch);
34
28
  }
@@ -54,7 +48,7 @@ class IterableApiStub {
54
48
  const onResponse = (response, finished) => {
55
49
  if (finished) {
56
50
  stac.clear();
57
- } else if (isUpdated(prevResponse, response)) {
51
+ } else if (this._isUpdated(prevResponse, response)) {
58
52
  stac.touch();
59
53
  }
60
54
  prevResponse = response;
@@ -65,6 +59,27 @@ class IterableApiStub {
65
59
  onResponse
66
60
  }, options))[Symbol.asyncIterator]();
67
61
  }
62
+
63
+ /**
64
+ * Compose the polling fetch: () => [response, finished, revision].
65
+ * Overridable by a subclass with its own finish or revision semantics
66
+ * (e.g. Answers, whose question ids may run dry).
67
+ */
68
+ _fetch() {
69
+ return async ({
70
+ signal
71
+ } = {}) => {
72
+ // TODO: pass signal
73
+ const response = await this.get();
74
+ return [response, isFinished(response), revisionOf(response)];
75
+ };
76
+ }
77
+
78
+ // whether the new response carries progress, keeping the stall timeout at
79
+ // bay; overridable by a subclass with its own response shape
80
+ _isUpdated(previousResponse, newResponse) {
81
+ return isUpdated(previousResponse, newResponse);
82
+ }
68
83
  }
69
84
  exports.IterableApiStub = IterableApiStub;
70
85
  class IdBasedIterableApiStub extends IterableApiStub {
@@ -7,7 +7,8 @@ exports.default = void 0;
7
7
  var api = _interopRequireWildcard(require("./api/index.js"));
8
8
  var _context = _interopRequireDefault(require("./context.js"));
9
9
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
10
- function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
10
+ var cache;
11
+ function _interopRequireWildcard(e, t) { if (!t && e && e.__esModule) return e; var r, c, a = Object.defineProperty, n = { __proto__: null, default: e }; if (Object(e) !== e) return n; if (cache || "function" != typeof WeakMap || (cache = new WeakMap()), cache) { if (cache.has(e)) return cache.get(e); cache.set(e, n); } for (c in e) "default" !== c && {}.hasOwnProperty.call(e, c) && ((r = a && Object.getOwnPropertyDescriptor(e, c)) && (r.get || r.set) ? a(n, c, r) : n[c] = e[c]); return n; }
11
12
  var _default = exports.default = {
12
13
  api,
13
14
  context: {
@@ -9,7 +9,8 @@ var _root = require("./root.js");
9
9
  var _index = require("./api/index.js");
10
10
  var _context = _interopRequireDefault(require("./context.js"));
11
11
  var helpers = _interopRequireWildcard(require("./utils.js"));
12
- function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
12
+ var cache;
13
+ function _interopRequireWildcard(e, t) { if (!t && e && e.__esModule) return e; var r, c, a = Object.defineProperty, n = { __proto__: null, default: e }; if (Object(e) !== e) return n; if (cache || "function" != typeof WeakMap || (cache = new WeakMap()), cache) { if (cache.has(e)) return cache.get(e); cache.set(e, n); } for (c in e) "default" !== c && {}.hasOwnProperty.call(e, c) && ((r = a && Object.getOwnPropertyDescriptor(e, c)) && (r.get || r.set) ? a(n, c, r) : n[c] = e[c]); return n; }
13
14
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
14
15
  const currentScript = _commons.isInBrowser ? document.currentScript : undefined;
15
16
  class MisoClient extends _commons.Component {
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = void 0;
7
+ var _commons = require("@miso.ai/commons");
8
+ const _excluded = ["client"];
9
+ function _objectWithoutPropertiesLoose(r, e) { if (null == r) return {}; var t = {}; for (var n in r) if ({}.hasOwnProperty.call(r, n)) { if (-1 !== e.indexOf(n)) continue; t[n] = r[n]; } return t; }
10
+ const PLUGIN_ID = 'std:api-recovery';
11
+
12
+ // a safety net against a callback that resends unconditionally
13
+ const MAX_RESENT = 10;
14
+
15
+ /**
16
+ * Lets the application intercept failed API requests, per client:
17
+ *
18
+ * ```js
19
+ * client.api.onError = async (error, { request, hold, resend }) => { ... };
20
+ * ```
21
+ *
22
+ * The request settles with the outcome of the callback: the error it throws,
23
+ * the value it returns (taken as the API result), or — by returning
24
+ * `resend()` — the outcome of sending the request again.
25
+ *
26
+ * A resent request that fails is intercepted like any other, with
27
+ * `error.resent` telling how many times the request has been resent so far
28
+ * (0 for the original failure), so the callback can give up at some count.
29
+ * As a safety net, a request resent 10 times fails for good, its error
30
+ * thrown without reaching the callback.
31
+ *
32
+ * `hold()` holds the requests issued from then on (resent ones included)
33
+ * until the unhold function it returns is called. Requests already in flight
34
+ * are not affected, so several errors may reach the callback before a hold
35
+ * takes effect: managing them in one go (e.g. sharing a single token renewal)
36
+ * is up to the application.
37
+ */
38
+ class ApiRecoveryPlugin {
39
+ static get id() {
40
+ return PLUGIN_ID;
41
+ }
42
+ constructor() {
43
+ this._recoveries = new WeakMap();
44
+ }
45
+ install(MisoClient, context) {
46
+ context.addApiMiddleware(this._middleware.bind(this));
47
+ MisoClient.on('create', this._injectClient.bind(this));
48
+ }
49
+ _injectClient(client) {
50
+ const recovery = new ApiRecovery();
51
+ this._recoveries.set(client, recovery);
52
+ (0, _commons.delegateProperties)(client.api, recovery, ['onError']);
53
+ }
54
+ async _middleware(_ref, next) {
55
+ let {
56
+ client
57
+ } = _ref,
58
+ request = _objectWithoutPropertiesLoose(_ref, _excluded);
59
+ const recovery = this._recoveries.get(client);
60
+ return recovery ? recovery.run(request, next) : next();
61
+ }
62
+ }
63
+ exports.default = ApiRecoveryPlugin;
64
+ class ApiRecovery {
65
+ constructor() {
66
+ this._callback = undefined;
67
+ this._holds = new Set();
68
+ this._gate = undefined;
69
+ }
70
+ get onError() {
71
+ return this._callback;
72
+ }
73
+
74
+ // a single callback: setting it replaces the current one, and setting it
75
+ // to undefined removes it
76
+ set onError(callback) {
77
+ if (callback != undefined && typeof callback !== 'function') {
78
+ throw new Error(`Expect onError to be a function: ${callback}`);
79
+ }
80
+ this._callback = callback || undefined;
81
+ }
82
+ async run(request, next, resent = 0) {
83
+ try {
84
+ return await this._send(next);
85
+ } catch (error) {
86
+ const callback = this._callback;
87
+ if (!callback || resent >= MAX_RESENT) {
88
+ throw error;
89
+ }
90
+ if (error && typeof error === 'object') {
91
+ error.resent = resent;
92
+ }
93
+ return callback(error, Object.freeze({
94
+ request,
95
+ hold: () => this._hold(),
96
+ resend: () => this.run(request, next, resent + 1)
97
+ }));
98
+ }
99
+ }
100
+ async _send(next) {
101
+ // loop: a new hold may be placed by the time the gate opens
102
+ while (this._gate) {
103
+ await this._gate.promise;
104
+ }
105
+ return next();
106
+ }
107
+ _hold() {
108
+ const hold = {};
109
+ this._holds.add(hold);
110
+ if (!this._gate) {
111
+ this._gate = new _commons.Resolution();
112
+ }
113
+ // holds overlap: the gate opens when the last one is released
114
+ return () => {
115
+ if (!this._holds.delete(hold) || this._holds.size) {
116
+ return;
117
+ }
118
+ const gate = this._gate;
119
+ this._gate = undefined;
120
+ gate.resolve();
121
+ };
122
+ }
123
+ }
@@ -15,6 +15,12 @@ Object.defineProperty(exports, "ApiPatchPlugin", {
15
15
  return _apiPatch.default;
16
16
  }
17
17
  });
18
+ Object.defineProperty(exports, "ApiRecoveryPlugin", {
19
+ enumerable: true,
20
+ get: function () {
21
+ return _apiRecovery.default;
22
+ }
23
+ });
18
24
  Object.defineProperty(exports, "AutoEventsPlugin", {
19
25
  enumerable: true,
20
26
  get: function () {
@@ -57,6 +63,7 @@ var _autoEvents = _interopRequireDefault(require("./auto-events.js"));
57
63
  var _interactions = _interopRequireDefault(require("./interactions.js"));
58
64
  var _nativeFetch = _interopRequireDefault(require("./native-fetch.js"));
59
65
  var _apiPatch = _interopRequireDefault(require("./api-patch.js"));
66
+ var _apiRecovery = _interopRequireDefault(require("./api-recovery.js"));
60
67
  var _headerApiKey = _interopRequireDefault(require("./header-api-key.js"));
61
68
  var _analytics = _interopRequireDefault(require("./analytics.js"));
62
69
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
@@ -106,6 +106,12 @@ class PluginRoot extends _commons.Registry {
106
106
  }
107
107
  this._root._urlPasses.push(pass);
108
108
  }
109
+ addApiMiddleware(middleware) {
110
+ if (typeof middleware !== 'function') {
111
+ throw new Error(`Expect parameter to be a function: ${middleware}`);
112
+ }
113
+ this._root._apiMiddlewares.push(middleware);
114
+ }
109
115
  setCustomFetch(fetch) {
110
116
  if (typeof fetch !== 'function') {
111
117
  throw new Error(`Expect parameter to be a function: ${fetch}`);
@@ -253,7 +259,7 @@ const PLUGIN_QUERY_METHODS = ['installed', 'registered', 'isInstalled', 'isRegis
253
259
  */
254
260
  class PluginContext {
255
261
  constructor(root, classes) {
256
- (0, _commons.delegateGetters)(this, root, [...PLUGIN_QUERY_METHODS, 'getPluginClass', 'addSubtree', 'addExtensionPoints', 'addPayloadPass', 'addHeadersPass', 'addUrlPass', 'setCustomFetch', 'setCustomSendBeacon', 'setCustomApiIterator', 'onHubUpdate', 'onHubEmit']);
262
+ (0, _commons.delegateGetters)(this, root, [...PLUGIN_QUERY_METHODS, 'getPluginClass', 'addSubtree', 'addExtensionPoints', 'addPayloadPass', 'addHeadersPass', 'addUrlPass', 'addApiMiddleware', 'setCustomFetch', 'setCustomSendBeacon', 'setCustomApiIterator', 'onHubUpdate', 'onHubEmit']);
257
263
  (0, _commons.defineValues)(this, {
258
264
  classes
259
265
  });
package/dist/cjs/root.js CHANGED
@@ -23,6 +23,7 @@ class Root extends _commons.Component {
23
23
  this._payloadPasses = [];
24
24
  this._headersPasses = [];
25
25
  this._urlPasses = [];
26
+ this._apiMiddlewares = [];
26
27
  this._customFetch = undefined;
27
28
  this._customSendBeacon = undefined;
28
29
  this._hubUpdateCallbacks = [];
@@ -4,4 +4,4 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.default = void 0;
7
- var _default = exports.default = '1.14.0-beta.2';
7
+ var _default = exports.default = '1.14.0-beta.4';
package/package.json CHANGED
@@ -30,7 +30,7 @@
30
30
  "url": "https://github.com/MisoAI/miso-client-js-sdk/issues"
31
31
  },
32
32
  "dependencies": {
33
- "@miso.ai/commons": "1.14.0-beta.2"
33
+ "@miso.ai/commons": "1.14.0-beta.4"
34
34
  },
35
- "version": "1.14.0-beta.2"
35
+ "version": "1.14.0-beta.4"
36
36
  }
package/src/api/ask.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { API, defineValues, trimObj } from '@miso.ai/commons';
2
2
  import ApiBase from './base.js';
3
3
  import UserHistoryV0 from './history.0.js';
4
- import { IdBasedIterableApiStub } from './iterable.js';
4
+ import { IterableApiStub, IdBasedIterableApiStub } from './iterable.js';
5
5
 
6
6
  const { GROUP, NAME } = API;
7
7
 
@@ -34,13 +34,17 @@ export default class Ask extends ApiBase {
34
34
  }
35
35
 
36
36
  /**
37
- * Retrieve multiple question-answer pairs at once.
37
+ * Retrieve multiple question-answer pairs at once: an iterable that polls
38
+ * the answers endpoint, like questions(). The id set is mutable between
39
+ * polls: `question_ids` may be a function returning the id array, resolved
40
+ * for every poll, and a fixed initial array settles out record by record
41
+ * (see Answers).
38
42
  */
39
43
  async answers(payload, options = {}) {
40
44
  if (Array.isArray(payload)) {
41
45
  payload = { question_ids: payload };
42
46
  }
43
- return this._run(NAME.ANSWERS, payload, options);
47
+ return new Answers(this, payload, { immediate: true, ...options });
44
48
  }
45
49
 
46
50
  async relatedQuestions(payload, options = {}) {
@@ -93,6 +97,71 @@ class Answer extends IdBasedIterableApiStub {
93
97
 
94
98
  }
95
99
 
100
+ /**
101
+ * The answer contents of a set of questions, polled until settled — the id
102
+ * set is mutable between polls, and the stream ends when it runs dry. The
103
+ * question ids may be given as a function returning the id array, resolved
104
+ * for every poll (settled ids drop out, new ones join, at the provider's
105
+ * discretion); a fixed initial id array is mutable all the same — its ids
106
+ * settle out as their records come back finished (_settle). Each fetch's
107
+ * issue order acts as the response revision, so a late, outdated response
108
+ * is discarded by the polling.
109
+ */
110
+ class Answers extends IterableApiStub {
111
+
112
+ constructor(api, payload, options = {}) {
113
+ super(mergeCustomApiIterator(api, options));
114
+ this._api = api;
115
+ this._payload = payload;
116
+ const { question_ids } = payload;
117
+ this._pending = typeof question_ids === 'function' ? undefined : new Set(question_ids || []);
118
+ }
119
+
120
+ async _get(options) {
121
+ // a plain one-shot read of the current ids' answers
122
+ return this._api._run(NAME.ANSWERS, { ...this._payload, question_ids: this._questionIds() }, options);
123
+ }
124
+
125
+ _questionIds() {
126
+ return this._pending ? [...this._pending] : this._payload.question_ids();
127
+ }
128
+
129
+ // a fixed id set settles by the records themselves: a record that came
130
+ // back with its finished answer needs no further polling
131
+ _settle(response) {
132
+ if (!this._pending || !Array.isArray(response)) {
133
+ return;
134
+ }
135
+ for (const record of response) {
136
+ if (record && record.answer !== undefined && record.finished !== false) {
137
+ this._pending.delete(record.question_id);
138
+ }
139
+ }
140
+ }
141
+
142
+ _fetch() {
143
+ let index = 0;
144
+ return async ({ signal } = {}) => {
145
+ const i = ++index; // taken at issue time: a stale response resolves with a lower revision
146
+ const question_ids = this._questionIds();
147
+ if (!question_ids || !question_ids.length) {
148
+ return [undefined, true, i]; // nothing left to fetch; the stream ends
149
+ }
150
+ // TODO: use single answer endpoint when array length = 1
151
+ const response = await this._api._run(NAME.ANSWERS, { ...this._payload, question_ids }, { signal });
152
+ this._settle(response);
153
+ return [response, false, i];
154
+ };
155
+ }
156
+
157
+ // any response is progress for the stall timeout: the record-diff test of
158
+ // the base class reads a single answer's shape, not an array of records
159
+ _isUpdated() {
160
+ return true;
161
+ }
162
+
163
+ }
164
+
96
165
  class SearchResult extends IdBasedIterableApiStub {
97
166
 
98
167
  constructor(api, response, options = {}) {
package/src/api/base.js CHANGED
@@ -11,6 +11,13 @@ export default class ApiBase extends Component {
11
11
  }
12
12
 
13
13
  async _run(apiName, payload, options = {}) {
14
+ const apiGroup = this._apiPath;
15
+ return this.helpers.applyMiddlewares({ apiGroup, apiName, payload, options }, () => this._request(apiName, payload, options));
16
+ }
17
+
18
+ // one round of the request: the passes run per round, so a round run again
19
+ // by a middleware picks up the context as of then (e.g. a renewed token)
20
+ async _request(apiName, payload, options) {
14
21
  const { bulk } = options;
15
22
  const bulkInfo = bulk ? { bulk: this.helpers.bulkInfo } : undefined;
16
23
  const apiGroup = this._apiPath;
@@ -13,14 +13,36 @@ export default class ApiHelpers {
13
13
  method = 'POST',
14
14
  headers,
15
15
  timeout,
16
+ retry,
16
17
  ...options
17
18
  } = {}) {
18
- const { apiKey, request = {} } = this._client.options;
19
+ const { request = {} } = this._client.options;
19
20
  timeout = timeout || request.timeout;
21
+ // retries on top of the first attempt, for failures that may be
22
+ // transient — network errors, timeouts, and server (5xx) errors;
23
+ // client (4xx) and application errors are deterministic, not retried.
24
+ // Off by default: a retried POST is not safe for non-idempotent calls
25
+ // (e.g. posting a question), so it is opted in per request via the
26
+ // request options, or client-wide via `options.request.retry`
27
+ retry = retry !== undefined ? retry : (request.retry || 0);
20
28
 
21
29
  // TODO: organize arguments
22
30
  const body = method !== 'GET' && payload != undefined ? JSON.stringify(payload) : undefined;
23
31
 
32
+ for (let attempt = 0; ; attempt++) {
33
+ try {
34
+ return await this._fetch(url, { method, headers, timeout, body, ...options });
35
+ } catch (error) {
36
+ if (attempt >= retry || !isRetryableFetchError(error)) {
37
+ throw error;
38
+ }
39
+ }
40
+ }
41
+ }
42
+
43
+ // one attempt; the timeout signal is created per attempt, so a retried
44
+ // attempt gets a full window of its own
45
+ async _fetch(url, { method, headers, timeout, body, ...options }) {
24
46
  // TODO: merge signal from options if any
25
47
  const signal = timeout ? AbortSignal.timeout(timeout) : undefined;
26
48
 
@@ -106,6 +128,16 @@ export default class ApiHelpers {
106
128
  return apiName === 'interactions' ? eventEndpoint : dataEndpoint;
107
129
  }
108
130
 
131
+ // middlewares wrap the whole request — passes, send, and postprocess —
132
+ // in registration order, the first being the outermost. Each is called
133
+ // with the request info and a `next` function that runs the rest of the
134
+ // chain, which it may defer, catch the failure of, or call again
135
+ async applyMiddlewares({ apiGroup, apiName, payload, options }, run) {
136
+ const client = this._client;
137
+ const request = Object.freeze({ client, apiGroup, apiName, payload, options });
138
+ return this._root._apiMiddlewares.reduceRight((next, middleware) => () => middleware(request, next), run)();
139
+ }
140
+
109
141
  applyUrlPasses(component, { apiGroup, apiName, url }) {
110
142
  const client = this._client;
111
143
  for (const pass of this._root._urlPasses) {
@@ -143,3 +175,10 @@ export default class ApiHelpers {
143
175
  }
144
176
 
145
177
  }
178
+
179
+ // no status: a network failure, a timeout, or an unparsable body — all
180
+ // transport-level; 5xx: the server's own failure. A 4xx or an
181
+ // application-level error (2xx with `errors`) is deterministic
182
+ function isRetryableFetchError(error) {
183
+ return error.status === undefined || error.status >= 500;
184
+ }
@@ -9,12 +9,12 @@ export default class UserHistory extends ApiBase {
9
9
  super(api, GROUP.ASK_USER_HISTORY);
10
10
  }
11
11
 
12
- async getThreads(options) {
13
- return this._run('threads', undefined, { ...options, method: 'GET' });
12
+ async getThreads(payload, options) {
13
+ return this._run('threads', payload, { ...options, method: 'GET' });
14
14
  }
15
15
 
16
- async getThread(threadId, options) {
17
- return this._run(`threads/${threadId}`, undefined, { ...options, method: 'GET' });
16
+ async getThread(threadId, payload, options) {
17
+ return this._run(`threads/${threadId}`, payload, { ...options, method: 'GET' });
18
18
  }
19
19
 
20
20
  async updateThread(threadId, payload, options) {
@@ -20,11 +20,7 @@ export class IterableApiStub {
20
20
  }
21
21
 
22
22
  [Symbol.asyncIterator]() {
23
- const fetch = async ({ signal } = {}) => {
24
- // TODO: pass signal
25
- const response = await this.get();
26
- return [response, isFinished(response), revisionOf(response)];
27
- };
23
+ const fetch = this._fetch();
28
24
 
29
25
  if (typeof this._options.customIterator === 'function') {
30
26
  return this._options.customIterator.call(this, fetch);
@@ -47,7 +43,7 @@ export class IterableApiStub {
47
43
  const onResponse = (response, finished) => {
48
44
  if (finished) {
49
45
  stac.clear();
50
- } else if (isUpdated(prevResponse, response)) {
46
+ } else if (this._isUpdated(prevResponse, response)) {
51
47
  stac.touch();
52
48
  }
53
49
  prevResponse = response;
@@ -55,6 +51,25 @@ export class IterableApiStub {
55
51
  return polling(fetch, { interval, signal, onResponse, ...options })[Symbol.asyncIterator]();
56
52
  }
57
53
 
54
+ /**
55
+ * Compose the polling fetch: () => [response, finished, revision].
56
+ * Overridable by a subclass with its own finish or revision semantics
57
+ * (e.g. Answers, whose question ids may run dry).
58
+ */
59
+ _fetch() {
60
+ return async ({ signal } = {}) => {
61
+ // TODO: pass signal
62
+ const response = await this.get();
63
+ return [response, isFinished(response), revisionOf(response)];
64
+ };
65
+ }
66
+
67
+ // whether the new response carries progress, keeping the stall timeout at
68
+ // bay; overridable by a subclass with its own response shape
69
+ _isUpdated(previousResponse, newResponse) {
70
+ return isUpdated(previousResponse, newResponse);
71
+ }
72
+
58
73
  }
59
74
 
60
75
  export class IdBasedIterableApiStub extends IterableApiStub {
@@ -20,6 +20,7 @@ Exposed to plugins during `install()`, provides hooks into the SDK:
20
20
  context.addPayloadPass(fn) // Modify API request payloads
21
21
  context.addHeadersPass(fn) // Modify request headers
22
22
  context.addUrlPass(fn) // Modify request URLs
23
+ context.addApiMiddleware(fn) // Wrap API requests: defer, catch failures, resend
23
24
  context.setCustomFetch(fn) // Override fetch implementation
24
25
  context.setCustomSendBeacon(fn) // Override sendBeacon
25
26
  context.addSubtree(component) // Register as component subtree for event propagation
@@ -77,6 +78,7 @@ export default class MyPlugin {
77
78
  | `InteractionsPlugin` | `std:interactions` | Enriches interaction records with SDK version/UUID |
78
79
  | `NativeFetchPlugin` | `std:native-fetch` | Uses native fetch, bypassing custom implementations |
79
80
  | `ApiPatchPlugin` | `std:api-patch` | Applies patches to API responses |
81
+ | `ApiRecoveryPlugin` | `std:api-recovery` | `client.api.onError`: intercept failed requests, hold and resend |
80
82
  | `HeaderApiKeyPlugin` | `std:header-api-key` | Sends API key in header instead of query |
81
83
  | `AnalyticsPlugin` | `std:analytics` | User engagement timing and analytics |
82
84
 
@@ -98,6 +100,22 @@ function modifyHeaders({ client, headers }) {
98
100
  }
99
101
  ```
100
102
 
103
+ ## API Middlewares
104
+
105
+ A middleware wraps a whole API request (passes, send, postprocess). It may defer the request, catch its failure, or run it again — each round re-applies the passes, so it picks up the context as of then:
106
+
107
+ ```javascript
108
+ // request: { client, apiGroup, apiName, payload, options }
109
+ async function middleware(request, next) {
110
+ try {
111
+ return await next();
112
+ } catch (error) {
113
+ // ...
114
+ return next();
115
+ }
116
+ }
117
+ ```
118
+
101
119
  ## Lifecycle
102
120
 
103
121
  1. `register()` - Plugin class stored by ID
@@ -0,0 +1,124 @@
1
+ import { Resolution, delegateProperties } from '@miso.ai/commons';
2
+
3
+ const PLUGIN_ID = 'std:api-recovery';
4
+
5
+ // a safety net against a callback that resends unconditionally
6
+ const MAX_RESENT = 10;
7
+
8
+ /**
9
+ * Lets the application intercept failed API requests, per client:
10
+ *
11
+ * ```js
12
+ * client.api.onError = async (error, { request, hold, resend }) => { ... };
13
+ * ```
14
+ *
15
+ * The request settles with the outcome of the callback: the error it throws,
16
+ * the value it returns (taken as the API result), or — by returning
17
+ * `resend()` — the outcome of sending the request again.
18
+ *
19
+ * A resent request that fails is intercepted like any other, with
20
+ * `error.resent` telling how many times the request has been resent so far
21
+ * (0 for the original failure), so the callback can give up at some count.
22
+ * As a safety net, a request resent 10 times fails for good, its error
23
+ * thrown without reaching the callback.
24
+ *
25
+ * `hold()` holds the requests issued from then on (resent ones included)
26
+ * until the unhold function it returns is called. Requests already in flight
27
+ * are not affected, so several errors may reach the callback before a hold
28
+ * takes effect: managing them in one go (e.g. sharing a single token renewal)
29
+ * is up to the application.
30
+ */
31
+ export default class ApiRecoveryPlugin {
32
+
33
+ static get id() {
34
+ return PLUGIN_ID;
35
+ }
36
+
37
+ constructor() {
38
+ this._recoveries = new WeakMap();
39
+ }
40
+
41
+ install(MisoClient, context) {
42
+ context.addApiMiddleware(this._middleware.bind(this));
43
+ MisoClient.on('create', this._injectClient.bind(this));
44
+ }
45
+
46
+ _injectClient(client) {
47
+ const recovery = new ApiRecovery();
48
+ this._recoveries.set(client, recovery);
49
+ delegateProperties(client.api, recovery, ['onError']);
50
+ }
51
+
52
+ async _middleware({ client, ...request }, next) {
53
+ const recovery = this._recoveries.get(client);
54
+ return recovery ? recovery.run(request, next) : next();
55
+ }
56
+
57
+ }
58
+
59
+ class ApiRecovery {
60
+
61
+ constructor() {
62
+ this._callback = undefined;
63
+ this._holds = new Set();
64
+ this._gate = undefined;
65
+ }
66
+
67
+ get onError() {
68
+ return this._callback;
69
+ }
70
+
71
+ // a single callback: setting it replaces the current one, and setting it
72
+ // to undefined removes it
73
+ set onError(callback) {
74
+ if (callback != undefined && typeof callback !== 'function') {
75
+ throw new Error(`Expect onError to be a function: ${callback}`);
76
+ }
77
+ this._callback = callback || undefined;
78
+ }
79
+
80
+ async run(request, next, resent = 0) {
81
+ try {
82
+ return await this._send(next);
83
+ } catch (error) {
84
+ const callback = this._callback;
85
+ if (!callback || resent >= MAX_RESENT) {
86
+ throw error;
87
+ }
88
+ if (error && typeof error === 'object') {
89
+ error.resent = resent;
90
+ }
91
+ return callback(error, Object.freeze({
92
+ request,
93
+ hold: () => this._hold(),
94
+ resend: () => this.run(request, next, resent + 1),
95
+ }));
96
+ }
97
+ }
98
+
99
+ async _send(next) {
100
+ // loop: a new hold may be placed by the time the gate opens
101
+ while (this._gate) {
102
+ await this._gate.promise;
103
+ }
104
+ return next();
105
+ }
106
+
107
+ _hold() {
108
+ const hold = {};
109
+ this._holds.add(hold);
110
+ if (!this._gate) {
111
+ this._gate = new Resolution();
112
+ }
113
+ // holds overlap: the gate opens when the last one is released
114
+ return () => {
115
+ if (!this._holds.delete(hold) || this._holds.size) {
116
+ return;
117
+ }
118
+ const gate = this._gate;
119
+ this._gate = undefined;
120
+ gate.resolve();
121
+ };
122
+ }
123
+
124
+ }
@@ -4,5 +4,6 @@ export { default as AutoEventsPlugin } from './auto-events.js';
4
4
  export { default as InteractionsPlugin } from './interactions.js';
5
5
  export { default as NativeFetchPlugin } from './native-fetch.js';
6
6
  export { default as ApiPatchPlugin } from './api-patch.js';
7
+ export { default as ApiRecoveryPlugin } from './api-recovery.js';
7
8
  export { default as HeaderApiKeyPlugin } from './header-api-key.js';
8
9
  export { default as AnalyticsPlugin } from './analytics.js';
@@ -111,6 +111,13 @@ export default class PluginRoot extends Registry {
111
111
  this._root._urlPasses.push(pass);
112
112
  }
113
113
 
114
+ addApiMiddleware(middleware) {
115
+ if (typeof middleware !== 'function') {
116
+ throw new Error(`Expect parameter to be a function: ${middleware}`);
117
+ }
118
+ this._root._apiMiddlewares.push(middleware);
119
+ }
120
+
114
121
  setCustomFetch(fetch) {
115
122
  if (typeof fetch !== 'function') {
116
123
  throw new Error(`Expect parameter to be a function: ${fetch}`);
@@ -276,6 +283,7 @@ class PluginContext {
276
283
  'addPayloadPass',
277
284
  'addHeadersPass',
278
285
  'addUrlPass',
286
+ 'addApiMiddleware',
279
287
  'setCustomFetch',
280
288
  'setCustomSendBeacon',
281
289
  'setCustomApiIterator',
package/src/root.js CHANGED
@@ -15,6 +15,7 @@ class Root extends Component {
15
15
  this._payloadPasses = [];
16
16
  this._headersPasses = [];
17
17
  this._urlPasses = [];
18
+ this._apiMiddlewares = [];
18
19
  this._customFetch = undefined;
19
20
  this._customSendBeacon = undefined;
20
21
  this._hubUpdateCallbacks = [];
package/src/version.js CHANGED
@@ -1 +1 @@
1
- export default '1.14.0-beta.2';
1
+ export default '1.14.0-beta.4';