@quenty/nevermore-cli 4.30.0 → 4.31.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/CHANGELOG.md +11 -0
- package/dist/utils/deploy/deploy-github-columns.js +1 -1
- package/dist/utils/deploy/deploy-github-columns.js.map +1 -1
- package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/base-job-context.js +7 -1
- package/dist/utils/job-context/base-job-context.js.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.d.ts +1 -1
- package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.js +53 -6
- package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
- package/dist/utils/open-cloud/rate-limiter.d.ts +9 -1
- package/dist/utils/open-cloud/rate-limiter.d.ts.map +1 -1
- package/dist/utils/open-cloud/rate-limiter.js +71 -19
- package/dist/utils/open-cloud/rate-limiter.js.map +1 -1
- package/dist/utils/open-cloud/rate-limiter.test.js +38 -4
- package/dist/utils/open-cloud/rate-limiter.test.js.map +1 -1
- package/dist/utils/testing/reporting/test-github-columns.js +1 -1
- package/dist/utils/testing/reporting/test-github-columns.js.map +1 -1
- package/package.json +6 -6
- package/src/utils/deploy/deploy-github-columns.ts +1 -1
- package/src/utils/job-context/base-job-context.ts +8 -1
- package/src/utils/open-cloud/open-cloud-client.ts +72 -7
- package/src/utils/open-cloud/rate-limiter.test.ts +52 -4
- package/src/utils/open-cloud/rate-limiter.ts +86 -24
- package/src/utils/testing/reporting/test-github-columns.ts +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -392,7 +392,8 @@ export class OpenCloudClient {
|
|
|
392
392
|
*/
|
|
393
393
|
async downloadPlaceAsync(
|
|
394
394
|
universeId: number,
|
|
395
|
-
placeId: number
|
|
395
|
+
placeId: number,
|
|
396
|
+
onProgress?: (transferredBytes: number, totalBytes: number) => void
|
|
396
397
|
): Promise<Buffer> {
|
|
397
398
|
const apiKey = await this._resolveApiKeyAsync();
|
|
398
399
|
const url = `https://apis.roblox.com/asset-delivery-api/v1/assetId/${placeId}`;
|
|
@@ -436,14 +437,78 @@ export class OpenCloudClient {
|
|
|
436
437
|
}
|
|
437
438
|
|
|
438
439
|
OutputHelper.verbose('Fetching place binary from CDN...');
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
440
|
+
return _fetchCdnBinaryAsync(data.location, onProgress);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const CDN_MAX_ATTEMPTS = 4;
|
|
445
|
+
|
|
446
|
+
async function _fetchCdnBinaryAsync(
|
|
447
|
+
url: string,
|
|
448
|
+
onProgress?: (transferredBytes: number, totalBytes: number) => void
|
|
449
|
+
): Promise<Buffer> {
|
|
450
|
+
let lastError: unknown = null;
|
|
451
|
+
for (let attempt = 1; attempt <= CDN_MAX_ATTEMPTS; attempt++) {
|
|
452
|
+
try {
|
|
453
|
+
const response = await fetch(url);
|
|
454
|
+
if (response.ok) {
|
|
455
|
+
return await _readBodyWithProgressAsync(response, onProgress);
|
|
456
|
+
}
|
|
457
|
+
lastError = new Error(
|
|
458
|
+
`Download place CDN fetch failed: ${response.status} ${response.statusText}`
|
|
443
459
|
);
|
|
460
|
+
// 4xx (other than 408/429) won't fix itself — stop retrying.
|
|
461
|
+
if (
|
|
462
|
+
response.status >= 400 &&
|
|
463
|
+
response.status < 500 &&
|
|
464
|
+
response.status !== 408 &&
|
|
465
|
+
response.status !== 429
|
|
466
|
+
) {
|
|
467
|
+
throw lastError;
|
|
468
|
+
}
|
|
469
|
+
} catch (err) {
|
|
470
|
+
lastError = err;
|
|
444
471
|
}
|
|
445
472
|
|
|
446
|
-
|
|
447
|
-
|
|
473
|
+
if (attempt === CDN_MAX_ATTEMPTS) {
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
const waitMs = Math.min(8000, 500 * Math.pow(2, attempt - 1));
|
|
477
|
+
OutputHelper.warn(
|
|
478
|
+
`CDN download attempt ${attempt}/${CDN_MAX_ATTEMPTS} failed (${
|
|
479
|
+
lastError instanceof Error ? lastError.message : String(lastError)
|
|
480
|
+
}). Retrying in ${(waitMs / 1000).toFixed(1)}s...`
|
|
481
|
+
);
|
|
482
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
483
|
+
}
|
|
484
|
+
throw lastError ?? new Error('Download place CDN fetch failed');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function _readBodyWithProgressAsync(
|
|
488
|
+
response: Response,
|
|
489
|
+
onProgress?: (transferredBytes: number, totalBytes: number) => void
|
|
490
|
+
): Promise<Buffer> {
|
|
491
|
+
if (!onProgress || !response.body) {
|
|
492
|
+
return Buffer.from(await response.arrayBuffer());
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Content-Length may be absent (chunked transfer); report 0 in that case
|
|
496
|
+
// and let the formatter render "transferred so far" without a denominator.
|
|
497
|
+
const contentLength = response.headers.get('content-length');
|
|
498
|
+
const totalBytes = contentLength ? parseInt(contentLength, 10) : 0;
|
|
499
|
+
|
|
500
|
+
const chunks: Uint8Array[] = [];
|
|
501
|
+
let transferred = 0;
|
|
502
|
+
const reader = response.body.getReader();
|
|
503
|
+
onProgress(0, totalBytes);
|
|
504
|
+
|
|
505
|
+
while (true) {
|
|
506
|
+
const { done, value } = await reader.read();
|
|
507
|
+
if (done) break;
|
|
508
|
+
chunks.push(value);
|
|
509
|
+
transferred += value.byteLength;
|
|
510
|
+
onProgress(transferred, totalBytes);
|
|
448
511
|
}
|
|
512
|
+
|
|
513
|
+
return Buffer.concat(chunks);
|
|
449
514
|
}
|
|
@@ -132,16 +132,16 @@ describe('RateLimiter', () => {
|
|
|
132
132
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
-
it('returns the last 429 response after exhausting all retries', async () => {
|
|
135
|
+
it('returns the last 429 response after exhausting all retries without sleeping past the final attempt', async () => {
|
|
136
136
|
vi.useFakeTimers();
|
|
137
|
-
const limiter = new RateLimiter();
|
|
137
|
+
const limiter = new RateLimiter({ maxRetries: 3 });
|
|
138
138
|
|
|
139
139
|
fetchMock.mockResolvedValue(makeResponse(429, { 'retry-after': '1' }));
|
|
140
140
|
|
|
141
141
|
const promise = limiter.fetchAsync('https://apis.roblox.com/cloud/v2/a');
|
|
142
142
|
|
|
143
|
-
//
|
|
144
|
-
|
|
143
|
+
// Two backoffs between three attempts. After the third failure the
|
|
144
|
+
// limiter must return immediately rather than sleeping pointlessly.
|
|
145
145
|
await vi.advanceTimersByTimeAsync(1000);
|
|
146
146
|
await vi.advanceTimersByTimeAsync(1000);
|
|
147
147
|
|
|
@@ -149,4 +149,52 @@ describe('RateLimiter', () => {
|
|
|
149
149
|
expect(response.status).toBe(429);
|
|
150
150
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
151
151
|
});
|
|
152
|
+
|
|
153
|
+
it('retries on 503 with backoff before returning success', async () => {
|
|
154
|
+
vi.useFakeTimers();
|
|
155
|
+
const limiter = new RateLimiter();
|
|
156
|
+
|
|
157
|
+
fetchMock
|
|
158
|
+
.mockResolvedValueOnce(makeResponse(503))
|
|
159
|
+
.mockResolvedValueOnce(makeResponse(200));
|
|
160
|
+
|
|
161
|
+
const promise = limiter.fetchAsync('https://apis.roblox.com/cloud/v2/a');
|
|
162
|
+
|
|
163
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
164
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
165
|
+
|
|
166
|
+
// Exponential fallback for attempt 0 is 2^0 = 1s.
|
|
167
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
168
|
+
const response = await promise;
|
|
169
|
+
expect(response.status).toBe(200);
|
|
170
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('retries on transport errors and surfaces the last error if all attempts reject', async () => {
|
|
174
|
+
vi.useFakeTimers();
|
|
175
|
+
const limiter = new RateLimiter({ maxRetries: 2 });
|
|
176
|
+
|
|
177
|
+
fetchMock.mockRejectedValue(new TypeError('ECONNRESET'));
|
|
178
|
+
|
|
179
|
+
const promise = limiter.fetchAsync('https://apis.roblox.com/cloud/v2/a');
|
|
180
|
+
const settled = promise.catch((err) => err);
|
|
181
|
+
|
|
182
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
183
|
+
|
|
184
|
+
const err = await settled;
|
|
185
|
+
expect(err).toBeInstanceOf(TypeError);
|
|
186
|
+
expect((err as Error).message).toBe('ECONNRESET');
|
|
187
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('does not retry on non-retryable 4xx responses', async () => {
|
|
191
|
+
const limiter = new RateLimiter();
|
|
192
|
+
fetchMock.mockResolvedValueOnce(makeResponse(404));
|
|
193
|
+
|
|
194
|
+
const response = await limiter.fetchAsync(
|
|
195
|
+
'https://apis.roblox.com/cloud/v2/a'
|
|
196
|
+
);
|
|
197
|
+
expect(response.status).toBe(404);
|
|
198
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
199
|
+
});
|
|
152
200
|
});
|
|
@@ -19,7 +19,9 @@ function _extractRoute(url: string | URL | Request): string {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
const DEFAULT_MAX_CONCURRENCY = 4;
|
|
22
|
+
const DEFAULT_MAX_RETRIES = 6;
|
|
22
23
|
const RETRY_JITTER_FRACTION = 0.3;
|
|
24
|
+
const RETRYABLE_SERVER_ERROR_STATUSES = new Set([502, 503, 504]);
|
|
23
25
|
|
|
24
26
|
export interface RateLimiterOptions {
|
|
25
27
|
/**
|
|
@@ -28,6 +30,12 @@ export interface RateLimiterOptions {
|
|
|
28
30
|
* keeping a tight feedback loop on `x-ratelimit-remaining`.
|
|
29
31
|
*/
|
|
30
32
|
maxConcurrency?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Total attempts per request before giving up (including the initial try).
|
|
35
|
+
* Defaults to 6, sized to ride out the Luau-execution endpoint's burst
|
|
36
|
+
* windows during a multi-place deploy.
|
|
37
|
+
*/
|
|
38
|
+
maxRetries?: number;
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
/**
|
|
@@ -48,12 +56,14 @@ export class RateLimiter {
|
|
|
48
56
|
private _queue: Array<() => void> = [];
|
|
49
57
|
private _inflight = 0;
|
|
50
58
|
private _maxConcurrency: number;
|
|
59
|
+
private _maxRetries: number;
|
|
51
60
|
|
|
52
61
|
constructor(options: RateLimiterOptions = {}) {
|
|
53
62
|
this._maxConcurrency = Math.max(
|
|
54
63
|
1,
|
|
55
64
|
options.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY
|
|
56
65
|
);
|
|
66
|
+
this._maxRetries = Math.max(1, options.maxRetries ?? DEFAULT_MAX_RETRIES);
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
private _updateFromHeaders(headers: Headers): void {
|
|
@@ -122,7 +132,8 @@ export class RateLimiter {
|
|
|
122
132
|
* - Caps concurrency at `_maxConcurrency` (default 4) so we keep getting
|
|
123
133
|
* header feedback before each new wave fans out
|
|
124
134
|
* - Delays if we know we're out of quota
|
|
125
|
-
* - Retries on 429
|
|
135
|
+
* - Retries on 429, retryable 5xx (502/503/504), and transport errors
|
|
136
|
+
* with jittered back-off
|
|
126
137
|
* - Tracks rate-limit headers from every response
|
|
127
138
|
*/
|
|
128
139
|
async fetchAsync(
|
|
@@ -134,44 +145,95 @@ export class RateLimiter {
|
|
|
134
145
|
const route = _extractRoute(url);
|
|
135
146
|
|
|
136
147
|
try {
|
|
137
|
-
await this._waitIfNeededAsync(route);
|
|
138
|
-
|
|
139
148
|
let lastResponse: Response | null = null;
|
|
140
|
-
|
|
149
|
+
let lastError: unknown = null;
|
|
150
|
+
|
|
151
|
+
for (let attempt = 0; attempt < this._maxRetries; attempt++) {
|
|
152
|
+
await this._waitIfNeededAsync(route);
|
|
153
|
+
|
|
154
|
+
let response: Response | null = null;
|
|
155
|
+
let transportError: unknown = null;
|
|
141
156
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
157
|
+
try {
|
|
158
|
+
response = await fetch(url, init);
|
|
159
|
+
this._updateFromHeaders(response.headers);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
transportError = err;
|
|
162
|
+
}
|
|
145
163
|
|
|
146
|
-
if (response.status
|
|
164
|
+
if (response && !_isRetryableStatus(response.status)) {
|
|
147
165
|
return response;
|
|
148
166
|
}
|
|
149
167
|
|
|
150
|
-
|
|
168
|
+
if (response) {
|
|
169
|
+
lastResponse = response;
|
|
170
|
+
} else {
|
|
171
|
+
lastError = transportError;
|
|
172
|
+
}
|
|
151
173
|
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
// Additive jitter only — never wait less than retry-after asked for,
|
|
157
|
-
// but desynchronize concurrent callers so they don't all retry on the
|
|
158
|
-
// exact same millisecond and re-collide on the next attempt.
|
|
159
|
-
const waitSec = baseSec * (1 + Math.random() * RETRY_JITTER_FRACTION);
|
|
174
|
+
const isFinalAttempt = attempt === this._maxRetries - 1;
|
|
175
|
+
if (isFinalAttempt) {
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
160
178
|
|
|
179
|
+
const waitSec = _computeWaitSeconds(response, attempt);
|
|
180
|
+
const reason = response
|
|
181
|
+
? `${response.status} on ${route}`
|
|
182
|
+
: `transport error on ${route} (${_describeError(transportError)})`;
|
|
161
183
|
OutputHelper.warn(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
1
|
|
166
|
-
)}s (Roblox requested)...`
|
|
184
|
+
`${reason} (attempt ${attempt + 1}/${
|
|
185
|
+
this._maxRetries
|
|
186
|
+
}). Retrying in ${waitSec.toFixed(1)}s...`
|
|
167
187
|
);
|
|
168
188
|
await new Promise((resolve) => setTimeout(resolve, waitSec * 1000));
|
|
169
189
|
}
|
|
170
190
|
|
|
171
|
-
|
|
172
|
-
|
|
191
|
+
if (lastResponse) {
|
|
192
|
+
return lastResponse;
|
|
193
|
+
}
|
|
194
|
+
throw lastError ?? new Error(`Request to ${route} failed`);
|
|
173
195
|
} finally {
|
|
174
196
|
this._release();
|
|
175
197
|
}
|
|
176
198
|
}
|
|
177
199
|
}
|
|
200
|
+
|
|
201
|
+
function _isRetryableStatus(status: number): boolean {
|
|
202
|
+
return status === 429 || RETRYABLE_SERVER_ERROR_STATUSES.has(status);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function _computeWaitSeconds(
|
|
206
|
+
response: Response | null,
|
|
207
|
+
attempt: number
|
|
208
|
+
): number {
|
|
209
|
+
const retryAfter = response?.headers.get('retry-after');
|
|
210
|
+
const parsed = retryAfter != null ? _parseRetryAfter(retryAfter) : null;
|
|
211
|
+
// Exponential fallback capped at 30s — keeps multi-place deploys from
|
|
212
|
+
// ballooning to minutes of compounded backoff while still spacing out
|
|
213
|
+
// collisions when the server doesn't pin a retry-after.
|
|
214
|
+
const baseSec = parsed ?? Math.min(30, Math.pow(2, attempt));
|
|
215
|
+
// Additive jitter only — never wait less than retry-after asked for, but
|
|
216
|
+
// desynchronize concurrent callers so they don't all retry on the exact
|
|
217
|
+
// same millisecond and re-collide on the next attempt.
|
|
218
|
+
return baseSec * (1 + Math.random() * RETRY_JITTER_FRACTION);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function _parseRetryAfter(value: string): number | null {
|
|
222
|
+
const trimmed = value.trim();
|
|
223
|
+
const asSeconds = Number(trimmed);
|
|
224
|
+
if (Number.isFinite(asSeconds) && asSeconds >= 0) {
|
|
225
|
+
return asSeconds;
|
|
226
|
+
}
|
|
227
|
+
const asDateMs = Date.parse(trimmed);
|
|
228
|
+
if (Number.isFinite(asDateMs)) {
|
|
229
|
+
return Math.max(0, (asDateMs - Date.now()) / 1000);
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function _describeError(err: unknown): string {
|
|
235
|
+
if (err instanceof Error) {
|
|
236
|
+
return err.message;
|
|
237
|
+
}
|
|
238
|
+
return String(err);
|
|
239
|
+
}
|
|
@@ -47,7 +47,7 @@ function createTryItColumn(): GithubCommentColumn {
|
|
|
47
47
|
return '';
|
|
48
48
|
}
|
|
49
49
|
const openUrl = `https://www.roblox.com/games/${placeId}`;
|
|
50
|
-
const playUrl = `roblox
|
|
50
|
+
const playUrl = `https://www.roblox.com/games/start?placeId=${placeId}`;
|
|
51
51
|
return `[Open](${openUrl}) \\| [Play](${playUrl})`;
|
|
52
52
|
},
|
|
53
53
|
};
|