#!/usr/bin/env python3 """Small read-only latency probe for alpha sizing; not a capacity benchmark.""" from __future__ import annotations import argparse import concurrent.futures import json import math import os import statistics import time import urllib.error import urllib.request PUBLIC = { "activity": "/api/v1/activity?hours=72&limit=20", "records": "/api/v1/records?limit=50", } ADMIN = { "staging": "/api/v1/admin/external-observations?status=review&limit=50", "moderation": "/api/v1/admin/catch-reports?status=pending&limit=50", } def percentile(values: list[float], percent: float) -> float: return sorted(values)[max(0, math.ceil(len(values) * percent) - 1)] def request_once(url: str, token: str | None, timeout: float) -> tuple[float, int]: headers = {"Accept": "application/json", "User-Agent": "RF4-Spotter-load-smoke/0.1"} if token: headers["Authorization"] = f"Bearer {token}" started = time.perf_counter() try: with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=timeout) as response: response.read() status = response.status except urllib.error.HTTPError as exc: exc.read() status = exc.code return (time.perf_counter() - started) * 1000, status def probe(name: str, url: str, *, token: str | None, requests: int, concurrency: int, timeout: float) -> dict: request_once(url, token, timeout) # one explicit warm-up excluded from statistics with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as pool: samples = list(pool.map(lambda _: request_once(url, token, timeout), range(requests))) latencies = [sample[0] for sample in samples] statuses: dict[str, int] = {} for _, status in samples: statuses[str(status)] = statuses.get(str(status), 0) + 1 return { "scenario": name, "requests": requests, "concurrency": concurrency, "p50_ms": round(statistics.median(latencies), 2), "p95_ms": round(percentile(latencies, .95), 2), "max_ms": round(max(latencies), 2), "statuses": statuses, } def main() -> int: parser = argparse.ArgumentParser(description="Read-only RF4 alpha latency probe") parser.add_argument("--base-url", required=True) parser.add_argument("--requests", type=int, default=100) parser.add_argument("--concurrency", type=int, default=5) parser.add_argument("--timeout", type=float, default=10) args = parser.parse_args() if not 1 <= args.requests <= 10_000 or not 1 <= args.concurrency <= 100: parser.error("requests must be 1..10000 and concurrency 1..100") base = args.base_url.rstrip("/") token = os.environ.get("ADMIN_TOKEN") results = [probe(name, base + path, token=None, requests=args.requests, concurrency=args.concurrency, timeout=args.timeout) for name, path in PUBLIC.items()] if token: results.extend(probe(name, base + path, token=token, requests=args.requests, concurrency=args.concurrency, timeout=args.timeout) for name, path in ADMIN.items()) print(json.dumps({"base_url": base, "admin_scenarios_skipped": not bool(token), "results": results}, ensure_ascii=False, indent=2)) return 1 if any(any(not status.startswith("2") for status in result["statuses"]) for result in results) else 0 if __name__ == "__main__": raise SystemExit(main())