@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.
- package/lib/apiClient.js +84 -3
- 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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
};
|