@scrymore/scry-deployer 0.3.0 → 0.3.1

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 (2) hide show
  1. package/lib/apiClient.js +84 -3
  2. package/package.json +1 -1
package/lib/apiClient.js CHANGED
@@ -16,10 +16,83 @@ const logger = createLogger({ verbose: isVerbose });
16
16
  const COVERAGE_UPLOAD_DELAY_MS = 5000;
17
17
  const COVERAGE_RETRY_DELAY_MS = 60000;
18
18
 
19
+ // The upload is the last step of a deploy, so a blip here discards several
20
+ // minutes of completed screenshot capture. Retrying is far cheaper than
21
+ // redoing that work. See ISSUES.md #19.
22
+ const UPLOAD_MAX_ATTEMPTS = 4;
23
+ const UPLOAD_BACKOFF_BASE_MS = 2000;
24
+
25
+ // Network-level failures that a later attempt can plausibly survive. Flapping
26
+ // DNS reports EAI_AGAIN (and, mid-flap, ENOTFOUND) rather than a clean refusal.
27
+ const TRANSIENT_CODES = new Set([
28
+ 'EAI_AGAIN',
29
+ 'ENOTFOUND',
30
+ 'ECONNRESET',
31
+ 'ECONNREFUSED',
32
+ 'ETIMEDOUT',
33
+ 'ECONNABORTED',
34
+ 'EPIPE',
35
+ 'EHOSTUNREACH',
36
+ 'ENETUNREACH',
37
+ 'ERR_NETWORK',
38
+ ]);
39
+
19
40
  function sleep(ms) {
20
41
  return new Promise((resolve) => setTimeout(resolve, ms));
21
42
  }
22
43
 
44
+ /**
45
+ * Whether a failed upload attempt is worth repeating.
46
+ *
47
+ * A 4xx other than 429 means the request itself is wrong — retrying it just
48
+ * burns time and produces the identical failure.
49
+ *
50
+ * @param {Error & {code?: string, response?: {status?: number}}} error
51
+ * @returns {boolean}
52
+ */
53
+ function isTransientUploadError(error) {
54
+ const status = error?.response?.status;
55
+ if (typeof status === 'number') {
56
+ return status === 429 || status >= 500;
57
+ }
58
+ return TRANSIENT_CODES.has(error?.code);
59
+ }
60
+
61
+ /**
62
+ * Run an upload attempt, repeating it while the failure looks transient.
63
+ *
64
+ * @template T
65
+ * @param {(attempt: number) => Promise<T>} attemptFn
66
+ * @param {string} label Shown in logs so a retried upload does not look like a hang.
67
+ * @returns {Promise<T>}
68
+ */
69
+ async function withUploadRetry(attemptFn, label) {
70
+ let lastError;
71
+
72
+ for (let attempt = 1; attempt <= UPLOAD_MAX_ATTEMPTS; attempt++) {
73
+ try {
74
+ return await attemptFn(attempt);
75
+ } catch (error) {
76
+ lastError = error;
77
+
78
+ if (!isTransientUploadError(error) || attempt === UPLOAD_MAX_ATTEMPTS) {
79
+ throw error;
80
+ }
81
+
82
+ const backoff = UPLOAD_BACKOFF_BASE_MS * Math.pow(2, attempt - 1);
83
+ const reason = error.code || `HTTP ${error?.response?.status}`;
84
+ // Deliberately at info level: a silent retry is indistinguishable from a
85
+ // hung deploy, which is its own reported failure mode.
86
+ logger.info(
87
+ `${label} attempt ${attempt}/${UPLOAD_MAX_ATTEMPTS} failed (${reason}); retrying in ${backoff / 1000}s...`
88
+ );
89
+ await sleep(backoff);
90
+ }
91
+ }
92
+
93
+ throw lastError;
94
+ }
95
+
23
96
  /**
24
97
  * Creates a pre-configured axios instance for making API calls.
25
98
  * @param {string} apiUrl The base URL of the API.
@@ -205,9 +278,14 @@ async function uploadFileDirectly(apiClient, { project, version }, filePath, fil
205
278
  const contentType = file.contentType || 'application/zip';
206
279
 
207
280
  try {
208
- const presigned = await requestPresignedUrl(apiClient, { project, version }, { fileName, contentType });
209
- const upload = await putToPresignedUrl(presigned.url, fileBuffer, contentType);
210
- return { success: true, url: presigned.url, status: upload.status, visibility: presigned.visibility };
281
+ // The presigned URL is signed at request time, so a retry after a long
282
+ // backoff must re-request it reusing a stale one fails with a confusing
283
+ // signature error instead of the real network cause.
284
+ return await withUploadRetry(async () => {
285
+ const presigned = await requestPresignedUrl(apiClient, { project, version }, { fileName, contentType });
286
+ const upload = await putToPresignedUrl(presigned.url, fileBuffer, contentType);
287
+ return { success: true, url: presigned.url, status: upload.status, visibility: presigned.visibility };
288
+ }, `Upload of ${fileName}`);
211
289
  } catch (error) {
212
290
  logger.debug(`Upload failed. Error type: ${error.constructor.name}, Message: ${error.message}`);
213
291
  const details = getAxiosErrorDetails(error, apiClient.defaults.baseURL);
@@ -374,4 +452,7 @@ module.exports = {
374
452
  uploadBuild,
375
453
  requestPresignedUrl,
376
454
  putToPresignedUrl,
455
+ isTransientUploadError,
456
+ withUploadRetry,
457
+ UPLOAD_MAX_ATTEMPTS,
377
458
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrymore/scry-deployer",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "A CLI to automate the deployment of Storybook static builds.",
5
5
  "main": "index.js",
6
6
  "bin": {