UNPKG

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
#!/usr/bin/env node // 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