@xeplr/actions 1.4.7 → 1.4.8
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 +35 -1
- package/lib/builtins/http-request.js +271 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -128,7 +128,8 @@ Register the ones you want (`register(actions.builtins.dbFetch)`) or pass a modu
|
|
|
128
128
|
| `fileList` | `file-list` | The files in a folder, optionally matching `pattern` (`*.csv`, `Bank-2026-??.pdf`), sorted by name or date. `{ files[{ name, path, size, modifiedAt }], count }`. "Is it there?" is the exact name and `count`. See [File steps](#file-steps-and-the-folders-they-may-use). |
|
|
129
129
|
| `fileRead` | `file-read` | A CSV, text, Excel, JSON or NDJSON file as rows (format from the extension). `{ rows, count, columns, truncated }`, at most `maxRows` (50,000). |
|
|
130
130
|
| `fileMove` | `file-move` | Move or copy a file into a folder (made if missing) or to a new name; `ifExists` as every saving step. `{ path, name, moved, skipped }`. |
|
|
131
|
-
| `httpRequest
|
|
131
|
+
| `httpRequest` | `http-request` | Call another system's API: `method`, `path`, `query`, `body`, `headers`, signing in with a saved connection. `{ status, ok, body, headers }`. See [Calling an API](#calling-an-api-http-request). |
|
|
132
|
+
| `spawnProgram`, `dbQuery` | — | **Placeholders.** Empty modules with no `name` or `execute`; `register()` rejects them. |
|
|
132
133
|
|
|
133
134
|
### Connections
|
|
134
135
|
|
|
@@ -248,6 +249,39 @@ A job or a workflow step stores `connectionInfoId`, never a password. `resolveCo
|
|
|
248
249
|
- **`resolver(id, { field, inputs })`** is the host's: a login, or `{ dbType, connection }`. It sees all the inputs, for a companion id such as BI's `dbInfoId`.
|
|
249
250
|
- **An id that resolves to nothing is refused** (`CONNECTION_NOT_FOUND`), never replaced by a literal `connection`. An id with no resolver configured: `NO_CONNECTION_RESOLVER`.
|
|
250
251
|
|
|
252
|
+
## Calling an API: `http-request`
|
|
253
|
+
|
|
254
|
+
```js
|
|
255
|
+
await runAction({ name: 'http-request', input: {
|
|
256
|
+
connectionInfoId: 'bank-api', // resolved by the host to `connection`
|
|
257
|
+
method: 'GET', path: '/statements',
|
|
258
|
+
query: { from: '2026-09-01', to: '2026-09-30' }
|
|
259
|
+
} })
|
|
260
|
+
// → { status: 200, ok: true, body: { rows: [...] }, headers: { 'content-type': … } }
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
**The connection** (what a saved API connection resolves to, so no key is ever in a workflow):
|
|
264
|
+
|
|
265
|
+
```js
|
|
266
|
+
{ baseUrl: 'https://api.bank.example/v2', headers?: { 'X-Tenant': 'acme' }, auth }
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
| `auth.type` | fields | sends |
|
|
270
|
+
|---|---|---|
|
|
271
|
+
| `none` | | nothing |
|
|
272
|
+
| `apiKey` | `in` (`header` / `query`), `name`, `value` | `X-API-Key: …`, or `?key=…` |
|
|
273
|
+
| `bearer` | `token` | `Authorization: Bearer …` |
|
|
274
|
+
| `basic` | `username`, `password` | `Authorization: Basic …` |
|
|
275
|
+
| `login` | `url` (path or address), `method` (POST), `body`, `tokenPath` (`data.token`), `expiresInPath`, `header`, `prefix` | calls the login first, then `Authorization: Bearer <token>` |
|
|
276
|
+
| `oauth2` | `tokenUrl`, `clientId`, `clientSecret`, `scope`, `audience` | client credentials; then `Authorization: Bearer <access_token>` |
|
|
277
|
+
|
|
278
|
+
- **A token from `login` or `oauth2` is kept in this process** until it runs out (`expires_in` / `expiresInPath`, a minute early; 10 minutes when none is given). A 401 throws it away and the call is tried once more with a fresh one.
|
|
279
|
+
- **Retries:** a network failure, 429 or 5xx is tried again (`retries`, 2), waiting `retryAfterMs` (1 s) and doubling, or what `Retry-After` says. A 4xx other than 401 / 429 is not retried.
|
|
280
|
+
- **A status that is not 2xx fails the step** with `HTTP_<status>` and the start of the answer. `failOnError: false`: it succeeds, so the arrows can branch on `output.status`.
|
|
281
|
+
- **A saved connection signs in to its own address only:** a whole URL on another host is refused (`URL_NOT_ON_CONNECTION`), so the key cannot be sent elsewhere. With no connection, `path` may be a whole `https://` address.
|
|
282
|
+
- **Bodies:** JSON by default; `bodyType: 'form'` or `'text'`. The answer is parsed as JSON when it is JSON, else text.
|
|
283
|
+
- **Not yet:** paging through many pages of results.
|
|
284
|
+
|
|
251
285
|
## File steps, and the folders they may use
|
|
252
286
|
|
|
253
287
|
`file-list`, `file-read` and `file-move` take a path from whoever wrote the workflow, so they work **only inside the folders the install names** (`lib/fileRoots.js`):
|
|
@@ -1,3 +1,271 @@
|
|
|
1
|
-
// http-request action —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// http-request action — call another system's API.
|
|
2
|
+
//
|
|
3
|
+
// One call: a method, a path on the API, query values, headers and a body,
|
|
4
|
+
// and the answer back as { status, ok, body, headers }. The body is parsed
|
|
5
|
+
// JSON when the answer says it is JSON, else text.
|
|
6
|
+
//
|
|
7
|
+
// WHERE AND AS WHOM come from a saved connection (`connectionInfoId`), which
|
|
8
|
+
// the host turns into `connection` just before the call (resolveConnections):
|
|
9
|
+
//
|
|
10
|
+
// { baseUrl: 'https://api.bank.example/v2',
|
|
11
|
+
// headers?: { 'X-Tenant': 'acme' },
|
|
12
|
+
// auth: { type: 'none' }
|
|
13
|
+
// | { type: 'apiKey', in: 'header' | 'query', name: 'X-API-Key', value }
|
|
14
|
+
// | { type: 'bearer', token }
|
|
15
|
+
// | { type: 'basic', username, password }
|
|
16
|
+
// | { type: 'login', url, method?, body?, tokenPath, header?, prefix?, expiresInPath? }
|
|
17
|
+
// | { type: 'oauth2', tokenUrl, clientId, clientSecret, scope?, audience? } }
|
|
18
|
+
//
|
|
19
|
+
// so a workflow never holds a key or a password, and a step's recorded input
|
|
20
|
+
// holds only the id. Without a saved connection, `path` may be a whole
|
|
21
|
+
// address (a public API that needs no sign-in).
|
|
22
|
+
//
|
|
23
|
+
// A TOKEN FROM A LOGIN CALL OR OAUTH is fetched once and kept in this process
|
|
24
|
+
// until it runs out (or, with no expiry given, for 10 minutes). A 401 throws
|
|
25
|
+
// it away and the call is tried once more with a fresh one.
|
|
26
|
+
//
|
|
27
|
+
// RETRIES: a network failure, 429 or 5xx is tried again (`retries`, default
|
|
28
|
+
// 2), waiting longer each time, or as long as Retry-After asks. A 4xx other
|
|
29
|
+
// than 401/429 is the API refusing the request and is not retried.
|
|
30
|
+
//
|
|
31
|
+
// A STATUS THAT IS NOT 2xx fails the step (`failOnError`, default true),
|
|
32
|
+
// saying the status and the start of the answer. Off: the step succeeds and
|
|
33
|
+
// the arrows can branch on output.status.
|
|
34
|
+
|
|
35
|
+
var crypto = require('crypto');
|
|
36
|
+
|
|
37
|
+
var TOKEN_DEFAULT_MS = 10 * 60 * 1000;
|
|
38
|
+
var tokens = {}; // key → { value, until }
|
|
39
|
+
|
|
40
|
+
function httpError(message, code, extra) {
|
|
41
|
+
var err = new Error(message);
|
|
42
|
+
err.code = code;
|
|
43
|
+
Object.assign(err, extra || {});
|
|
44
|
+
return err;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sleep(ms) { return new Promise(function(r) { setTimeout(r, ms); }); }
|
|
48
|
+
|
|
49
|
+
/** A value at a dotted path in an object: 'data.token' → obj.data.token. */
|
|
50
|
+
function at(obj, path) {
|
|
51
|
+
return String(path || '').split('.').filter(Boolean).reduce(function(o, k) { return o == null ? undefined : o[k]; }, obj);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The full address: a path is joined to the connection's base; a whole URL must stay on it. */
|
|
55
|
+
function addressOf(conn, input) {
|
|
56
|
+
var raw = String(input.path || '').trim();
|
|
57
|
+
var base = conn && conn.baseUrl ? String(conn.baseUrl).replace(/\/+$/, '') : '';
|
|
58
|
+
var url;
|
|
59
|
+
if (/^https?:\/\//i.test(raw)) {
|
|
60
|
+
url = new URL(raw);
|
|
61
|
+
// A saved connection signs in to ITS address only: a whole URL elsewhere
|
|
62
|
+
// would send the key to whoever is there.
|
|
63
|
+
if (base && url.origin !== new URL(base).origin) {
|
|
64
|
+
throw httpError('"' + raw + '" is not on this connection\'s address (' + base + '). Give a path, e.g. /statements.', 'URL_NOT_ON_CONNECTION');
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
if (!base) throw httpError('Give a whole address (https://…), or pick a saved connection and give a path.', 'URL_REQUIRED');
|
|
68
|
+
url = new URL(base + (raw ? '/' + raw.replace(/^\/+/, '') : ''));
|
|
69
|
+
}
|
|
70
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') throw httpError('Only http and https addresses.', 'URL_REQUIRED');
|
|
71
|
+
Object.keys(input.query || {}).forEach(function(k) {
|
|
72
|
+
var v = input.query[k];
|
|
73
|
+
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
74
|
+
});
|
|
75
|
+
return url;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A body as fetch sends it, and its content type. */
|
|
79
|
+
function bodyOf(input) {
|
|
80
|
+
if (input.body === undefined || input.body === null || input.body === '') return { body: undefined };
|
|
81
|
+
var kind = input.bodyType || (typeof input.body === 'string' ? 'text' : 'json');
|
|
82
|
+
if (kind === 'json') return { body: typeof input.body === 'string' ? input.body : JSON.stringify(input.body), type: 'application/json' };
|
|
83
|
+
if (kind === 'form') {
|
|
84
|
+
var form = new URLSearchParams();
|
|
85
|
+
Object.keys(input.body || {}).forEach(function(k) { form.set(k, String(input.body[k])); });
|
|
86
|
+
return { body: form.toString(), type: 'application/x-www-form-urlencoded' };
|
|
87
|
+
}
|
|
88
|
+
return { body: String(input.body), type: 'text/plain' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function readBody(res) {
|
|
92
|
+
var text = await res.text();
|
|
93
|
+
var type = res.headers.get('content-type') || '';
|
|
94
|
+
if (/json/i.test(type) || /^\s*[\[{]/.test(text)) {
|
|
95
|
+
try { return JSON.parse(text); } catch (e) { /* not JSON after all */ }
|
|
96
|
+
}
|
|
97
|
+
return text;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function cacheKey(conn) {
|
|
101
|
+
return crypto.createHash('sha256').update(JSON.stringify([conn.baseUrl, conn.auth])).digest('hex');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A token from the connection's login call or OAuth, kept until it runs out. */
|
|
105
|
+
async function tokenFor(conn, timeoutMs) {
|
|
106
|
+
var key = cacheKey(conn);
|
|
107
|
+
var kept = tokens[key];
|
|
108
|
+
if (kept && kept.until > Date.now()) return kept.value;
|
|
109
|
+
var auth = conn.auth;
|
|
110
|
+
var res, answer, token, expiresIn;
|
|
111
|
+
if (auth.type === 'oauth2') {
|
|
112
|
+
var form = new URLSearchParams({ grant_type: 'client_credentials', client_id: auth.clientId || '', client_secret: auth.clientSecret || '' });
|
|
113
|
+
if (auth.scope) form.set('scope', auth.scope);
|
|
114
|
+
if (auth.audience) form.set('audience', auth.audience);
|
|
115
|
+
res = await timed(auth.tokenUrl, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body: form.toString() }, timeoutMs);
|
|
116
|
+
answer = await readBody(res);
|
|
117
|
+
token = answer && answer.access_token;
|
|
118
|
+
expiresIn = answer && answer.expires_in;
|
|
119
|
+
} else {
|
|
120
|
+
var loginUrl = /^https?:\/\//i.test(auth.url || '') ? auth.url : String(conn.baseUrl || '').replace(/\/+$/, '') + '/' + String(auth.url || '').replace(/^\/+/, '');
|
|
121
|
+
var b = auth.body === undefined ? undefined : (typeof auth.body === 'string' ? auth.body : JSON.stringify(auth.body));
|
|
122
|
+
res = await timed(loginUrl, { method: auth.method || 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: b }, timeoutMs);
|
|
123
|
+
answer = await readBody(res);
|
|
124
|
+
token = at(answer, auth.tokenPath || 'token');
|
|
125
|
+
expiresIn = auth.expiresInPath ? at(answer, auth.expiresInPath) : undefined;
|
|
126
|
+
}
|
|
127
|
+
if (!res.ok || !token) {
|
|
128
|
+
tokens[key] = undefined;
|
|
129
|
+
throw httpError('Signing in to ' + (conn.baseUrl || 'the API') + ' failed: ' + res.status +
|
|
130
|
+
(token ? '' : ', and no token in the answer' + (auth.type === 'login' ? ' at "' + (auth.tokenPath || 'token') + '"' : '')) + '.', 'SIGN_IN_FAILED', { status: res.status });
|
|
131
|
+
}
|
|
132
|
+
// A minute early, so a token never runs out mid-call.
|
|
133
|
+
var lifeMs = Number(expiresIn) > 0 ? Math.max(0, Number(expiresIn) * 1000 - 60000) : TOKEN_DEFAULT_MS;
|
|
134
|
+
tokens[key] = { value: String(token), until: Date.now() + lifeMs };
|
|
135
|
+
return String(token);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function timed(url, init, timeoutMs) {
|
|
139
|
+
var controller = new AbortController();
|
|
140
|
+
var timer = setTimeout(function() { controller.abort(); }, timeoutMs);
|
|
141
|
+
if (timer.unref) timer.unref();
|
|
142
|
+
try {
|
|
143
|
+
return await fetch(url, Object.assign({}, init, { signal: controller.signal }));
|
|
144
|
+
} catch (err) {
|
|
145
|
+
var why = err.name === 'AbortError' ? 'no answer within ' + timeoutMs + ' ms' : (err.cause && err.cause.code) || err.message;
|
|
146
|
+
throw httpError('Could not reach ' + String(url).split('?')[0] + ': ' + why + '.', 'UNREACHABLE', { transient: true });
|
|
147
|
+
} finally {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Headers for one call: the connection's, the sign-in's, then the step's own. */
|
|
153
|
+
async function headersFor(conn, input, url, timeoutMs) {
|
|
154
|
+
var h = { accept: 'application/json, text/plain;q=0.9, */*;q=0.8' };
|
|
155
|
+
Object.assign(h, (conn && conn.headers) || {});
|
|
156
|
+
var auth = (conn && conn.auth) || { type: 'none' };
|
|
157
|
+
if (auth.type === 'apiKey') {
|
|
158
|
+
if (auth.in === 'query') url.searchParams.set(auth.name || 'api_key', auth.value || '');
|
|
159
|
+
else h[auth.name || 'X-API-Key'] = auth.value || '';
|
|
160
|
+
} else if (auth.type === 'bearer') {
|
|
161
|
+
h.authorization = 'Bearer ' + (auth.token || '');
|
|
162
|
+
} else if (auth.type === 'basic') {
|
|
163
|
+
h.authorization = 'Basic ' + Buffer.from((auth.username || '') + ':' + (auth.password || '')).toString('base64');
|
|
164
|
+
} else if (auth.type === 'login' || auth.type === 'oauth2') {
|
|
165
|
+
var token = await tokenFor(conn, timeoutMs);
|
|
166
|
+
var name = auth.header || 'authorization';
|
|
167
|
+
h[name] = (auth.prefix === undefined ? 'Bearer ' : auth.prefix) + token;
|
|
168
|
+
} else if (auth.type && auth.type !== 'none') {
|
|
169
|
+
throw httpError('"' + auth.type + '" is not a way of signing in. Kinds: none, apiKey, bearer, basic, login, oauth2.', 'AUTH_UNKNOWN');
|
|
170
|
+
}
|
|
171
|
+
Object.assign(h, input.headers || {});
|
|
172
|
+
return h;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** How long to wait before try `n`: Retry-After when the API says, else 1s, 2s, 4s… */
|
|
176
|
+
function waitFor(res, n, baseMs) {
|
|
177
|
+
var after = res && res.headers && res.headers.get('retry-after');
|
|
178
|
+
if (after && !isNaN(Number(after))) return Math.min(60000, Number(after) * 1000);
|
|
179
|
+
return baseMs * Math.pow(2, n - 1);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = {
|
|
183
|
+
name: 'http-request',
|
|
184
|
+
description: 'Call another system\'s API: a method, a path, query values, headers and a body. ' +
|
|
185
|
+
'Signs in with a saved connection. Returns { status, ok, body, headers }.',
|
|
186
|
+
requires: [],
|
|
187
|
+
inputSchema: [
|
|
188
|
+
{ name: 'connectionInfoId', type: 'string', order: 1, optionsFrom: 'apis',
|
|
189
|
+
description: 'A saved API connection: its address and how to sign in. Resolved on the server.' },
|
|
190
|
+
{ name: 'connection', type: 'object', order: 2, system: true,
|
|
191
|
+
description: 'Filled from the saved connection: { baseUrl, headers?, auth }.' },
|
|
192
|
+
{ name: 'method', type: 'string', default: 'GET', order: 3, options: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
|
193
|
+
description: 'GET reads; POST sends something new; PUT / PATCH change; DELETE removes.' },
|
|
194
|
+
{ name: 'path', type: 'string', order: 4,
|
|
195
|
+
description: 'Where on the API, e.g. /statements. Or a whole address (https://…) with no saved connection.' },
|
|
196
|
+
{ name: 'query', type: 'object', order: 5,
|
|
197
|
+
description: 'Values for the address, e.g. { "from": "{trigger.window.from|date:YYYY-MM-DD}" }.' },
|
|
198
|
+
{ name: 'body', type: 'object', order: 6,
|
|
199
|
+
description: 'What to send with POST / PUT / PATCH, e.g. { "rows": "{steps.match.output.rows}" }.' },
|
|
200
|
+
{ name: 'bodyType', type: 'string', default: 'json', order: 7, group: 'Advanced', options: ['json', 'form', 'text'],
|
|
201
|
+
description: 'How the body is sent: json, a form, or plain text.' },
|
|
202
|
+
{ name: 'headers', type: 'object', order: 8, group: 'Advanced',
|
|
203
|
+
description: 'Extra headers for this call. The sign-in is added from the connection.' },
|
|
204
|
+
{ name: 'failOnError', type: 'boolean', default: true, order: 9, group: 'Advanced',
|
|
205
|
+
description: 'On: a status that is not 2xx fails the step. Off: the step succeeds and the arrows branch on output.status.' },
|
|
206
|
+
{ name: 'retries', type: 'number', default: 2, order: 10, group: 'Advanced',
|
|
207
|
+
description: 'Tries again on a network failure, 429 or 5xx, waiting longer each time.' },
|
|
208
|
+
{ name: 'retryAfterMs', type: 'number', default: 1000, order: 11, group: 'Advanced',
|
|
209
|
+
description: 'The first wait between tries; it doubles each time, unless the API says Retry-After.' },
|
|
210
|
+
{ name: 'timeoutMs', type: 'number', default: 30000, order: 12, group: 'Advanced',
|
|
211
|
+
description: 'How long to wait for an answer.' }
|
|
212
|
+
],
|
|
213
|
+
outputSchema: [
|
|
214
|
+
{ name: 'status', type: 'number', description: 'The HTTP status, e.g. 200.' },
|
|
215
|
+
{ name: 'ok', type: 'boolean', description: 'Whether it was 2xx.' },
|
|
216
|
+
{ name: 'body', type: 'object', description: 'The answer: parsed JSON, or text.' },
|
|
217
|
+
{ name: 'headers', type: 'object', description: 'The answer\'s content-type, and any rate-limit or paging headers.' }
|
|
218
|
+
],
|
|
219
|
+
|
|
220
|
+
execute: async function(ctx) {
|
|
221
|
+
var input = ctx.input || {};
|
|
222
|
+
var conn = input.connection || null;
|
|
223
|
+
if (input.connectionInfoId && !conn) throw httpError('The saved connection was not resolved.', 'CONNECTION_NOT_FOUND');
|
|
224
|
+
var method = String(input.method || 'GET').toUpperCase();
|
|
225
|
+
var timeoutMs = Math.max(1000, Number(input.timeoutMs) || 30000);
|
|
226
|
+
var retries = Math.max(0, Math.min(10, input.retries == null ? 2 : Number(input.retries)));
|
|
227
|
+
var baseWait = Math.max(0, input.retryAfterMs == null ? 1000 : Number(input.retryAfterMs));
|
|
228
|
+
var sent = method === 'GET' || method === 'DELETE' ? { body: undefined } : bodyOf(input);
|
|
229
|
+
|
|
230
|
+
var refreshed = false;
|
|
231
|
+
var res;
|
|
232
|
+
for (var attempt = 0; ; attempt++) {
|
|
233
|
+
var url = addressOf(conn, input);
|
|
234
|
+
var headers = await headersFor(conn, input, url, timeoutMs);
|
|
235
|
+
if (sent.type && !Object.keys(headers).some(function(k) { return k.toLowerCase() === 'content-type'; })) headers['content-type'] = sent.type;
|
|
236
|
+
try {
|
|
237
|
+
res = await timed(url.toString(), { method: method, headers: headers, body: sent.body }, timeoutMs);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
if (err.transient && attempt < retries) { await sleep(baseWait * Math.pow(2, attempt)); continue; }
|
|
240
|
+
throw err;
|
|
241
|
+
}
|
|
242
|
+
// A kept token the API no longer takes: throw it away and sign in again, once.
|
|
243
|
+
if (res.status === 401 && conn && conn.auth && (conn.auth.type === 'login' || conn.auth.type === 'oauth2') && !refreshed) {
|
|
244
|
+
tokens[cacheKey(conn)] = undefined;
|
|
245
|
+
refreshed = true;
|
|
246
|
+
attempt--;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if ((res.status === 429 || res.status >= 500) && attempt < retries) {
|
|
250
|
+
await res.text().catch(function() {});
|
|
251
|
+
await sleep(waitFor(res, attempt + 1, baseWait));
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
var body = await readBody(res);
|
|
258
|
+
var kept = {};
|
|
259
|
+
res.headers.forEach(function(v, k) {
|
|
260
|
+
if (k === 'content-type' || k === 'link' || /^x-(rate|ratelimit|total|page|next)/i.test(k) || /^ratelimit/i.test(k)) kept[k] = v;
|
|
261
|
+
});
|
|
262
|
+
if (!res.ok && input.failOnError !== false) {
|
|
263
|
+
var said = typeof body === 'string' ? body : JSON.stringify(body);
|
|
264
|
+
throw httpError(method + ' ' + url.pathname + ' answered ' + res.status + (said ? ': ' + said.slice(0, 300) : '') + '.', 'HTTP_' + res.status, { status: res.status });
|
|
265
|
+
}
|
|
266
|
+
return { status: res.status, ok: res.ok, body: body, headers: kept };
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
// For tests: forget every kept token.
|
|
270
|
+
_forgetTokens: function() { tokens = {}; }
|
|
271
|
+
};
|
package/package.json
CHANGED