@forwardimpact/libutil 0.1.60
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/LICENSE +201 -0
- package/bin/fit-download-bundle.js +24 -0
- package/downloader.js +108 -0
- package/extractor.js +330 -0
- package/finder.js +148 -0
- package/http.js +21 -0
- package/index.js +154 -0
- package/package.json +24 -0
- package/processor.js +125 -0
- package/retry.js +126 -0
- package/test/downloader.test.js +223 -0
- package/test/extractor.test.js +338 -0
- package/test/finder.test.js +285 -0
- package/test/fixtures/sample.tar.gz +0 -0
- package/test/fixtures/sample.zip +0 -0
- package/test/http.test.js +93 -0
- package/test/libutil.test.js +38 -0
- package/test/logger.test.js +322 -0
- package/test/processor.test.js +140 -0
- package/test/retry.test.js +194 -0
- package/test/tokenizer.test.js +123 -0
- package/test/wait.test.js +89 -0
- package/tokenizer.js +89 -0
- package/wait.js +28 -0
package/finder.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import fsAsync from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Finder class for project path resolution and symlink management
|
|
8
|
+
* Handles filesystem operations for linking generated code to packages
|
|
9
|
+
*/
|
|
10
|
+
export class Finder {
|
|
11
|
+
#logger;
|
|
12
|
+
#process;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Creates a new Finder instance
|
|
16
|
+
* @param {object} fs - Filesystem module (fs/promises)
|
|
17
|
+
* @param {object} logger - Logger instance for debug output
|
|
18
|
+
* @param {object} process - Process environment access (for testing)
|
|
19
|
+
*/
|
|
20
|
+
constructor(fs, logger, process = global.process) {
|
|
21
|
+
if (!fs) throw new Error("fs is required");
|
|
22
|
+
if (!logger) throw new Error("logger is required");
|
|
23
|
+
if (!process) throw new Error("process is required");
|
|
24
|
+
|
|
25
|
+
this.#logger = logger;
|
|
26
|
+
this.#process = process;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Searches upward from one or more roots for a target file or directory.
|
|
31
|
+
* @param {string} root - Starting directory to search from
|
|
32
|
+
* @param {string} relativePath - Relative path to append while traversing upward
|
|
33
|
+
* @param {number} maxDepth - Maximum parent levels to check
|
|
34
|
+
* @returns {string|null} Found absolute path or null
|
|
35
|
+
*/
|
|
36
|
+
findUpward(root, relativePath, maxDepth = 3) {
|
|
37
|
+
let current = root;
|
|
38
|
+
for (let depth = 0; depth < maxDepth; depth++) {
|
|
39
|
+
const candidate = path.join(current, relativePath);
|
|
40
|
+
if (fs.existsSync(candidate)) {
|
|
41
|
+
return candidate;
|
|
42
|
+
}
|
|
43
|
+
const parent = path.dirname(current);
|
|
44
|
+
if (parent === current) break;
|
|
45
|
+
current = parent;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Find the project root directory
|
|
52
|
+
* @param {string} startPath - Starting directory path
|
|
53
|
+
* @returns {string} Project root directory path
|
|
54
|
+
*/
|
|
55
|
+
findProjectRoot(startPath) {
|
|
56
|
+
const projectRoot = this.findUpward(startPath, "package.json", 5);
|
|
57
|
+
if (projectRoot) {
|
|
58
|
+
return path.dirname(projectRoot);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
throw new Error("Could not find project root");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the actual filesystem path to a package
|
|
66
|
+
* Works both in monorepo (./packages) and when installed as dependency
|
|
67
|
+
* @param {string} projectRoot - Project root directory path
|
|
68
|
+
* @param {"libtype"|"librpc"} packageName - Package name without scope
|
|
69
|
+
* @returns {string} Absolute path to package directory
|
|
70
|
+
*/
|
|
71
|
+
findPackagePath(projectRoot, packageName) {
|
|
72
|
+
const fullPackageName = `@forwardimpact/${packageName}`;
|
|
73
|
+
|
|
74
|
+
// First try local monorepo structures
|
|
75
|
+
for (const dir of ["libraries", "packages"]) {
|
|
76
|
+
const localPath = path.join(projectRoot, dir, packageName);
|
|
77
|
+
if (fs.existsSync(localPath)) {
|
|
78
|
+
return localPath;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Fall back to Node module resolution for installed packages
|
|
83
|
+
const require = createRequire(path.join(projectRoot, "package.json"));
|
|
84
|
+
|
|
85
|
+
// Resolve the package.json path
|
|
86
|
+
const packageJsonPath = require.resolve(`${fullPackageName}/package.json`);
|
|
87
|
+
return path.dirname(packageJsonPath);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve the generated directory path for a package
|
|
92
|
+
* @param {string} projectRoot - Project root directory path
|
|
93
|
+
* @param {"libtype"|"librpc"} packageName - Package name without scope
|
|
94
|
+
* @returns {string} Absolute path to package's generated directory
|
|
95
|
+
*/
|
|
96
|
+
findGeneratedPath(projectRoot, packageName) {
|
|
97
|
+
const packagePath = this.findPackagePath(projectRoot, packageName);
|
|
98
|
+
return path.join(packagePath, "generated");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Create symlink from source to target directory
|
|
103
|
+
* @param {string} sourcePath - Source directory path
|
|
104
|
+
* @param {string} targetPath - Target directory path
|
|
105
|
+
* @returns {Promise<void>}
|
|
106
|
+
*/
|
|
107
|
+
async createSymlink(sourcePath, targetPath) {
|
|
108
|
+
// Ensure the source directory exists
|
|
109
|
+
await fsAsync.mkdir(sourcePath, { recursive: true });
|
|
110
|
+
|
|
111
|
+
// Remove the existing target if it exists
|
|
112
|
+
try {
|
|
113
|
+
const stats = await fsAsync.lstat(targetPath);
|
|
114
|
+
if (stats.isSymbolicLink()) {
|
|
115
|
+
await fsAsync.unlink(targetPath);
|
|
116
|
+
} else {
|
|
117
|
+
await fsAsync.rm(targetPath, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// Target doesn't exist, which is fine
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Create the symlink
|
|
124
|
+
await fsAsync.symlink(sourcePath, targetPath, "dir");
|
|
125
|
+
this.#logger.debug("Finder", "Created symlink", {
|
|
126
|
+
source_path: sourcePath,
|
|
127
|
+
target_path: targetPath,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Create symlinks to the generated directory for standard packages
|
|
133
|
+
* Attempts to find project root and create symlinks, but won't fail in test environments
|
|
134
|
+
* @param {string} generatedPath - Path to generated code directory
|
|
135
|
+
* @returns {Promise<void>}
|
|
136
|
+
*/
|
|
137
|
+
async createPackageSymlinks(generatedPath) {
|
|
138
|
+
const projectRoot = this.findProjectRoot(this.#process.cwd());
|
|
139
|
+
const packageNames = ["libtype", "librpc"];
|
|
140
|
+
|
|
141
|
+
const promises = packageNames.map(async (packageName) => {
|
|
142
|
+
const targetPath = this.findGeneratedPath(projectRoot, packageName);
|
|
143
|
+
await this.createSymlink(generatedPath, targetPath);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await Promise.all(promises);
|
|
147
|
+
}
|
|
148
|
+
}
|
package/http.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses the JSON body from an incoming HTTP request.
|
|
3
|
+
* @param {import('http').IncomingMessage} req - The HTTP request object.
|
|
4
|
+
* @returns {Promise<object>} The parsed JSON object, or an empty object if parsing fails.
|
|
5
|
+
*/
|
|
6
|
+
export function parseJsonBody(req) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
let body = "";
|
|
9
|
+
req.on("data", (chunk) => {
|
|
10
|
+
body += chunk;
|
|
11
|
+
});
|
|
12
|
+
req.on("end", () => {
|
|
13
|
+
try {
|
|
14
|
+
resolve(JSON.parse(body));
|
|
15
|
+
} catch {
|
|
16
|
+
resolve({});
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
req.on("error", reject);
|
|
20
|
+
});
|
|
21
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { spawn } from "child_process";
|
|
5
|
+
|
|
6
|
+
import { Tokenizer, ranks } from "./tokenizer.js";
|
|
7
|
+
import { Finder } from "./finder.js";
|
|
8
|
+
import { BundleDownloader } from "./downloader.js";
|
|
9
|
+
import { TarExtractor } from "./extractor.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Updates or creates an environment variable in .env file
|
|
13
|
+
* @param {string} key - Environment variable name (e.g., "SERVICE_SECRET")
|
|
14
|
+
* @param {string} value - Environment variable value
|
|
15
|
+
* @param {string} [envPath] - Path to .env file (defaults to .env in current directory)
|
|
16
|
+
*/
|
|
17
|
+
export async function updateEnvFile(key, value, envPath = ".env") {
|
|
18
|
+
const fullPath = path.resolve(envPath);
|
|
19
|
+
let content = "";
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
content = await fs.readFile(fullPath, "utf8");
|
|
23
|
+
} catch (error) {
|
|
24
|
+
// It's ok if the file doesn't exist
|
|
25
|
+
if (error.code !== "ENOENT") throw error;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const envLine = `${key}=${value}`;
|
|
29
|
+
const lines = content.split("\n");
|
|
30
|
+
let found = false;
|
|
31
|
+
|
|
32
|
+
// Look for existing key line (both active and commented)
|
|
33
|
+
for (let i = 0; i < lines.length; i++) {
|
|
34
|
+
if (lines[i].startsWith(`${key}=`) || lines[i].startsWith(`# ${key}=`)) {
|
|
35
|
+
lines[i] = envLine;
|
|
36
|
+
found = true;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// If not found, add it to the end
|
|
42
|
+
if (!found) {
|
|
43
|
+
if (content && !content.endsWith("\n")) {
|
|
44
|
+
lines.push("");
|
|
45
|
+
}
|
|
46
|
+
lines.push(envLine);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Write back to file
|
|
50
|
+
await fs.writeFile(fullPath, lines.join("\n"));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Generates a deterministic hash from multiple input values
|
|
55
|
+
* @param {...string} values - Values to hash together
|
|
56
|
+
* @returns {string} The first 16 characters of SHA256 hash
|
|
57
|
+
*/
|
|
58
|
+
export function generateHash(...values) {
|
|
59
|
+
const input = values.filter(Boolean).join(".");
|
|
60
|
+
return crypto
|
|
61
|
+
.createHash("sha256")
|
|
62
|
+
.update(input)
|
|
63
|
+
.digest("hex")
|
|
64
|
+
.substring(0, 8);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Generates a unique session ID for conversation tracking
|
|
69
|
+
* @returns {string} Unique session identifier
|
|
70
|
+
*/
|
|
71
|
+
export function generateUUID() {
|
|
72
|
+
return crypto.randomUUID();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Helper function to count tokens
|
|
77
|
+
* @param {string} text - Text to count tokens for
|
|
78
|
+
* @param {Tokenizer} tokenizer - Tokenizer instance
|
|
79
|
+
* @returns {number} Approximate token count
|
|
80
|
+
*/
|
|
81
|
+
export function countTokens(text, tokenizer) {
|
|
82
|
+
if (!tokenizer) tokenizer = createTokenizer();
|
|
83
|
+
return tokenizer.encode(text).length;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Creates a new tokenizer instance
|
|
88
|
+
* @returns {Tokenizer} New tokenizer instance
|
|
89
|
+
*/
|
|
90
|
+
export function createTokenizer() {
|
|
91
|
+
return new Tokenizer(ranks);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Creates a BundleDownloader instance configured for generated code management.
|
|
96
|
+
* Used in containerized deployments to download pre-generated code bundles.
|
|
97
|
+
* @param {Function} createStorage - Storage factory function from libstorage
|
|
98
|
+
* @returns {Promise<BundleDownloader>} Configured BundleDownloader instance
|
|
99
|
+
*/
|
|
100
|
+
export async function createBundleDownloader(createStorage) {
|
|
101
|
+
if (!createStorage) throw new Error("createStorage is required");
|
|
102
|
+
|
|
103
|
+
// Dynamic import to avoid circular dependency with libtelemetry
|
|
104
|
+
const { createLogger } = await import("@forwardimpact/libtelemetry");
|
|
105
|
+
const logger = createLogger("generated");
|
|
106
|
+
const finder = new Finder(fs, logger);
|
|
107
|
+
const extractor = new TarExtractor(fs, path);
|
|
108
|
+
|
|
109
|
+
return new BundleDownloader(createStorage, finder, logger, extractor);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Executes command line arguments as child process, similar to execv() in C
|
|
114
|
+
* @param {number} [shift] - Number of arguments to skip from process.argv before extracting command
|
|
115
|
+
* @returns {void} Function does not return - exits parent process
|
|
116
|
+
*/
|
|
117
|
+
export function execLine(shift = 0) {
|
|
118
|
+
const args = process.argv.slice(2 + shift);
|
|
119
|
+
if (args.length === 0) return;
|
|
120
|
+
|
|
121
|
+
// Look for '--' delimiter and use everything after it as the command
|
|
122
|
+
const index = args.indexOf("--");
|
|
123
|
+
const line = index !== -1 ? args.slice(index + 1) : args;
|
|
124
|
+
|
|
125
|
+
if (line.length === 0) return;
|
|
126
|
+
|
|
127
|
+
const [command, ...commandArgs] = line;
|
|
128
|
+
const child = spawn(command, commandArgs, {
|
|
129
|
+
stdio: "inherit",
|
|
130
|
+
env: process.env,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Forward signals to child process
|
|
134
|
+
["SIGTERM", "SIGINT", "SIGQUIT"].forEach((signal) => {
|
|
135
|
+
process.on(signal, () => child.kill(signal));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
child.on("error", (error) => {
|
|
139
|
+
console.error("Error:", error);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
child.on("exit", (code, signal) => {
|
|
144
|
+
process.exit(signal ? 1 : code || 0);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export { Finder } from "./finder.js";
|
|
149
|
+
export { BundleDownloader } from "./downloader.js";
|
|
150
|
+
export { TarExtractor, ZipExtractor } from "./extractor.js";
|
|
151
|
+
export { ProcessorBase } from "./processor.js";
|
|
152
|
+
export { Retry, createRetry } from "./retry.js";
|
|
153
|
+
export { parseJsonBody } from "./http.js";
|
|
154
|
+
export { waitFor } from "./wait.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forwardimpact/libutil",
|
|
3
|
+
"version": "0.1.60",
|
|
4
|
+
"description": "Utility functions and utilities for Guide",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "D. Olsson <hi@senzilla.io>",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "index.js",
|
|
9
|
+
"bin": {
|
|
10
|
+
"fit-download-bundle": "./bin/fit-download-bundle.js"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22.0.0"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test test/*.test.js"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@forwardimpact/libtelemetry": "^0.1.22"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@forwardimpact/libharness": "^0.1.5"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/processor.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class for batch processor implementations with common batch management logic
|
|
3
|
+
*/
|
|
4
|
+
export class ProcessorBase {
|
|
5
|
+
#logger;
|
|
6
|
+
#batchSize;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates a new processor instance
|
|
10
|
+
* @param {object} logger - Logger instance for debug output
|
|
11
|
+
* @param {number} batchSize - Size of batches for processing (default: 4)
|
|
12
|
+
*/
|
|
13
|
+
constructor(logger, batchSize = 4) {
|
|
14
|
+
if (!logger) throw new Error("logger is required");
|
|
15
|
+
if (typeof batchSize !== "number" || batchSize < 1) {
|
|
16
|
+
throw new Error("batchSize must be a positive number");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
this.#logger = logger;
|
|
20
|
+
this.#batchSize = batchSize;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Gets the logger instance for use in subclasses
|
|
25
|
+
* @returns {object} Logger instance
|
|
26
|
+
* @protected
|
|
27
|
+
*/
|
|
28
|
+
get logger() {
|
|
29
|
+
return this.#logger;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Processes items in batches
|
|
34
|
+
* @param {any[]} items - Items to process
|
|
35
|
+
* @param {string} context - Processing context label
|
|
36
|
+
* @returns {Promise<any[]>} Processed results
|
|
37
|
+
*/
|
|
38
|
+
async process(items, context = "items") {
|
|
39
|
+
if (!Array.isArray(items)) {
|
|
40
|
+
throw new Error("items must be an array");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (items.length === 0) {
|
|
44
|
+
this.#logger.debug("Processor", "No items to process", { context });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this.#logger.debug("Processor", "Starting batch", {
|
|
49
|
+
total: items.length,
|
|
50
|
+
context,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
let currentBatch = [];
|
|
54
|
+
let processedCount = 0;
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < items.length; i++) {
|
|
57
|
+
currentBatch.push(items[i]);
|
|
58
|
+
|
|
59
|
+
if (currentBatch.length >= this.#batchSize) {
|
|
60
|
+
await this.processBatch(
|
|
61
|
+
currentBatch,
|
|
62
|
+
processedCount,
|
|
63
|
+
items.length,
|
|
64
|
+
context,
|
|
65
|
+
);
|
|
66
|
+
processedCount += currentBatch.length;
|
|
67
|
+
currentBatch = [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (currentBatch.length > 0) {
|
|
72
|
+
await this.processBatch(
|
|
73
|
+
currentBatch,
|
|
74
|
+
processedCount,
|
|
75
|
+
items.length,
|
|
76
|
+
context,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Processes a batch of items
|
|
83
|
+
* @param {any[]} batch - Batch to process
|
|
84
|
+
* @param {number} processed - Number already processed
|
|
85
|
+
* @param {number} total - Total number of items
|
|
86
|
+
* @param {object} context - Processing context
|
|
87
|
+
* @returns {Promise<any[]>} Batch results
|
|
88
|
+
*/
|
|
89
|
+
async processBatch(batch, processed, total, context) {
|
|
90
|
+
const batchSize = batch.length;
|
|
91
|
+
|
|
92
|
+
this.#logger.debug("Processor", "Processing batch", {
|
|
93
|
+
items:
|
|
94
|
+
batchSize > 1
|
|
95
|
+
? `${processed + 1}-${processed + batchSize}/${total}`
|
|
96
|
+
: `${processed + 1}/${total}`,
|
|
97
|
+
context,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const promises = batch.map(async (item, itemIndex) => {
|
|
101
|
+
const globalIndex = processed + itemIndex;
|
|
102
|
+
try {
|
|
103
|
+
return await this.processItem(item);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
this.#logger.debug("Processor", "Skipping, failed to process item", {
|
|
106
|
+
item: `${globalIndex + 1}/${total}`,
|
|
107
|
+
context,
|
|
108
|
+
error: error.message,
|
|
109
|
+
});
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await Promise.all(promises);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Processes a single item (must be implemented by subclass)
|
|
119
|
+
* @param {any} _item - Item to process
|
|
120
|
+
* @returns {Promise<any>} Processed result
|
|
121
|
+
*/
|
|
122
|
+
async processItem(_item) {
|
|
123
|
+
throw new Error("processItem must be implemented by subclass");
|
|
124
|
+
}
|
|
125
|
+
}
|
package/retry.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry class for handling transient errors with exponential backoff
|
|
3
|
+
*/
|
|
4
|
+
export class Retry {
|
|
5
|
+
#retries;
|
|
6
|
+
#delay;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates a new Retry instance
|
|
10
|
+
* @param {object} [config] - Optional configuration object
|
|
11
|
+
* @param {number} [config.retries] - Maximum number of retry attempts (default: 10)
|
|
12
|
+
* @param {number} [config.delay] - Initial delay in milliseconds (default: 1000)
|
|
13
|
+
*/
|
|
14
|
+
constructor(config = {}) {
|
|
15
|
+
this.#retries = config.retries ?? 10;
|
|
16
|
+
this.#delay = config.delay ?? 1000;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Checks if an HTTP status code or error should trigger a retry
|
|
21
|
+
* @param {number} status - HTTP status code
|
|
22
|
+
* @returns {boolean} True if the status should be retried
|
|
23
|
+
* @private
|
|
24
|
+
*/
|
|
25
|
+
#isRetryableStatus(status) {
|
|
26
|
+
// Retry on rate limits, transient server errors, and client timeouts
|
|
27
|
+
return (
|
|
28
|
+
status === 429 ||
|
|
29
|
+
status === 499 ||
|
|
30
|
+
status === 500 ||
|
|
31
|
+
status === 502 ||
|
|
32
|
+
status === 503 ||
|
|
33
|
+
status === 504
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Checks if an error is a network error that should trigger a retry
|
|
39
|
+
* @param {Error} error - Error object
|
|
40
|
+
* @returns {boolean} True if the error should be retried
|
|
41
|
+
* @private
|
|
42
|
+
*/
|
|
43
|
+
#isRetryableError(error) {
|
|
44
|
+
const message = error.message.toLowerCase();
|
|
45
|
+
|
|
46
|
+
// Check for HTTP status codes in error messages (e.g., "HTTP 499: status code 499")
|
|
47
|
+
const httpStatusMatch = message.match(/http (\d{3})/);
|
|
48
|
+
if (httpStatusMatch) {
|
|
49
|
+
const statusCode = parseInt(httpStatusMatch[1], 10);
|
|
50
|
+
if (this.#isRetryableStatus(statusCode)) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
message.includes("network") ||
|
|
57
|
+
message.includes("timeout") ||
|
|
58
|
+
message.includes("econnrefused") ||
|
|
59
|
+
message.includes("econnreset") ||
|
|
60
|
+
message.includes("etimedout") ||
|
|
61
|
+
message.includes("unavailable") ||
|
|
62
|
+
message.includes("fetch failed") ||
|
|
63
|
+
message.includes("unexpected eof")
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Executes a function with exponential backoff retry logic for transient errors
|
|
69
|
+
* @param {() => Promise<Response>} requestFn - Function that returns a fetch promise
|
|
70
|
+
* @returns {Promise<Response>} Response from successful request
|
|
71
|
+
* @throws {Error} When all retry attempts are exhausted
|
|
72
|
+
*/
|
|
73
|
+
async execute(requestFn) {
|
|
74
|
+
let lastError;
|
|
75
|
+
|
|
76
|
+
for (let attempt = 0; attempt <= this.#retries; attempt++) {
|
|
77
|
+
try {
|
|
78
|
+
const response = await requestFn();
|
|
79
|
+
|
|
80
|
+
// Check if we should retry based on status code
|
|
81
|
+
if (
|
|
82
|
+
response?.status &&
|
|
83
|
+
this.#isRetryableStatus(response.status) &&
|
|
84
|
+
attempt < this.#retries
|
|
85
|
+
) {
|
|
86
|
+
// Add jitter to exponential backoff to avoid thundering herd
|
|
87
|
+
const exponentialDelay = this.#delay * Math.pow(2, attempt);
|
|
88
|
+
const jitter = Math.random() * 0.3 * exponentialDelay;
|
|
89
|
+
const wait = exponentialDelay + jitter;
|
|
90
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return response;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
lastError = error;
|
|
97
|
+
|
|
98
|
+
// Check if this is a retryable network error
|
|
99
|
+
if (this.#isRetryableError(error) && attempt < this.#retries) {
|
|
100
|
+
const exponentialDelay = this.#delay * Math.pow(2, attempt);
|
|
101
|
+
const jitter = Math.random() * 0.3 * exponentialDelay;
|
|
102
|
+
const wait = exponentialDelay + jitter;
|
|
103
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Non-retryable error or out of retries
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// This should never be reached, but if it is, throw the last error
|
|
113
|
+
throw lastError || new Error("Retries exhausted without a valid response");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Factory function to create a Retry instance with optional configuration
|
|
119
|
+
* @param {object} [config] - Optional configuration object
|
|
120
|
+
* @param {number} [config.retries] - Maximum number of retry attempts
|
|
121
|
+
* @param {number} [config.delay] - Initial delay in milliseconds
|
|
122
|
+
* @returns {Retry} Configured Retry instance
|
|
123
|
+
*/
|
|
124
|
+
export function createRetry(config) {
|
|
125
|
+
return new Retry(config);
|
|
126
|
+
}
|