no-mistakes 0.17.0 → 0.19.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/package.json +1 -1
- package/scripts/install/download.js +43 -30
- package/scripts/install/index.js +2 -0
- package/scripts/install/retry.js +94 -0
package/package.json
CHANGED
|
@@ -7,23 +7,29 @@ const https = require("node:https");
|
|
|
7
7
|
const { pipeline } = require("node:stream/promises");
|
|
8
8
|
const { fileURLToPath } = require("node:url");
|
|
9
9
|
|
|
10
|
+
const { HttpError, withRetry } = require("./retry");
|
|
11
|
+
|
|
10
12
|
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
11
13
|
|
|
12
|
-
function download(url, destination, redirects = 0, validateUrl = () => {}) {
|
|
14
|
+
function download(url, destination, redirects = 0, validateUrl = () => {}, retryOptions) {
|
|
13
15
|
validateUrl(url);
|
|
14
16
|
if (isFileUrl(url)) {
|
|
15
17
|
return copyFile(fileURLToPath(url), destination);
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
return
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
return withRetry(
|
|
21
|
+
() =>
|
|
22
|
+
request(
|
|
23
|
+
url,
|
|
24
|
+
async (response) => {
|
|
25
|
+
await pipeline(response, createWriteStream(destination));
|
|
26
|
+
},
|
|
27
|
+
redirects,
|
|
28
|
+
{ http, https },
|
|
29
|
+
DOWNLOAD_TIMEOUT_MS,
|
|
30
|
+
validateUrl,
|
|
31
|
+
),
|
|
32
|
+
{ ...retryOptions, describe: () => `download of ${url}` },
|
|
27
33
|
);
|
|
28
34
|
}
|
|
29
35
|
|
|
@@ -67,7 +73,7 @@ function request(
|
|
|
67
73
|
|
|
68
74
|
if (response.statusCode !== 200) {
|
|
69
75
|
response.resume();
|
|
70
|
-
reject(new
|
|
76
|
+
reject(new HttpError(url, response.statusCode));
|
|
71
77
|
return;
|
|
72
78
|
}
|
|
73
79
|
|
|
@@ -75,7 +81,9 @@ function request(
|
|
|
75
81
|
});
|
|
76
82
|
|
|
77
83
|
req.setTimeout(timeoutMs, () => {
|
|
78
|
-
|
|
84
|
+
const timeoutError = new Error(`Download timed out after ${timeoutMs}ms: ${url}`);
|
|
85
|
+
timeoutError.retryable = true;
|
|
86
|
+
req.destroy(timeoutError);
|
|
79
87
|
});
|
|
80
88
|
req.on("error", reject);
|
|
81
89
|
});
|
|
@@ -85,32 +93,37 @@ function isRedirectStatus(statusCode) {
|
|
|
85
93
|
return [301, 302, 303, 307, 308].includes(statusCode);
|
|
86
94
|
}
|
|
87
95
|
|
|
88
|
-
async function fetchText(url, validateUrl = () => {}) {
|
|
96
|
+
async function fetchText(url, validateUrl = () => {}, retryOptions) {
|
|
89
97
|
validateUrl(url);
|
|
90
98
|
|
|
91
99
|
if (isFileUrl(url)) {
|
|
92
100
|
return readFile(fileURLToPath(url), "utf8");
|
|
93
101
|
}
|
|
94
|
-
const chunks = [];
|
|
95
|
-
let totalLength = 0;
|
|
96
102
|
const MAX_LENGTH = 1024 * 1024;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
103
|
+
return withRetry(
|
|
104
|
+
async () => {
|
|
105
|
+
const chunks = [];
|
|
106
|
+
let totalLength = 0;
|
|
107
|
+
await request(
|
|
108
|
+
url,
|
|
109
|
+
async (response) => {
|
|
110
|
+
for await (const chunk of response) {
|
|
111
|
+
totalLength += chunk.length;
|
|
112
|
+
if (totalLength > MAX_LENGTH) {
|
|
113
|
+
throw new Error(`Response exceeded maximum size of ${MAX_LENGTH} bytes`);
|
|
114
|
+
}
|
|
115
|
+
chunks.push(chunk);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
0,
|
|
119
|
+
{ http, https },
|
|
120
|
+
DOWNLOAD_TIMEOUT_MS,
|
|
121
|
+
validateUrl,
|
|
122
|
+
);
|
|
123
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
107
124
|
},
|
|
108
|
-
|
|
109
|
-
{ http, https },
|
|
110
|
-
DOWNLOAD_TIMEOUT_MS,
|
|
111
|
-
validateUrl,
|
|
125
|
+
{ ...retryOptions, describe: () => `fetch of ${url}` },
|
|
112
126
|
);
|
|
113
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
114
127
|
}
|
|
115
128
|
|
|
116
129
|
function isFileUrl(url) {
|
package/scripts/install/index.js
CHANGED
|
@@ -4,10 +4,12 @@ const assets = require("./assets");
|
|
|
4
4
|
const download = require("./download");
|
|
5
5
|
const installer = require("./installer");
|
|
6
6
|
const platform = require("./platform");
|
|
7
|
+
const retry = require("./retry");
|
|
7
8
|
|
|
8
9
|
module.exports = {
|
|
9
10
|
...assets,
|
|
10
11
|
...download,
|
|
11
12
|
...installer,
|
|
12
13
|
...platform,
|
|
14
|
+
...retry,
|
|
13
15
|
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MAX_ATTEMPTS = 4;
|
|
4
|
+
const DEFAULT_RETRY_BASE_MS = 500;
|
|
5
|
+
const RETRY_MAX_DELAY_MS = 4_000;
|
|
6
|
+
const RETRYABLE_NETWORK_CODES = new Set([
|
|
7
|
+
"ECONNRESET",
|
|
8
|
+
"ECONNREFUSED",
|
|
9
|
+
"ETIMEDOUT",
|
|
10
|
+
"EAI_AGAIN",
|
|
11
|
+
"ENETUNREACH",
|
|
12
|
+
"EPIPE",
|
|
13
|
+
"ERR_STREAM_PREMATURE_CLOSE",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
class HttpError extends Error {
|
|
17
|
+
constructor(url, statusCode) {
|
|
18
|
+
super(`Download failed for ${url}: HTTP ${statusCode}`);
|
|
19
|
+
this.name = "HttpError";
|
|
20
|
+
this.statusCode = statusCode;
|
|
21
|
+
this.retryable = isRetryableStatus(statusCode);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isRetryableStatus(statusCode) {
|
|
26
|
+
if (typeof statusCode !== "number") return false;
|
|
27
|
+
if (statusCode >= 500 && statusCode < 600) return true;
|
|
28
|
+
return statusCode === 408 || statusCode === 429;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isRetryableError(error) {
|
|
32
|
+
if (!error) return false;
|
|
33
|
+
if (error.retryable === true) return true;
|
|
34
|
+
if (error.code && RETRYABLE_NETWORK_CODES.has(error.code)) return true;
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sleep(ms) {
|
|
39
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parsePositiveInt(value, fallback) {
|
|
43
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
44
|
+
const n = Number(value);
|
|
45
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) return fallback;
|
|
46
|
+
return n;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function computeBackoffMs(attempt, baseDelayMs, random = Math.random) {
|
|
50
|
+
const exponential = baseDelayMs * 2 ** (attempt - 1);
|
|
51
|
+
const capped = Math.min(exponential, RETRY_MAX_DELAY_MS);
|
|
52
|
+
return Math.floor(random() * capped);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function withRetry(fn, options = {}) {
|
|
56
|
+
const maxAttempts = parsePositiveInt(
|
|
57
|
+
options.maxAttempts ?? process.env.NO_MISTAKES_DOWNLOAD_MAX_ATTEMPTS,
|
|
58
|
+
DEFAULT_MAX_ATTEMPTS,
|
|
59
|
+
);
|
|
60
|
+
const baseDelayMs = parsePositiveInt(
|
|
61
|
+
options.baseDelayMs ?? process.env.NO_MISTAKES_DOWNLOAD_RETRY_BASE_MS,
|
|
62
|
+
DEFAULT_RETRY_BASE_MS,
|
|
63
|
+
);
|
|
64
|
+
const delay = options.delay ?? sleep;
|
|
65
|
+
const random = options.random ?? Math.random;
|
|
66
|
+
const logger = options.logger ?? console;
|
|
67
|
+
const describe = options.describe ?? (() => "download");
|
|
68
|
+
|
|
69
|
+
let attempt = 0;
|
|
70
|
+
for (;;) {
|
|
71
|
+
attempt += 1;
|
|
72
|
+
try {
|
|
73
|
+
return await fn();
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (attempt >= maxAttempts || !isRetryableError(error)) {
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
const waitMs = computeBackoffMs(attempt, baseDelayMs, random);
|
|
79
|
+
logger.warn(
|
|
80
|
+
`no-mistakes: retrying ${describe()} after ${error.message} (attempt ${attempt + 1}/${maxAttempts}, waiting ~${waitMs}ms)`,
|
|
81
|
+
);
|
|
82
|
+
await delay(waitMs);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = {
|
|
88
|
+
HttpError,
|
|
89
|
+
computeBackoffMs,
|
|
90
|
+
isRetryableError,
|
|
91
|
+
isRetryableStatus,
|
|
92
|
+
parsePositiveInt,
|
|
93
|
+
withRetry,
|
|
94
|
+
};
|