@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.
@@ -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
- const cdnResponse = await fetch(data.location);
440
- if (!cdnResponse.ok) {
441
- throw new Error(
442
- `Download place CDN fetch failed: ${cdnResponse.status} ${cdnResponse.statusText}`
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
- const arrayBuffer = await cdnResponse.arrayBuffer();
447
- return Buffer.from(arrayBuffer);
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
- // Three attempts, each waiting 1s before the next (or before giving up).
144
- await vi.advanceTimersByTimeAsync(1000);
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 with jittered back-off (up to 3 attempts)
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
- const maxRetries = 3;
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
- for (let attempt = 0; attempt < maxRetries; attempt++) {
143
- const response = await fetch(url, init);
144
- this._updateFromHeaders(response.headers);
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 !== 429) {
164
+ if (response && !_isRetryableStatus(response.status)) {
147
165
  return response;
148
166
  }
149
167
 
150
- lastResponse = response;
168
+ if (response) {
169
+ lastResponse = response;
170
+ } else {
171
+ lastError = transportError;
172
+ }
151
173
 
152
- const retryAfter = response.headers.get('retry-after');
153
- const baseSec = retryAfter
154
- ? parseFloat(retryAfter)
155
- : 10 * (attempt + 1);
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
- `429 on ${route} (attempt ${
163
- attempt + 1
164
- }/${maxRetries}). Retrying in ${waitSec.toFixed(
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
- // All retries exhausted — return the last 429 response
172
- return lastResponse!;
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://experiences/start?placeId=${placeId}`;
50
+ const playUrl = `https://www.roblox.com/games/start?placeId=${placeId}`;
51
51
  return `[Open](${openUrl}) \\| [Play](${playUrl})`;
52
52
  },
53
53
  };