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.
- package/LICENSE +21 -0
- package/README.md +380 -0
- package/addons/ravensight_driver/driver.gd +836 -0
- package/addons/ravensight_driver/export_plugin.gd +51 -0
- package/addons/ravensight_driver/plugin.cfg +7 -0
- package/addons/ravensight_driver/plugin.gd +36 -0
- package/bin/ravensight-playtest.js +31 -0
- package/package.json +45 -0
- package/src/api/README.md +500 -0
- package/src/api/client.js +340 -0
- package/src/api/errors.js +115 -0
- package/src/api/http.js +194 -0
- package/src/api/index.js +107 -0
- package/src/auth/deviceCode.js +79 -0
- package/src/auth/keychain.js +159 -0
- package/src/auth/session.js +128 -0
- package/src/cli.js +335 -0
- package/src/commands/brief.js +303 -0
- package/src/commands/check.js +318 -0
- package/src/commands/fakeCore.js +379 -0
- package/src/commands/init.js +120 -0
- package/src/commands/login.js +90 -0
- package/src/commands/logout.js +70 -0
- package/src/commands/open.js +125 -0
- package/src/commands/profile.js +262 -0
- package/src/commands/resume.js +156 -0
- package/src/commands/run.js +1015 -0
- package/src/commands/upload.js +137 -0
- package/src/config.js +100 -0
- package/src/dashboard.js +97 -0
- package/src/detect.js +77 -0
- package/src/errors.js +44 -0
- package/src/fsutil.js +77 -0
- package/src/godot.js +85 -0
- package/src/packs/index.js +191 -0
- package/src/paths.js +129 -0
- package/src/run/aggregate.js +658 -0
- package/src/run/args.js +111 -0
- package/src/run/context.js +181 -0
- package/src/run/deps.js +184 -0
- package/src/run/drivers/driver.js +183 -0
- package/src/run/drivers/godot-observation.js +138 -0
- package/src/run/drivers/godot-project.js +475 -0
- package/src/run/drivers/godot-rpc.js +225 -0
- package/src/run/drivers/godot.js +587 -0
- package/src/run/drivers/index.js +52 -0
- package/src/run/drivers/web.js +385 -0
- package/src/run/exit.js +21 -0
- package/src/run/heartbeat.js +131 -0
- package/src/run/index.js +31 -0
- package/src/run/json.js +56 -0
- package/src/run/model.js +384 -0
- package/src/run/paths.js +88 -0
- package/src/run/personaLoop.js +871 -0
- package/src/run/profile.js +214 -0
- package/src/run/regenerate.js +149 -0
- package/src/run/repoTools.js +286 -0
- package/src/run/report.js +222 -0
- package/src/run/resume.js +272 -0
- package/src/run/secretScan.js +171 -0
- package/src/run/state.js +198 -0
- package/src/run/synthetic.js +206 -0
- package/src/run/tools.js +344 -0
- package/src/run/transcript.js +93 -0
- package/src/run/usage.js +115 -0
- package/src/state/index.js +105 -0
- package/src/states.js +104 -0
- package/src/ui/index.js +195 -0
- package/src/upload/allowlist.js +116 -0
- package/src/upload/index.js +467 -0
- package/src/upload/queue.js +114 -0
- 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
|
+
}
|
package/src/api/http.js
ADDED
|
@@ -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
|
+
}
|