ravensight-playtest 0.1.0

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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -0
  3. package/addons/ravensight_driver/driver.gd +836 -0
  4. package/addons/ravensight_driver/export_plugin.gd +51 -0
  5. package/addons/ravensight_driver/plugin.cfg +7 -0
  6. package/addons/ravensight_driver/plugin.gd +36 -0
  7. package/bin/ravensight-playtest.js +31 -0
  8. package/package.json +45 -0
  9. package/src/api/README.md +500 -0
  10. package/src/api/client.js +340 -0
  11. package/src/api/errors.js +115 -0
  12. package/src/api/http.js +194 -0
  13. package/src/api/index.js +107 -0
  14. package/src/auth/deviceCode.js +79 -0
  15. package/src/auth/keychain.js +159 -0
  16. package/src/auth/session.js +128 -0
  17. package/src/cli.js +335 -0
  18. package/src/commands/brief.js +303 -0
  19. package/src/commands/check.js +318 -0
  20. package/src/commands/fakeCore.js +379 -0
  21. package/src/commands/init.js +120 -0
  22. package/src/commands/login.js +90 -0
  23. package/src/commands/logout.js +70 -0
  24. package/src/commands/open.js +125 -0
  25. package/src/commands/profile.js +262 -0
  26. package/src/commands/resume.js +156 -0
  27. package/src/commands/run.js +1015 -0
  28. package/src/commands/upload.js +137 -0
  29. package/src/config.js +100 -0
  30. package/src/dashboard.js +97 -0
  31. package/src/detect.js +77 -0
  32. package/src/errors.js +44 -0
  33. package/src/fsutil.js +77 -0
  34. package/src/godot.js +85 -0
  35. package/src/packs/index.js +191 -0
  36. package/src/paths.js +129 -0
  37. package/src/run/aggregate.js +658 -0
  38. package/src/run/args.js +111 -0
  39. package/src/run/context.js +181 -0
  40. package/src/run/deps.js +184 -0
  41. package/src/run/drivers/driver.js +183 -0
  42. package/src/run/drivers/godot-observation.js +138 -0
  43. package/src/run/drivers/godot-project.js +475 -0
  44. package/src/run/drivers/godot-rpc.js +225 -0
  45. package/src/run/drivers/godot.js +587 -0
  46. package/src/run/drivers/index.js +52 -0
  47. package/src/run/drivers/web.js +385 -0
  48. package/src/run/exit.js +21 -0
  49. package/src/run/heartbeat.js +131 -0
  50. package/src/run/index.js +31 -0
  51. package/src/run/json.js +56 -0
  52. package/src/run/model.js +384 -0
  53. package/src/run/paths.js +88 -0
  54. package/src/run/personaLoop.js +871 -0
  55. package/src/run/profile.js +214 -0
  56. package/src/run/regenerate.js +149 -0
  57. package/src/run/repoTools.js +286 -0
  58. package/src/run/report.js +222 -0
  59. package/src/run/resume.js +272 -0
  60. package/src/run/secretScan.js +171 -0
  61. package/src/run/state.js +198 -0
  62. package/src/run/synthetic.js +206 -0
  63. package/src/run/tools.js +344 -0
  64. package/src/run/transcript.js +93 -0
  65. package/src/run/usage.js +115 -0
  66. package/src/state/index.js +105 -0
  67. package/src/states.js +104 -0
  68. package/src/ui/index.js +195 -0
  69. package/src/upload/allowlist.js +116 -0
  70. package/src/upload/index.js +467 -0
  71. package/src/upload/queue.js +114 -0
  72. package/src/version.js +63 -0
@@ -0,0 +1,340 @@
1
+ import { ApiError } from './errors.js';
2
+ import { buildUrl, requestWithRetry, DEFAULT_RETRIES, DEFAULT_TIMEOUT_MS } from './http.js';
3
+ import { resolveApiUrl } from '../paths.js';
4
+ import { CLI_VERSION, CLIENT_NAME, userAgentFor } from '../version.js';
5
+
6
+ const CLI_BASE = '/api/v1/playtest/cli';
7
+ const PACKS_BASE = '/api/v1/playtest/packs';
8
+
9
+ /** `/api/v1/games/:gameId/playtest` with the game id encoded. */
10
+ function gameBase(gameId) {
11
+ if (typeof gameId !== 'string' || gameId.length === 0) {
12
+ throw new TypeError('gameId is required');
13
+ }
14
+ return `/api/v1/games/${encodeURIComponent(gameId)}/playtest`;
15
+ }
16
+
17
+ /**
18
+ * The Ravensight playtest API, as one object with one namespace per surface.
19
+ *
20
+ * Every method is a thin wrapper over `request`, and every wrapper keeps the
21
+ * server's own field spelling (`confirm_price_cents`, not `confirmPriceCents`)
22
+ * on anything that goes into or comes out of a body. Translating between two
23
+ * naming conventions in a client is a second place for a contract to drift,
24
+ * and the server's spelling is the one written down in the task reports.
25
+ *
26
+ * @param {Object} [options]
27
+ * @param {string} [options.apiUrl]
28
+ * @param {string|null} [options.token] - a `gt_cli_...` token
29
+ * @param {string} [options.cliVersion]
30
+ * @param {typeof fetch} [options.fetch]
31
+ * @param {number} [options.retries]
32
+ * @param {number} [options.timeoutMs]
33
+ * @param {Function} [options.onRetry]
34
+ * @param {Function} [options.sleep]
35
+ */
36
+ export function createClient(options = {}) {
37
+ const apiUrl = (options.apiUrl || resolveApiUrl()).replace(/\/+$/, '');
38
+ const token = options.token === undefined ? null : options.token;
39
+ const cliVersion = options.cliVersion || CLI_VERSION;
40
+ const userAgent = userAgentFor(cliVersion);
41
+ const transport = {
42
+ fetchImpl: options.fetch || globalThis.fetch,
43
+ retries: options.retries === undefined ? DEFAULT_RETRIES : options.retries,
44
+ timeoutMs: options.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : options.timeoutMs,
45
+ onRetry: options.onRetry,
46
+ sleep: options.sleep,
47
+ random: options.random
48
+ };
49
+
50
+ /**
51
+ * @param {string} method
52
+ * @param {string} path
53
+ * @param {{query?: Object, body?: unknown, headers?: Object, idempotent?: boolean,
54
+ * raw?: boolean, anonymous?: boolean}} [opts]
55
+ * @returns {Promise<{status: number, headers: Headers, body: unknown}>}
56
+ */
57
+ async function request(method, path, opts = {}) {
58
+ const headers = {
59
+ accept: opts.raw ? '*/*' : 'application/json',
60
+ 'user-agent': userAgent,
61
+ 'x-ravensight-cli-version': cliVersion,
62
+ ...(opts.headers || {})
63
+ };
64
+ if (!opts.anonymous) {
65
+ if (!token) {
66
+ throw new ApiError({
67
+ status: 0,
68
+ code: 'not_authenticated',
69
+ message: 'No Ravensight credential. Run login, or set RAVENSIGHT_TOKEN.',
70
+ method,
71
+ path
72
+ });
73
+ }
74
+ headers.authorization = `Bearer ${token}`;
75
+ }
76
+ let bodyText;
77
+ if (opts.body !== undefined) {
78
+ bodyText = JSON.stringify(opts.body);
79
+ headers['content-type'] = 'application/json';
80
+ }
81
+ return requestWithRetry({
82
+ url: buildUrl(apiUrl, path, opts.query),
83
+ method,
84
+ path,
85
+ headers,
86
+ bodyText,
87
+ idempotent: Boolean(opts.idempotent),
88
+ raw: Boolean(opts.raw),
89
+ ...transport
90
+ });
91
+ }
92
+
93
+ /** A GET whose body we want and whose status we do not. */
94
+ const get = async (path, opts) => (await request('GET', path, { idempotent: true, ...opts })).body;
95
+
96
+ /**
97
+ * A cacheable GET: hand back the status, the ETag and the body, because a
98
+ * 304 is a successful answer with no body and the caller has to be able to
99
+ * tell it from a 200.
100
+ * @param {string} path
101
+ * @param {{query?: Object, etag?: string|null}} [opts]
102
+ */
103
+ async function cacheable(path, opts = {}) {
104
+ const headers = {};
105
+ if (opts.etag) headers['if-none-match'] = opts.etag;
106
+ const answer = await request('GET', path, { query: opts.query, headers, idempotent: true });
107
+ return {
108
+ status: answer.status,
109
+ etag: answer.headers.get('etag'),
110
+ body: answer.status === 304 ? null : answer.body
111
+ };
112
+ }
113
+
114
+ const packModules = modules => (Array.isArray(modules) && modules.length > 0 ? modules.join(',') : undefined);
115
+
116
+ const cli = {
117
+ /** Unauthenticated on purpose: a CLI asks this before it holds a credential. */
118
+ version: () => get(`${CLI_BASE}/version`, { anonymous: true }),
119
+
120
+ deviceCode: ({ client = CLIENT_NAME, clientVersion = cliVersion } = {}) =>
121
+ request('POST', `${CLI_BASE}/device-code`, {
122
+ body: { client, clientVersion },
123
+ anonymous: true
124
+ }).then(answer => answer.body),
125
+
126
+ /**
127
+ * One poll. Throws `ApiError` with code `authorization_pending` (428),
128
+ * `slow_down` (429), `expired_token` (400) or `access_denied` (403); the
129
+ * caller's loop decides which of those means keep waiting.
130
+ *
131
+ * Not retried, although 429 normally is: `slow_down` is the server telling
132
+ * this exact caller to poll less often, and an automatic retry inside the
133
+ * request would be the CLI arguing with it.
134
+ */
135
+ pollToken: deviceCode =>
136
+ request('POST', `${CLI_BASE}/token`, {
137
+ body: { device_code: deviceCode },
138
+ anonymous: true,
139
+ idempotent: false
140
+ }).then(answer => answer.body),
141
+
142
+ whoami: () => get(`${CLI_BASE}/whoami`),
143
+
144
+ revoke: () => request('DELETE', `${CLI_BASE}/token`).then(() => undefined)
145
+ };
146
+
147
+ const packs = {
148
+ manifest: (opts = {}) => cacheable(`${PACKS_BASE}/manifest`, opts),
149
+ personas: ({ gameId, etag } = {}) =>
150
+ cacheable(`${PACKS_BASE}/personas`, { query: { game_id: gameId }, etag }),
151
+ skills: ({ modules, etag } = {}) =>
152
+ cacheable(`${PACKS_BASE}/skills`, { query: { modules: packModules(modules) }, etag }),
153
+ schemas: ({ etag } = {}) => cacheable(`${PACKS_BASE}/schemas`, { etag }),
154
+ routing: ({ etag } = {}) => cacheable(`${PACKS_BASE}/routing`, { etag }),
155
+ bundle: ({ gameId, modules, etag } = {}) =>
156
+ cacheable(`${PACKS_BASE}/bundle`, {
157
+ query: { game_id: gameId, modules: packModules(modules) },
158
+ etag
159
+ })
160
+ };
161
+
162
+ const brief = {
163
+ get: gameId => get(`${gameBase(gameId)}/brief`),
164
+ versions: gameId => get(`${gameBase(gameId)}/brief/versions`),
165
+ version: (gameId, version) => get(`${gameBase(gameId)}/brief/versions/${encodeURIComponent(version)}`),
166
+
167
+ /**
168
+ * Save a new version. `parentVersion` is the version this edit was based
169
+ * on; a mismatch is 409 `stale_version` carrying `current_version`, which
170
+ * is the whole point of sending it.
171
+ */
172
+ put: (gameId, { fields, status = 'draft', parentVersion = null } = {}) =>
173
+ request('PUT', `${gameBase(gameId)}/brief`, {
174
+ body: { fields, status, parent_version: parentVersion }
175
+ }).then(answer => answer.body),
176
+
177
+ estimate: (gameId, { modules = [], personas = [] } = {}) =>
178
+ request('POST', `${gameBase(gameId)}/brief/estimate`, { body: { modules, personas } })
179
+ .then(answer => answer.body),
180
+
181
+ check: (gameId, completeness) =>
182
+ request('POST', `${gameBase(gameId)}/brief/check`, { body: { completeness } })
183
+ .then(answer => answer.body)
184
+ };
185
+
186
+ const jobs = {
187
+ estimate: (gameId, { modules = [], personas = [] } = {}) =>
188
+ request('POST', `${gameBase(gameId)}/estimate`, { body: { modules, personas } })
189
+ .then(answer => answer.body),
190
+
191
+ /**
192
+ * Register a job: this is the request that moves money.
193
+ *
194
+ * `Idempotency-Key` is required by the server and checked here, before the
195
+ * request leaves, so a caller that forgot one gets a TypeError with a
196
+ * stack pointing at its own bug instead of a 400 from a round trip. With
197
+ * the key present the call is marked idempotent, so a dropped response is
198
+ * retried and the server replays its stored job rather than charging
199
+ * twice.
200
+ */
201
+ register: (gameId, body, { idempotencyKey } = {}) => {
202
+ if (typeof idempotencyKey !== 'string' || idempotencyKey.length === 0) {
203
+ throw new TypeError('jobs.register needs an idempotencyKey: without one a retry charges twice');
204
+ }
205
+ return request('POST', `${gameBase(gameId)}/jobs`, {
206
+ body: { cli_version: cliVersion, ...body },
207
+ headers: { 'idempotency-key': idempotencyKey },
208
+ idempotent: true
209
+ }).then(answer => ({ ...answer.body, status: answer.status }));
210
+ },
211
+
212
+ list: (gameId, query = {}) => get(`${gameBase(gameId)}/jobs`, { query }),
213
+ get: (gameId, jobId) => get(`${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}`),
214
+
215
+ patch: (gameId, jobId, { state, reason = '' } = {}) =>
216
+ request('PATCH', `${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}`, {
217
+ body: { state, reason }
218
+ }).then(answer => answer.body),
219
+
220
+ cancel: (gameId, jobId) =>
221
+ request('POST', `${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/cancel`, { body: {} })
222
+ .then(answer => answer.body),
223
+
224
+ finish: (gameId, jobId, { state, reason = '' } = {}) =>
225
+ request('POST', `${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/finish`, {
226
+ body: { state, reason }
227
+ }).then(answer => answer.body),
228
+
229
+ remove: (gameId, jobId) =>
230
+ request('DELETE', `${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}`).then(() => undefined),
231
+
232
+ /** Job level presign: capability-report.json and aggregate-report.*. */
233
+ uploads: (gameId, jobId, { files } = {}) =>
234
+ request('POST', `${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/uploads`, {
235
+ body: { files }
236
+ }).then(answer => answer.body),
237
+
238
+ capabilityReport: (gameId, jobId, body) =>
239
+ request('POST', `${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/capability-report`, { body })
240
+ .then(answer => answer.body),
241
+
242
+ aggregateComplete: (gameId, jobId, body) =>
243
+ request('POST', `${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/aggregate/complete`, { body })
244
+ .then(answer => answer.body),
245
+
246
+ artifacts: (gameId, jobId) =>
247
+ get(`${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/artifacts`),
248
+
249
+ /** The aggregate report itself, as text. `format` is 'md' or 'json'. */
250
+ report: async (gameId, jobId, format = 'md') =>
251
+ (await request('GET', `${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/report`, {
252
+ query: { format },
253
+ raw: true,
254
+ idempotent: true
255
+ })).body,
256
+
257
+ summary: gameId => get(`${gameBase(gameId)}/summary`),
258
+ findings: (gameId, query = {}) => get(`${gameBase(gameId)}/findings`, { query })
259
+ };
260
+
261
+ function runPath(gameId, jobId, runId, suffix = '') {
262
+ return `${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/runs/${encodeURIComponent(runId)}${suffix}`;
263
+ }
264
+
265
+ /** Both heartbeat and transition are the same PATCH; only `state` differs. */
266
+ function patchRun(gameId, jobId, runId, body, idempotent) {
267
+ return request('PATCH', runPath(gameId, jobId, runId), { body, idempotent })
268
+ .then(answer => answer.body);
269
+ }
270
+
271
+ const runs = {
272
+ list: (gameId, jobId) => get(`${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/runs`),
273
+
274
+ add: (gameId, jobId, personas) =>
275
+ request('POST', `${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/runs`, {
276
+ body: { personas }
277
+ }).then(answer => answer.body),
278
+
279
+ /**
280
+ * Say the runner is alive, and optionally where it is. Idempotent, so a
281
+ * dropped beat is retried: losing beats is how a live run gets marked
282
+ * `interrupted` after 30 minutes.
283
+ *
284
+ * Answers `{ run, cancel_requested }`. `cancel_requested` is the only way
285
+ * a runner learns it should stop, so read it on every beat.
286
+ */
287
+ heartbeat: (gameId, jobId, runId, { checkpointStep, actionsTaken } = {}) =>
288
+ patchRun(gameId, jobId, runId, {
289
+ heartbeat: true,
290
+ ...(Number.isInteger(checkpointStep) ? { checkpoint_step: checkpointStep } : {}),
291
+ ...(Number.isInteger(actionsTaken) ? { actions_taken: actionsTaken } : {})
292
+ }, true),
293
+
294
+ /**
295
+ * Move a run to a new state, and beat at the same time (a runner reporting
296
+ * a state is by definition alive).
297
+ *
298
+ * `heartbeat: true` is sent explicitly, because the server only beats when
299
+ * the flag is set OR no state was given
300
+ * (`if (req.body.heartbeat || req.body.state === undefined)`). Without it a
301
+ * run that transitions regularly and beats rarely would look silent to the
302
+ * sweep and be marked `interrupted` mid flight.
303
+ *
304
+ * NOT idempotent: the server's transition is a conditional write filtered
305
+ * on the state the caller read, so a retry after a dropped response can
306
+ * lose the race and answer 409 `invalid_transition`. That answer has to
307
+ * reach the caller rather than be swallowed by a retry loop.
308
+ */
309
+ // Idempotent on purpose: a state transition names its target, so a
310
+ // retry after a timed-out request either lands the same state or is
311
+ // refused as already there. The first production run lost a run to a
312
+ // single timed-out PATCH that was never retried.
313
+ transition: (gameId, jobId, runId, state, { reason = '', checkpointStep, actionsTaken } = {}) =>
314
+ patchRun(gameId, jobId, runId, {
315
+ state,
316
+ reason,
317
+ heartbeat: true,
318
+ ...(Number.isInteger(checkpointStep) ? { checkpoint_step: checkpointStep } : {}),
319
+ ...(Number.isInteger(actionsTaken) ? { actions_taken: actionsTaken } : {})
320
+ }, true),
321
+
322
+ uploads: (gameId, jobId, runId, { files, includeVideo = false } = {}) =>
323
+ request('POST', runPath(gameId, jobId, runId, '/uploads'), {
324
+ body: { files, include_video: includeVideo }
325
+ }).then(answer => answer.body),
326
+
327
+ complete: (gameId, jobId, runId, body) =>
328
+ request('POST', runPath(gameId, jobId, runId, '/complete'), { body })
329
+ .then(answer => answer.body),
330
+
331
+ report: async (gameId, jobId, runId, format = 'md') =>
332
+ (await request('GET', runPath(gameId, jobId, runId, '/report'), {
333
+ query: { format },
334
+ raw: true,
335
+ idempotent: true
336
+ })).body
337
+ };
338
+
339
+ return { apiUrl, token, cliVersion, request, cli, packs, brief, jobs, runs };
340
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The Ravensight error envelope, as one class.
3
+ *
4
+ * Every playtest route answers a refusal as
5
+ * `{ error, message, details: [{ instancePath, message, code? }] }` plus
6
+ * whatever figures that particular refusal carries (`shortfall_cents`,
7
+ * `min_version`, `current_version`, and so on). `ApiError` keeps all three
8
+ * parts: the code to branch on, the message to print, and the whole body for
9
+ * the figures, so no caller has to reach past it to the raw response.
10
+ *
11
+ * A request that never got an answer is also an `ApiError`, with `status: 0`
12
+ * and `code: 'network_error'`. Callers branch on one type, not two.
13
+ */
14
+ export class ApiError extends Error {
15
+ /**
16
+ * @param {{status: number, code: string, message: string, details?: Array,
17
+ * body?: unknown, retryAfter?: number|null, method?: string, path?: string,
18
+ * cause?: unknown}} fields
19
+ */
20
+ constructor(fields) {
21
+ super(fields.message || fields.code || 'request failed', { cause: fields.cause });
22
+ this.name = 'ApiError';
23
+ this.status = fields.status;
24
+ this.code = fields.code;
25
+ this.details = Array.isArray(fields.details) ? fields.details : [];
26
+ this.body = fields.body === undefined ? null : fields.body;
27
+ this.retryAfter = fields.retryAfter === undefined ? null : fields.retryAfter;
28
+ this.method = fields.method || null;
29
+ this.path = fields.path || null;
30
+ }
31
+
32
+ /** True when this is a local or transport failure rather than a server verdict. */
33
+ get isNetwork() {
34
+ return this.status === 0;
35
+ }
36
+
37
+ /**
38
+ * The validation pointers as one printable block, or an empty string when
39
+ * the refusal carried none.
40
+ * @returns {string}
41
+ */
42
+ formatDetails() {
43
+ return this.details
44
+ .map(detail => ` ${detail.instancePath || '/'}: ${detail.message}`)
45
+ .join('\n');
46
+ }
47
+ }
48
+
49
+ const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
50
+
51
+ /**
52
+ * Two playtest error codes where the status alone gives the wrong answer, so
53
+ * the code has to be consulted.
54
+ *
55
+ * `too_many_active_jobs` is a 429, and every other 429 is a retry. This one is
56
+ * not: it means this game already has three jobs running, which no amount of
57
+ * waiting inside one request will change. Retrying it would spend four
58
+ * backoffs to arrive at the same refusal, and the person needs to be told, not
59
+ * made to wait.
60
+ *
61
+ * `charge_in_progress` is a 409, and no other 409 is a retry. This one is,
62
+ * because it does not mean refused: it means another caller holds the charge
63
+ * claim and this one cannot tell whether money moved. The server sends
64
+ * `Retry-After: 2` precisely so the client asks again, and it becomes a real
65
+ * answer (a replay, or a charge) within a few seconds. Only retried when it
66
+ * actually carries a `Retry-After`: without one there is no interval the
67
+ * server has blessed, and guessing at a money path is not this layer's call.
68
+ */
69
+ const NEVER_RETRY_CODES = new Set(['too_many_active_jobs']);
70
+ const CODE_RETRY_LIMITS = new Map([['charge_in_progress', 5]]);
71
+
72
+ /**
73
+ * How many retries this error's own code is allowed, independent of the
74
+ * client's general `retries` setting, or null when the code has no special
75
+ * allowance and the ordinary status rules apply.
76
+ * @param {unknown} error
77
+ * @returns {number|null}
78
+ */
79
+ export function codeRetryLimit(error) {
80
+ if (!(error instanceof ApiError)) return null;
81
+ if (!CODE_RETRY_LIMITS.has(error.code)) return null;
82
+ if (error.retryAfter === null || error.retryAfter === undefined) return null;
83
+ return CODE_RETRY_LIMITS.get(error.code);
84
+ }
85
+
86
+ /**
87
+ * Whether a retry could plausibly succeed. This is about the failure, not
88
+ * about whether repeating the request is safe: that is the caller's
89
+ * `idempotent` flag, and a POST that moves money is never retried on this
90
+ * answer alone.
91
+ * @param {unknown} error
92
+ * @returns {boolean}
93
+ */
94
+ export function isRetryable(error) {
95
+ if (!(error instanceof ApiError)) return false;
96
+ if (error.isNetwork) return true;
97
+ if (NEVER_RETRY_CODES.has(error.code)) return false;
98
+ if (codeRetryLimit(error) !== null) return true;
99
+ return RETRYABLE_STATUSES.has(error.status);
100
+ }
101
+
102
+ /**
103
+ * Parse a `Retry-After` header. Seconds or an HTTP date, per RFC 9110;
104
+ * anything else is null rather than a guess.
105
+ * @param {string|null|undefined} value
106
+ * @returns {number|null} seconds
107
+ */
108
+ export function parseRetryAfter(value) {
109
+ if (!value) return null;
110
+ const seconds = Number(value);
111
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds;
112
+ const when = Date.parse(value);
113
+ if (Number.isNaN(when)) return null;
114
+ return Math.max(0, Math.round((when - Date.now()) / 1000));
115
+ }
@@ -0,0 +1,194 @@
1
+ import { ApiError, codeRetryLimit, isRetryable, parseRetryAfter } from './errors.js';
2
+
3
+ export const DEFAULT_RETRIES = 4;
4
+ export const DEFAULT_TIMEOUT_MS = 30000;
5
+ const BASE_DELAY_MS = 500;
6
+ const MAX_DELAY_MS = 30000;
7
+
8
+ /**
9
+ * Backoff for attempt `n` (1 based): exponential from 500 ms, capped at 30 s,
10
+ * with full jitter so a fleet of runners that all hit a 503 at once do not
11
+ * come back in lockstep. A `Retry-After` from the server wins over the
12
+ * computed delay, because the server knows something the client does not.
13
+ *
14
+ * @param {number} attempt
15
+ * @param {number|null} retryAfterSeconds
16
+ * @param {() => number} [random]
17
+ * @returns {number} milliseconds
18
+ */
19
+ export function backoffDelay(attempt, retryAfterSeconds = null, random = Math.random) {
20
+ if (retryAfterSeconds !== null && retryAfterSeconds !== undefined) {
21
+ return Math.min(retryAfterSeconds * 1000, MAX_DELAY_MS);
22
+ }
23
+ const ceiling = Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), MAX_DELAY_MS);
24
+ return Math.round(ceiling * (0.5 + random() * 0.5));
25
+ }
26
+
27
+ /**
28
+ * Build a URL from a base, a path and a query object. Undefined and null
29
+ * values are dropped rather than sent as the strings "undefined" and "null",
30
+ * which is what a template literal would do.
31
+ * @param {string} apiUrl
32
+ * @param {string} path
33
+ * @param {Object} [query]
34
+ * @returns {string}
35
+ */
36
+ export function buildUrl(apiUrl, path, query) {
37
+ const url = new URL(path.replace(/^\/+/, ''), apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`);
38
+ for (const [key, value] of Object.entries(query || {})) {
39
+ if (value === undefined || value === null) continue;
40
+ url.searchParams.set(key, String(value));
41
+ }
42
+ return url.toString();
43
+ }
44
+
45
+ /**
46
+ * Read a response body without ever throwing. A 502 from a load balancer is
47
+ * HTML, a 204 is empty, and a JSON parse failure on the error path would
48
+ * replace a useful status code with a useless SyntaxError.
49
+ * @param {Response} response
50
+ * @returns {Promise<{json: unknown, text: string}>}
51
+ */
52
+ async function readBody(response) {
53
+ let text = '';
54
+ try {
55
+ text = await response.text();
56
+ } catch {
57
+ return { json: null, text: '' };
58
+ }
59
+ if (!text) return { json: null, text: '' };
60
+ try {
61
+ return { json: JSON.parse(text), text };
62
+ } catch {
63
+ return { json: null, text };
64
+ }
65
+ }
66
+
67
+ function errorFrom(response, parsed, { method, path }) {
68
+ const body = parsed.json;
69
+ const envelope = body && typeof body === 'object' ? body : {};
70
+ return new ApiError({
71
+ status: response.status,
72
+ code: typeof envelope.error === 'string' ? envelope.error : `http_${response.status}`,
73
+ message: typeof envelope.message === 'string' && envelope.message
74
+ ? envelope.message
75
+ : `${method} ${path} failed with ${response.status}`,
76
+ details: envelope.details,
77
+ body: body === null ? parsed.text || null : body,
78
+ retryAfter: parseRetryAfter(response.headers.get('retry-after')),
79
+ method,
80
+ path
81
+ });
82
+ }
83
+
84
+ /**
85
+ * One HTTP request, with retries.
86
+ *
87
+ * Retries cover a network failure, a timeout, 408, 425, 429 and 5xx, plus the
88
+ * two error codes whose status alone gives the wrong answer (see
89
+ * `codeRetryLimit`): `charge_in_progress` is retried on its own `Retry-After`
90
+ * although it is a 409, and `too_many_active_jobs` is not retried although it
91
+ * is a 429. A mutation is only ever retried when the caller says
92
+ * `idempotent: true`, which in practice means a GET, a heartbeat, or a POST
93
+ * carrying an `Idempotency-Key` the server uses to replay rather than repeat.
94
+ * Everything else fails on the first answer, because a second charge is worse
95
+ * than an error message.
96
+ *
97
+ * @param {Object} options
98
+ * @param {string} options.url
99
+ * @param {string} options.method
100
+ * @param {string} options.path - for error messages only
101
+ * @param {Object} [options.headers]
102
+ * @param {string} [options.bodyText]
103
+ * @param {boolean} [options.idempotent]
104
+ * @param {boolean} [options.raw] - resolve the body as text, not JSON
105
+ * @param {number} [options.retries]
106
+ * @param {number} [options.timeoutMs]
107
+ * @param {typeof fetch} [options.fetchImpl]
108
+ * @param {(info: {attempt: number, delayMs: number, error: ApiError}) => void} [options.onRetry]
109
+ * @param {(ms: number) => Promise<void>} [options.sleep]
110
+ * @param {() => number} [options.random]
111
+ * @returns {Promise<{status: number, headers: Headers, body: unknown}>}
112
+ */
113
+ export async function requestWithRetry(options) {
114
+ const {
115
+ url,
116
+ method,
117
+ path,
118
+ headers = {},
119
+ bodyText,
120
+ idempotent = false,
121
+ raw = false,
122
+ retries = DEFAULT_RETRIES,
123
+ timeoutMs = DEFAULT_TIMEOUT_MS,
124
+ fetchImpl = globalThis.fetch,
125
+ onRetry,
126
+ sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
127
+ random = Math.random
128
+ } = options;
129
+
130
+ const retryable = idempotent || method === 'GET' || method === 'HEAD';
131
+ let lastError = null;
132
+ // Two budgets, not one. `generalRetries` is the client's `retries` setting,
133
+ // spent on network failures and 5xx. `codeRetries` is the separate allowance
134
+ // a specific error code carries (see codeRetryLimit), which must not be
135
+ // shortened by a caller that set `retries: 0` for its own reasons, nor
136
+ // lengthened by one that set it to 20.
137
+ let generalRetries = 0;
138
+ const codeRetries = new Map();
139
+
140
+ for (let attempt = 1; ; attempt += 1) {
141
+ const controller = new AbortController();
142
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
143
+ let response = null;
144
+ try {
145
+ response = await fetchImpl(url, {
146
+ method,
147
+ headers,
148
+ body: bodyText === undefined ? undefined : bodyText,
149
+ signal: controller.signal
150
+ });
151
+ } catch (cause) {
152
+ lastError = new ApiError({
153
+ status: 0,
154
+ code: 'network_error',
155
+ message: `${method} ${path} could not reach ${new URL(url).host}: ${cause.message}`,
156
+ method,
157
+ path,
158
+ cause
159
+ });
160
+ } finally {
161
+ clearTimeout(timer);
162
+ }
163
+
164
+ if (response) {
165
+ const parsed = await readBody(response);
166
+ if (response.ok || response.status === 304) {
167
+ return {
168
+ status: response.status,
169
+ headers: response.headers,
170
+ body: raw ? parsed.text : parsed.json
171
+ };
172
+ }
173
+ lastError = errorFrom(response, parsed, { method, path });
174
+ }
175
+
176
+ if (!retryable) throw lastError;
177
+
178
+ const limit = codeRetryLimit(lastError);
179
+ let canRetry;
180
+ if (limit === null) {
181
+ canRetry = isRetryable(lastError) && generalRetries < retries;
182
+ if (canRetry) generalRetries += 1;
183
+ } else {
184
+ const used = codeRetries.get(lastError.code) || 0;
185
+ canRetry = used < limit;
186
+ if (canRetry) codeRetries.set(lastError.code, used + 1);
187
+ }
188
+ if (!canRetry) throw lastError;
189
+
190
+ const delayMs = backoffDelay(attempt, lastError.retryAfter, random);
191
+ if (onRetry) onRetry({ attempt, delayMs, error: lastError });
192
+ await sleep(delayMs);
193
+ }
194
+ }