api-throttle-tester
Version:
A production-ready CLI tool for stress-testing API endpoints to understand rate limits, throttling behavior, and latency characteristics
405 lines (396 loc) • 13.6 kB
JavaScript
// src/cli.ts
import { Command } from "commander";
// src/runner/loadTester.ts
import fetch from "node-fetch";
async function runLoadTest(config) {
const results = [];
const resultsMutex = { lock: false };
let requestCounter = 0;
const makeRequest = async () => {
const currentIndex = requestCounter++;
if (currentIndex >= config.totalRequests) {
return null;
}
const startTime = Date.now();
let endTime;
let statusCode;
let error;
let timeoutId;
try {
const controller = new AbortController();
timeoutId = setTimeout(() => controller.abort(), config.timeoutMs);
const fetchOptions = {
method: config.method,
headers: config.headers,
signal: controller.signal
};
if (config.body && ["POST", "PUT", "PATCH"].includes(config.method.toUpperCase())) {
fetchOptions.body = config.body;
if (!config.headers["Content-Type"] && !config.headers["content-type"]) {
fetchOptions.headers = {
...config.headers,
"Content-Type": "application/json"
};
}
}
const response = await fetch(config.url, fetchOptions);
if (timeoutId) clearTimeout(timeoutId);
endTime = Date.now();
statusCode = response.status;
} catch (err) {
if (timeoutId) clearTimeout(timeoutId);
endTime = Date.now();
if (err instanceof Error) {
if (err.name === "AbortError") {
error = "Timeout";
} else {
error = err.message;
}
} else {
error = "Unknown error";
}
}
const responseTime = endTime - startTime;
return {
startTime,
endTime,
statusCode,
error,
responseTime
};
};
const worker = async () => {
while (true) {
const result = await makeRequest();
if (result === null) {
break;
}
while (resultsMutex.lock) {
await new Promise((resolve) => setTimeout(resolve, 1));
}
resultsMutex.lock = true;
results.push(result);
resultsMutex.lock = false;
if (config.delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, config.delayMs));
}
}
};
const workers = Array.from({ length: config.concurrency }, () => worker());
await Promise.all(workers);
return results;
}
// src/utils/metrics.ts
function calculateSummary(results, throttleStatus) {
const successful = [];
const throttled = [];
const errors = [];
const networkErrors = [];
const responseTimes = [];
for (const result of results) {
if (result.error) {
networkErrors.push(result);
continue;
}
responseTimes.push(result.responseTime);
if (result.statusCode === void 0) {
networkErrors.push(result);
continue;
}
if (result.statusCode >= 200 && result.statusCode < 300) {
successful.push(result);
} else if (result.statusCode === throttleStatus) {
throttled.push(result);
} else {
errors.push(result);
}
}
const validResponseTimes = responseTimes.filter((rt) => rt > 0);
return {
success: successful.length,
throttled: throttled.length,
errors: errors.length,
networkErrors: networkErrors.length,
avgResponseMs: validResponseTimes.length > 0 ? validResponseTimes.reduce((a, b) => a + b, 0) / validResponseTimes.length : 0,
minResponseMs: validResponseTimes.length > 0 ? Math.min(...validResponseTimes) : 0,
maxResponseMs: validResponseTimes.length > 0 ? Math.max(...validResponseTimes) : 0
};
}
function calculateStatusCounts(results) {
const counts = {};
for (const result of results) {
if (result.statusCode !== void 0) {
const status = result.statusCode.toString();
counts[status] = (counts[status] || 0) + 1;
}
}
return counts;
}
function calculateLatencyBuckets(results) {
const buckets = {
"<100": 0,
"100-250": 0,
"250-500": 0,
"500-1000": 0,
">1000": 0
};
for (const result of results) {
if (result.error || result.responseTime <= 0) {
continue;
}
const ms = result.responseTime;
if (ms < 100) {
buckets["<100"]++;
} else if (ms < 250) {
buckets["100-250"]++;
} else if (ms < 500) {
buckets["250-500"]++;
} else if (ms < 1e3) {
buckets["500-1000"]++;
} else {
buckets[">1000"]++;
}
}
return buckets;
}
// src/utils/logger.ts
var Logger = class {
static colors = {
reset: "\x1B[0m",
green: "\x1B[32m",
yellow: "\x1B[33m",
red: "\x1B[31m",
blue: "\x1B[34m",
cyan: "\x1B[36m",
gray: "\x1B[90m"
};
static info(message) {
console.log(`${this.colors.blue}\u2139${this.colors.reset} ${message}`);
}
static success(message) {
console.log(`${this.colors.green}\u2713${this.colors.reset} ${message}`);
}
static warning(message) {
console.log(`${this.colors.yellow}\u26A0${this.colors.reset} ${message}`);
}
static error(message) {
console.error(`${this.colors.red}\u2717${this.colors.reset} ${message}`);
}
static log(message) {
console.log(message);
}
static colorize(text, color) {
return `${this.colors[color]}${text}${this.colors.reset}`;
}
};
// src/utils/output.ts
function formatTableOutput(report) {
const { summary, statusCounts, latencyBuckets, config } = report;
Logger.log("\n" + "=".repeat(60));
Logger.log("API Throttle Test Results");
Logger.log("=".repeat(60) + "\n");
Logger.log("Configuration:");
Logger.log(` URL: ${report.url}`);
Logger.log(` Method: ${report.method}`);
if (report.tag) {
Logger.log(` Tag: ${report.tag}`);
}
Logger.log(` Total Requests: ${config.totalRequests}`);
Logger.log(` Concurrency: ${config.concurrency}`);
Logger.log(` Delay: ${config.delayMs}ms`);
Logger.log(` Timeout: ${config.timeoutMs}ms`);
Logger.log(` Throttle Status: ${config.throttleStatus}
`);
Logger.log("Summary:");
Logger.log(` ${Logger.colorize("Successful (2xx)", "green")}: ${summary.success}`);
Logger.log(` ${Logger.colorize("Throttled", "yellow")}: ${summary.throttled}`);
Logger.log(` ${Logger.colorize("Errors", "red")}: ${summary.errors}`);
Logger.log(` ${Logger.colorize("Network Errors", "red")}: ${summary.networkErrors}`);
Logger.log(` Total: ${config.totalRequests}
`);
Logger.log("Latency:");
Logger.log(` Average: ${summary.avgResponseMs.toFixed(2)}ms`);
Logger.log(` Min: ${summary.minResponseMs.toFixed(2)}ms`);
Logger.log(` Max: ${summary.maxResponseMs.toFixed(2)}ms
`);
if (Object.keys(statusCounts).length > 0) {
Logger.log("Status Code Breakdown:");
const sortedStatuses = Object.entries(statusCounts).sort((a, b) => {
const codeA = parseInt(a[0], 10);
const codeB = parseInt(b[0], 10);
return codeA - codeB;
});
for (const [status, count] of sortedStatuses) {
const code = parseInt(status, 10);
let color = "green";
if (code >= 500) {
color = "red";
} else if (code >= 400) {
color = "yellow";
}
Logger.log(` ${Logger.colorize(status, color)}: ${count}`);
}
Logger.log("");
}
Logger.log("Latency Distribution:");
Logger.log(` < 100ms: ${latencyBuckets["<100"]}`);
Logger.log(` 100-250ms: ${latencyBuckets["100-250"]}`);
Logger.log(` 250-500ms: ${latencyBuckets["250-500"]}`);
Logger.log(` 500-1000ms: ${latencyBuckets["500-1000"]}`);
Logger.log(` > 1000ms: ${latencyBuckets[">1000"]}`);
Logger.log("\n" + "=".repeat(60) + "\n");
}
function formatJsonOutput(report) {
return JSON.stringify(report, null, 2);
}
// src/utils/config.ts
import { readFileSync } from "fs";
function loadConfigFile(configPath) {
try {
const content = readFileSync(configPath, "utf-8");
return JSON.parse(content);
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to load config file: ${error.message}`);
}
throw new Error("Failed to load config file: Unknown error");
}
}
function mergeConfig(fileConfig, cliConfig) {
const defaults = {
url: "",
method: "GET",
totalRequests: 100,
concurrency: 10,
delayMs: 0,
timeoutMs: 5e3,
headers: {},
throttleStatus: 429
};
const merged = {
...defaults,
...fileConfig || {},
...cliConfig,
headers: {
...fileConfig?.headers || {},
...cliConfig.headers || {}
}
};
if (!merged.url) {
throw new Error("URL is required. Provide it via --url, CLI argument, or config file.");
}
return merged;
}
// src/utils/version.ts
var VERSION = "1.0.0";
// src/cli.ts
import { writeFileSync } from "fs";
var program = new Command();
program.name("api-throttle-tester").description("A CLI tool for stress-testing API endpoints to understand rate limits and throttling behavior").version(VERSION).argument("[url]", "API endpoint URL to test").option("-X, --method <method>", "HTTP method (GET, POST, PUT, DELETE, etc.)", "GET").option("-t, --total <number>", "Total number of requests to send", "100").option("-c, --concurrency <number>", "Number of concurrent workers", "10").option("-d, --delay <number>", "Delay between requests per worker (ms)", "0").option("-H, --header <header>", "HTTP header (can be used multiple times)", (val, prev = []) => {
prev.push(val);
return prev;
}, []).option("-b, --body <body>", "Request body (JSON string for POST/PUT/PATCH)").option("--timeout <number>", "Per-request timeout (ms)", "5000").option("--json", "Output results as JSON").option("--report-file <path>", "Path to write JSON report file").option("--status-as-throttle <number>", "Status code to count as throttled", "429").option("--tag <tag>", "Optional label/tag for the test run").option("--config <path>", "Path to config file (JSON)").action(async (url, options) => {
try {
let fileConfig = null;
if (options.config) {
try {
fileConfig = loadConfigFile(options.config);
} catch (error) {
Logger.error(`Failed to load config file: ${error instanceof Error ? error.message : "Unknown error"}`);
process.exit(1);
}
}
const headers = {};
if (options.header && Array.isArray(options.header)) {
for (const header of options.header) {
const colonIndex = header.indexOf(":");
if (colonIndex === -1) {
Logger.error(`Invalid header format: ${header}. Expected format: "Key: Value"`);
process.exit(1);
}
const key = header.substring(0, colonIndex).trim();
const value = header.substring(colonIndex + 1).trim();
headers[key] = value;
}
}
const config = {
url: url || options.url,
method: options.method,
totalRequests: parseInt(options.total, 10),
concurrency: parseInt(options.concurrency, 10),
delayMs: parseInt(options.delay, 10),
timeoutMs: parseInt(options.timeout, 10),
headers,
body: options.body,
throttleStatus: parseInt(options.statusAsThrottle, 10),
tag: options.tag
};
const finalConfig = mergeConfig(fileConfig, config);
if (finalConfig.totalRequests <= 0) {
Logger.error("Total requests must be greater than 0");
process.exit(1);
}
if (finalConfig.concurrency <= 0) {
Logger.error("Concurrency must be greater than 0");
process.exit(1);
}
if (finalConfig.concurrency > finalConfig.totalRequests) {
Logger.warning(`Concurrency (${finalConfig.concurrency}) is greater than total requests (${finalConfig.totalRequests}). Setting concurrency to ${finalConfig.totalRequests}.`);
finalConfig.concurrency = finalConfig.totalRequests;
}
Logger.info(`Starting load test: ${finalConfig.totalRequests} requests with ${finalConfig.concurrency} concurrent workers`);
Logger.info(`Target: ${finalConfig.method} ${finalConfig.url}
`);
const startTime = Date.now();
const results = await runLoadTest(finalConfig);
const endTime = Date.now();
const totalDuration = endTime - startTime;
if (results.length === 0) {
Logger.error("No results collected. Check your URL and network connection.");
process.exit(1);
}
const summary = calculateSummary(results, finalConfig.throttleStatus);
const statusCounts = calculateStatusCounts(results);
const latencyBuckets = calculateLatencyBuckets(results);
const report = {
url: finalConfig.url,
method: finalConfig.method,
tag: finalConfig.tag,
config: {
totalRequests: finalConfig.totalRequests,
concurrency: finalConfig.concurrency,
delayMs: finalConfig.delayMs,
timeoutMs: finalConfig.timeoutMs,
throttleStatus: finalConfig.throttleStatus
},
summary,
statusCounts,
latencyBuckets
};
if (options.json) {
const jsonOutput = formatJsonOutput(report);
console.log(jsonOutput);
} else {
formatTableOutput(report);
Logger.info(`Total test duration: ${(totalDuration / 1e3).toFixed(2)}s`);
}
if (options.reportFile) {
const jsonOutput = formatJsonOutput(report);
writeFileSync(options.reportFile, jsonOutput, "utf-8");
if (!options.json) {
Logger.success(`Report written to ${options.reportFile}`);
}
}
process.exit(0);
} catch (error) {
Logger.error(error instanceof Error ? error.message : "Unknown error occurred");
process.exit(1);
}
});
function parseArgs(args = process.argv) {
program.parse(args);
}
// src/index.ts
parseArgs();
//# sourceMappingURL=index.js.map