608 lines
19 KiB
Python
Executable File
608 lines
19 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
API Load Tester
|
|
|
|
Performs HTTP load testing with configurable concurrency, measuring latency
|
|
percentiles, throughput, and error rates.
|
|
|
|
Usage:
|
|
python api_load_tester.py https://api.example.com/users --concurrency 50 --duration 30
|
|
python api_load_tester.py https://api.example.com/orders --method POST --body '{"item": 1}'
|
|
python api_load_tester.py https://api.example.com/v1/users https://api.example.com/v2/users --compare
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import argparse
|
|
import time
|
|
import statistics
|
|
import threading
|
|
import queue
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from dataclasses import dataclass, field, asdict
|
|
from typing import Dict, List, Optional, Tuple
|
|
from datetime import datetime
|
|
from urllib.request import Request, urlopen
|
|
from urllib.error import URLError, HTTPError
|
|
from urllib.parse import urlparse
|
|
import ssl
|
|
|
|
|
|
@dataclass
|
|
class RequestResult:
|
|
"""Result of a single HTTP request."""
|
|
success: bool
|
|
status_code: int
|
|
latency_ms: float
|
|
error: Optional[str] = None
|
|
response_size: int = 0
|
|
|
|
|
|
@dataclass
|
|
class LoadTestResults:
|
|
"""Aggregated load test results."""
|
|
target_url: str
|
|
method: str
|
|
duration_seconds: float
|
|
concurrency: int
|
|
total_requests: int
|
|
successful_requests: int
|
|
failed_requests: int
|
|
requests_per_second: float
|
|
|
|
# Latency metrics (milliseconds)
|
|
latency_min: float
|
|
latency_max: float
|
|
latency_avg: float
|
|
latency_p50: float
|
|
latency_p90: float
|
|
latency_p95: float
|
|
latency_p99: float
|
|
latency_stddev: float
|
|
|
|
# Error breakdown
|
|
errors_by_type: Dict[str, int] = field(default_factory=dict)
|
|
|
|
# Transfer metrics
|
|
total_bytes_received: int = 0
|
|
throughput_mbps: float = 0.0
|
|
|
|
def success_rate(self) -> float:
|
|
"""Calculate success rate percentage."""
|
|
if self.total_requests == 0:
|
|
return 0.0
|
|
return (self.successful_requests / self.total_requests) * 100
|
|
|
|
|
|
def calculate_percentile(data: List[float], percentile: float) -> float:
|
|
"""Calculate percentile from sorted data."""
|
|
if not data:
|
|
return 0.0
|
|
k = (len(data) - 1) * (percentile / 100)
|
|
f = int(k)
|
|
c = f + 1 if f + 1 < len(data) else f
|
|
return data[f] + (data[c] - data[f]) * (k - f)
|
|
|
|
|
|
class HTTPClient:
|
|
"""HTTP client with configurable settings."""
|
|
|
|
def __init__(self, timeout: float = 30.0, headers: Optional[Dict[str, str]] = None,
|
|
verify_ssl: bool = True):
|
|
self.timeout = timeout
|
|
self.headers = headers or {}
|
|
self.verify_ssl = verify_ssl
|
|
|
|
# Create SSL context
|
|
if not verify_ssl:
|
|
self.ssl_context = ssl.create_default_context()
|
|
self.ssl_context.check_hostname = False
|
|
self.ssl_context.verify_mode = ssl.CERT_NONE
|
|
else:
|
|
self.ssl_context = None
|
|
|
|
def request(self, url: str, method: str = 'GET', body: Optional[bytes] = None) -> RequestResult:
|
|
"""Execute HTTP request and return result."""
|
|
start_time = time.perf_counter()
|
|
|
|
try:
|
|
request = Request(url, data=body, method=method)
|
|
|
|
# Add headers
|
|
for key, value in self.headers.items():
|
|
request.add_header(key, value)
|
|
|
|
# Add content-type for POST/PUT
|
|
if body and method in ['POST', 'PUT', 'PATCH']:
|
|
if 'Content-Type' not in self.headers:
|
|
request.add_header('Content-Type', 'application/json')
|
|
|
|
# Execute request
|
|
with urlopen(request, timeout=self.timeout, context=self.ssl_context) as response:
|
|
response_data = response.read()
|
|
elapsed = (time.perf_counter() - start_time) * 1000
|
|
|
|
return RequestResult(
|
|
success=True,
|
|
status_code=response.status,
|
|
latency_ms=elapsed,
|
|
response_size=len(response_data),
|
|
)
|
|
|
|
except HTTPError as e:
|
|
elapsed = (time.perf_counter() - start_time) * 1000
|
|
return RequestResult(
|
|
success=False,
|
|
status_code=e.code,
|
|
latency_ms=elapsed,
|
|
error=f"HTTP {e.code}: {e.reason}",
|
|
)
|
|
|
|
except URLError as e:
|
|
elapsed = (time.perf_counter() - start_time) * 1000
|
|
return RequestResult(
|
|
success=False,
|
|
status_code=0,
|
|
latency_ms=elapsed,
|
|
error=f"Connection error: {str(e.reason)}",
|
|
)
|
|
|
|
except TimeoutError:
|
|
elapsed = (time.perf_counter() - start_time) * 1000
|
|
return RequestResult(
|
|
success=False,
|
|
status_code=0,
|
|
latency_ms=elapsed,
|
|
error="Connection timeout",
|
|
)
|
|
|
|
except Exception as e:
|
|
elapsed = (time.perf_counter() - start_time) * 1000
|
|
return RequestResult(
|
|
success=False,
|
|
status_code=0,
|
|
latency_ms=elapsed,
|
|
error=str(e),
|
|
)
|
|
|
|
|
|
class LoadTester:
|
|
"""HTTP load testing engine."""
|
|
|
|
def __init__(self, url: str, method: str = 'GET', body: Optional[str] = None,
|
|
headers: Optional[Dict[str, str]] = None, concurrency: int = 10,
|
|
duration: float = 10.0, timeout: float = 30.0, verify_ssl: bool = True):
|
|
self.url = url
|
|
self.method = method.upper()
|
|
self.body = body.encode() if body else None
|
|
self.headers = headers or {}
|
|
self.concurrency = concurrency
|
|
self.duration = duration
|
|
self.timeout = timeout
|
|
self.verify_ssl = verify_ssl
|
|
|
|
self.results: List[RequestResult] = []
|
|
self.stop_event = threading.Event()
|
|
self.results_lock = threading.Lock()
|
|
|
|
def run(self) -> LoadTestResults:
|
|
"""Execute load test and return results."""
|
|
print(f"Load Testing: {self.url}")
|
|
print(f"Method: {self.method}")
|
|
print(f"Concurrency: {self.concurrency}")
|
|
print(f"Duration: {self.duration}s")
|
|
print("-" * 50)
|
|
|
|
self.results = []
|
|
self.stop_event.clear()
|
|
|
|
start_time = time.time()
|
|
|
|
# Start worker threads
|
|
with ThreadPoolExecutor(max_workers=self.concurrency) as executor:
|
|
futures = []
|
|
for _ in range(self.concurrency):
|
|
future = executor.submit(self._worker)
|
|
futures.append(future)
|
|
|
|
# Wait for duration
|
|
time.sleep(self.duration)
|
|
self.stop_event.set()
|
|
|
|
# Wait for workers to finish
|
|
for future in as_completed(futures):
|
|
try:
|
|
future.result()
|
|
except Exception as e:
|
|
print(f"Worker error: {e}")
|
|
|
|
elapsed_time = time.time() - start_time
|
|
|
|
return self._aggregate_results(elapsed_time)
|
|
|
|
def _worker(self):
|
|
"""Worker thread that continuously sends requests."""
|
|
client = HTTPClient(
|
|
timeout=self.timeout,
|
|
headers=self.headers,
|
|
verify_ssl=self.verify_ssl,
|
|
)
|
|
|
|
while not self.stop_event.is_set():
|
|
result = client.request(self.url, self.method, self.body)
|
|
|
|
with self.results_lock:
|
|
self.results.append(result)
|
|
|
|
def _aggregate_results(self, elapsed_time: float) -> LoadTestResults:
|
|
"""Aggregate individual results into summary."""
|
|
if not self.results:
|
|
return LoadTestResults(
|
|
target_url=self.url,
|
|
method=self.method,
|
|
duration_seconds=elapsed_time,
|
|
concurrency=self.concurrency,
|
|
total_requests=0,
|
|
successful_requests=0,
|
|
failed_requests=0,
|
|
requests_per_second=0,
|
|
latency_min=0,
|
|
latency_max=0,
|
|
latency_avg=0,
|
|
latency_p50=0,
|
|
latency_p90=0,
|
|
latency_p95=0,
|
|
latency_p99=0,
|
|
latency_stddev=0,
|
|
)
|
|
|
|
# Separate successful and failed
|
|
successful = [r for r in self.results if r.success]
|
|
failed = [r for r in self.results if not r.success]
|
|
|
|
# Latency calculations (from successful requests)
|
|
latencies = sorted([r.latency_ms for r in successful]) if successful else [0]
|
|
|
|
# Error breakdown
|
|
errors_by_type: Dict[str, int] = {}
|
|
for r in failed:
|
|
error_type = r.error or 'Unknown'
|
|
errors_by_type[error_type] = errors_by_type.get(error_type, 0) + 1
|
|
|
|
# Calculate throughput
|
|
total_bytes = sum(r.response_size for r in successful)
|
|
throughput_mbps = (total_bytes * 8) / (elapsed_time * 1_000_000) if elapsed_time > 0 else 0
|
|
|
|
return LoadTestResults(
|
|
target_url=self.url,
|
|
method=self.method,
|
|
duration_seconds=elapsed_time,
|
|
concurrency=self.concurrency,
|
|
total_requests=len(self.results),
|
|
successful_requests=len(successful),
|
|
failed_requests=len(failed),
|
|
requests_per_second=len(self.results) / elapsed_time if elapsed_time > 0 else 0,
|
|
latency_min=min(latencies),
|
|
latency_max=max(latencies),
|
|
latency_avg=statistics.mean(latencies) if latencies else 0,
|
|
latency_p50=calculate_percentile(latencies, 50),
|
|
latency_p90=calculate_percentile(latencies, 90),
|
|
latency_p95=calculate_percentile(latencies, 95),
|
|
latency_p99=calculate_percentile(latencies, 99),
|
|
latency_stddev=statistics.stdev(latencies) if len(latencies) > 1 else 0,
|
|
errors_by_type=errors_by_type,
|
|
total_bytes_received=total_bytes,
|
|
throughput_mbps=throughput_mbps,
|
|
)
|
|
|
|
|
|
def print_results(results: LoadTestResults, verbose: bool = False):
|
|
"""Print formatted load test results."""
|
|
print("\n" + "=" * 60)
|
|
print("LOAD TEST RESULTS")
|
|
print("=" * 60)
|
|
|
|
print(f"\nTarget: {results.target_url}")
|
|
print(f"Method: {results.method}")
|
|
print(f"Duration: {results.duration_seconds:.1f}s")
|
|
print(f"Concurrency: {results.concurrency}")
|
|
|
|
print(f"\nTHROUGHPUT:")
|
|
print(f" Total requests: {results.total_requests:,}")
|
|
print(f" Requests/sec: {results.requests_per_second:.1f}")
|
|
print(f" Successful: {results.successful_requests:,} ({results.success_rate():.1f}%)")
|
|
print(f" Failed: {results.failed_requests:,}")
|
|
|
|
print(f"\nLATENCY (ms):")
|
|
print(f" Min: {results.latency_min:.1f}")
|
|
print(f" Avg: {results.latency_avg:.1f}")
|
|
print(f" P50: {results.latency_p50:.1f}")
|
|
print(f" P90: {results.latency_p90:.1f}")
|
|
print(f" P95: {results.latency_p95:.1f}")
|
|
print(f" P99: {results.latency_p99:.1f}")
|
|
print(f" Max: {results.latency_max:.1f}")
|
|
print(f" StdDev: {results.latency_stddev:.1f}")
|
|
|
|
if results.errors_by_type:
|
|
print(f"\nERRORS:")
|
|
for error_type, count in sorted(results.errors_by_type.items(), key=lambda x: -x[1]):
|
|
print(f" {error_type}: {count}")
|
|
|
|
if verbose:
|
|
print(f"\nTRANSFER:")
|
|
print(f" Total bytes: {results.total_bytes_received:,}")
|
|
print(f" Throughput: {results.throughput_mbps:.2f} Mbps")
|
|
|
|
# Recommendations
|
|
print(f"\nRECOMMENDATIONS:")
|
|
|
|
if results.latency_p99 > 500:
|
|
print(f" Warning: P99 latency ({results.latency_p99:.0f}ms) exceeds 500ms")
|
|
print(f" Consider: Connection pooling, query optimization, caching")
|
|
|
|
if results.latency_p95 > 200:
|
|
print(f" Warning: P95 latency ({results.latency_p95:.0f}ms) exceeds 200ms target")
|
|
|
|
if results.success_rate() < 99.0:
|
|
print(f" Warning: Success rate ({results.success_rate():.1f}%) below 99%")
|
|
print(f" Check server capacity and error logs")
|
|
|
|
if results.latency_stddev > results.latency_avg:
|
|
print(f" Warning: High latency variance (stddev > avg)")
|
|
print(f" Indicates inconsistent performance")
|
|
|
|
if results.success_rate() >= 99.0 and results.latency_p95 <= 200:
|
|
print(f" Performance looks good for this load level")
|
|
|
|
print("=" * 60)
|
|
|
|
|
|
def compare_results(results1: LoadTestResults, results2: LoadTestResults):
|
|
"""Compare two load test results."""
|
|
print("\n" + "=" * 60)
|
|
print("COMPARISON RESULTS")
|
|
print("=" * 60)
|
|
|
|
print(f"\n{'Metric':<25} {'Endpoint 1':<15} {'Endpoint 2':<15} {'Diff':<15}")
|
|
print("-" * 70)
|
|
|
|
# Helper to format diff
|
|
def diff_str(v1: float, v2: float, lower_better: bool = True) -> str:
|
|
if v1 == 0:
|
|
return "N/A"
|
|
diff_pct = ((v2 - v1) / v1) * 100
|
|
symbol = "-" if (diff_pct < 0) == lower_better else "+"
|
|
color_good = diff_pct < 0 if lower_better else diff_pct > 0
|
|
return f"{symbol}{abs(diff_pct):.1f}%"
|
|
|
|
metrics = [
|
|
("Requests/sec", results1.requests_per_second, results2.requests_per_second, False),
|
|
("Success rate (%)", results1.success_rate(), results2.success_rate(), False),
|
|
("Latency Avg (ms)", results1.latency_avg, results2.latency_avg, True),
|
|
("Latency P50 (ms)", results1.latency_p50, results2.latency_p50, True),
|
|
("Latency P90 (ms)", results1.latency_p90, results2.latency_p90, True),
|
|
("Latency P95 (ms)", results1.latency_p95, results2.latency_p95, True),
|
|
("Latency P99 (ms)", results1.latency_p99, results2.latency_p99, True),
|
|
]
|
|
|
|
for name, v1, v2, lower_better in metrics:
|
|
print(f"{name:<25} {v1:<15.1f} {v2:<15.1f} {diff_str(v1, v2, lower_better):<15}")
|
|
|
|
print("-" * 70)
|
|
|
|
# Summary
|
|
print(f"\nEndpoint 1: {results1.target_url}")
|
|
print(f"Endpoint 2: {results2.target_url}")
|
|
|
|
# Determine winner
|
|
score1, score2 = 0, 0
|
|
|
|
if results1.requests_per_second > results2.requests_per_second:
|
|
score1 += 1
|
|
else:
|
|
score2 += 1
|
|
|
|
if results1.latency_p95 < results2.latency_p95:
|
|
score1 += 1
|
|
else:
|
|
score2 += 1
|
|
|
|
if results1.success_rate() > results2.success_rate():
|
|
score1 += 1
|
|
else:
|
|
score2 += 1
|
|
|
|
print(f"\nOverall: {'Endpoint 1' if score1 > score2 else 'Endpoint 2'} performs better")
|
|
|
|
print("=" * 60)
|
|
|
|
|
|
class APILoadTester:
|
|
"""Main load tester class with CLI integration."""
|
|
|
|
def __init__(self, urls: List[str], method: str = 'GET', body: Optional[str] = None,
|
|
headers: Optional[Dict[str, str]] = None, concurrency: int = 10,
|
|
duration: float = 10.0, timeout: float = 30.0, compare: bool = False,
|
|
verbose: bool = False, verify_ssl: bool = True):
|
|
self.urls = urls
|
|
self.method = method
|
|
self.body = body
|
|
self.headers = headers or {}
|
|
self.concurrency = concurrency
|
|
self.duration = duration
|
|
self.timeout = timeout
|
|
self.compare = compare
|
|
self.verbose = verbose
|
|
self.verify_ssl = verify_ssl
|
|
|
|
def run(self) -> Dict:
|
|
"""Execute load test(s) and return results."""
|
|
results = []
|
|
|
|
for url in self.urls:
|
|
tester = LoadTester(
|
|
url=url,
|
|
method=self.method,
|
|
body=self.body,
|
|
headers=self.headers,
|
|
concurrency=self.concurrency,
|
|
duration=self.duration,
|
|
timeout=self.timeout,
|
|
verify_ssl=self.verify_ssl,
|
|
)
|
|
|
|
result = tester.run()
|
|
results.append(result)
|
|
|
|
if not self.compare:
|
|
print_results(result, self.verbose)
|
|
|
|
if self.compare and len(results) >= 2:
|
|
compare_results(results[0], results[1])
|
|
|
|
return {
|
|
'status': 'success',
|
|
'results': [asdict(r) for r in results],
|
|
}
|
|
|
|
|
|
def parse_headers(header_args: Optional[List[str]]) -> Dict[str, str]:
|
|
"""Parse header arguments into dictionary."""
|
|
headers = {}
|
|
if header_args:
|
|
for h in header_args:
|
|
if ':' in h:
|
|
key, value = h.split(':', 1)
|
|
headers[key.strip()] = value.strip()
|
|
return headers
|
|
|
|
|
|
def main():
|
|
"""CLI entry point."""
|
|
parser = argparse.ArgumentParser(
|
|
description='HTTP load testing tool',
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog='''
|
|
Examples:
|
|
%(prog)s https://api.example.com/users --concurrency 50 --duration 30
|
|
%(prog)s https://api.example.com/orders --method POST --body '{"item": 1}'
|
|
%(prog)s https://api.example.com/v1 https://api.example.com/v2 --compare
|
|
%(prog)s https://api.example.com/health --header "Authorization: Bearer token"
|
|
'''
|
|
)
|
|
|
|
parser.add_argument(
|
|
'urls',
|
|
nargs='+',
|
|
help='URL(s) to test'
|
|
)
|
|
parser.add_argument(
|
|
'--method', '-m',
|
|
default='GET',
|
|
choices=['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
|
help='HTTP method (default: GET)'
|
|
)
|
|
parser.add_argument(
|
|
'--body', '-b',
|
|
help='Request body (JSON string)'
|
|
)
|
|
parser.add_argument(
|
|
'--header', '-H',
|
|
action='append',
|
|
dest='headers',
|
|
help='HTTP header (format: "Name: Value")'
|
|
)
|
|
parser.add_argument(
|
|
'--concurrency', '-c',
|
|
type=int,
|
|
default=10,
|
|
help='Number of concurrent requests (default: 10)'
|
|
)
|
|
parser.add_argument(
|
|
'--duration', '-d',
|
|
type=float,
|
|
default=10.0,
|
|
help='Test duration in seconds (default: 10)'
|
|
)
|
|
parser.add_argument(
|
|
'--timeout', '-t',
|
|
type=float,
|
|
default=30.0,
|
|
help='Request timeout in seconds (default: 30)'
|
|
)
|
|
parser.add_argument(
|
|
'--compare',
|
|
action='store_true',
|
|
help='Compare two endpoints (requires two URLs)'
|
|
)
|
|
parser.add_argument(
|
|
'--no-verify-ssl',
|
|
action='store_true',
|
|
help='Disable SSL certificate verification'
|
|
)
|
|
parser.add_argument(
|
|
'--verbose', '-v',
|
|
action='store_true',
|
|
help='Enable verbose output'
|
|
)
|
|
parser.add_argument(
|
|
'--json',
|
|
action='store_true',
|
|
help='Output results as JSON'
|
|
)
|
|
parser.add_argument(
|
|
'--output', '-o',
|
|
help='Output file path for results'
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Validate
|
|
if args.compare and len(args.urls) < 2:
|
|
print("Error: --compare requires two URLs", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Parse headers
|
|
headers = parse_headers(args.headers)
|
|
|
|
try:
|
|
tester = APILoadTester(
|
|
urls=args.urls,
|
|
method=args.method,
|
|
body=args.body,
|
|
headers=headers,
|
|
concurrency=args.concurrency,
|
|
duration=args.duration,
|
|
timeout=args.timeout,
|
|
compare=args.compare,
|
|
verbose=args.verbose,
|
|
verify_ssl=not args.no_verify_ssl,
|
|
)
|
|
|
|
results = tester.run()
|
|
|
|
if args.json:
|
|
output = json.dumps(results, indent=2)
|
|
if args.output:
|
|
with open(args.output, 'w') as f:
|
|
f.write(output)
|
|
print(f"\nResults written to: {args.output}")
|
|
else:
|
|
print(output)
|
|
elif args.output:
|
|
with open(args.output, 'w') as f:
|
|
json.dump(results, f, indent=2)
|
|
print(f"\nResults written to: {args.output}")
|
|
|
|
except KeyboardInterrupt:
|
|
print("\nTest interrupted by user")
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|