what
This commit is contained in:
1 parent
4201a3cbb1
commit
0cd543a21a
10 files changed
+1457
-256
No files matched your search
@@ -0,0 +1,265 @@
|
||||
"""Stress-test live control endpoints for the smoking detection server.
|
||||
|
||||
Usage example:
|
||||
python scripts/live_stress_test.py --cycles 20 --request-timeout 8 --sleep-ms 120
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepResult:
|
||||
name: str
|
||||
ok: bool
|
||||
duration_ms: int
|
||||
status_code: int | None
|
||||
body: str
|
||||
|
||||
|
||||
def _call(
|
||||
base_url: str,
|
||||
name: str,
|
||||
method: str,
|
||||
path: str,
|
||||
timeout_seconds: float,
|
||||
form: dict[str, Any] | None = None,
|
||||
) -> StepResult:
|
||||
url = f"{base_url}{path}"
|
||||
data = None
|
||||
headers: dict[str, str] = {}
|
||||
|
||||
if form is not None:
|
||||
data = urlencode(form).encode("utf-8")
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
|
||||
request = Request(url=url, data=data, method=method, headers=headers)
|
||||
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
with urlopen(request, timeout=timeout_seconds) as response:
|
||||
body = response.read().decode("utf-8", errors="replace")
|
||||
status = getattr(response, "status", None)
|
||||
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||
return StepResult(
|
||||
name=name, ok=True, duration_ms=duration_ms, status_code=status, body=body
|
||||
)
|
||||
except HTTPError as err:
|
||||
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||
try:
|
||||
body = err.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
body = str(err)
|
||||
return StepResult(
|
||||
name=name,
|
||||
ok=False,
|
||||
duration_ms=duration_ms,
|
||||
status_code=err.code,
|
||||
body=body,
|
||||
)
|
||||
except URLError as err:
|
||||
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||
return StepResult(
|
||||
name=name,
|
||||
ok=False,
|
||||
duration_ms=duration_ms,
|
||||
status_code=None,
|
||||
body=str(err.reason),
|
||||
)
|
||||
except Exception as err:
|
||||
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||
return StepResult(
|
||||
name=name,
|
||||
ok=False,
|
||||
duration_ms=duration_ms,
|
||||
status_code=None,
|
||||
body=str(err),
|
||||
)
|
||||
|
||||
|
||||
def _summarize(results: list[StepResult]) -> dict[str, Any]:
|
||||
ok_results = [r for r in results if r.ok]
|
||||
err_results = [r for r in results if not r.ok]
|
||||
durations = [r.duration_ms for r in ok_results]
|
||||
|
||||
return {
|
||||
"total": len(results),
|
||||
"ok": len(ok_results),
|
||||
"err": len(err_results),
|
||||
"avg_ms": round(sum(durations) / len(durations), 1) if durations else 0,
|
||||
"max_ms": max(durations) if durations else 0,
|
||||
"over_500ms": sum(1 for d in durations if d > 500),
|
||||
"over_1000ms": sum(1 for d in durations if d > 1000),
|
||||
}
|
||||
|
||||
|
||||
def run_stress(args: argparse.Namespace) -> tuple[list[StepResult], dict[str, Any]]:
|
||||
results: list[StepResult] = []
|
||||
|
||||
def add(result: StepResult) -> None:
|
||||
results.append(result)
|
||||
if args.verbose or not result.ok:
|
||||
status = "OK" if result.ok else "ERR"
|
||||
print(
|
||||
f"{status}|{result.name}|{result.duration_ms}ms|"
|
||||
f"status={result.status_code}|{result.body[:180]}"
|
||||
)
|
||||
|
||||
add(
|
||||
_call(
|
||||
args.base_url, "warmup-status", "GET", "/live/status", args.request_timeout
|
||||
)
|
||||
)
|
||||
add(
|
||||
_call(
|
||||
args.base_url,
|
||||
"warmup-start",
|
||||
"POST",
|
||||
"/live/start",
|
||||
args.request_timeout,
|
||||
{"source": args.source_b},
|
||||
)
|
||||
)
|
||||
|
||||
for cycle in range(1, args.cycles + 1):
|
||||
add(
|
||||
_call(
|
||||
args.base_url,
|
||||
f"cycle{cycle}-status-before",
|
||||
"GET",
|
||||
"/live/status",
|
||||
args.request_timeout,
|
||||
)
|
||||
)
|
||||
|
||||
src = args.source_b if cycle % 2 == 0 else args.source_a
|
||||
add(
|
||||
_call(
|
||||
args.base_url,
|
||||
f"cycle{cycle}-switch-{src}",
|
||||
"POST",
|
||||
"/live/switch_source",
|
||||
args.request_timeout,
|
||||
{"source": src},
|
||||
)
|
||||
)
|
||||
|
||||
if args.pause_every > 0 and cycle % args.pause_every == 0:
|
||||
add(
|
||||
_call(
|
||||
args.base_url,
|
||||
f"cycle{cycle}-pause",
|
||||
"POST",
|
||||
"/live/pause",
|
||||
args.request_timeout,
|
||||
)
|
||||
)
|
||||
time.sleep(args.pause_sleep_ms / 1000.0)
|
||||
add(
|
||||
_call(
|
||||
args.base_url,
|
||||
f"cycle{cycle}-resume",
|
||||
"POST",
|
||||
"/live/resume",
|
||||
args.request_timeout,
|
||||
)
|
||||
)
|
||||
|
||||
add(
|
||||
_call(
|
||||
args.base_url,
|
||||
f"cycle{cycle}-status-after",
|
||||
"GET",
|
||||
"/live/status",
|
||||
args.request_timeout,
|
||||
)
|
||||
)
|
||||
|
||||
time.sleep(args.sleep_ms / 1000.0)
|
||||
|
||||
add(
|
||||
_call(
|
||||
args.base_url, "final-status", "GET", "/live/status", args.request_timeout
|
||||
)
|
||||
)
|
||||
|
||||
summary = _summarize(results)
|
||||
return results, summary
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Stress test /live/* endpoints")
|
||||
parser.add_argument("--base-url", default="http://127.0.0.1:8000")
|
||||
parser.add_argument("--cycles", type=int, default=20)
|
||||
parser.add_argument("--request-timeout", type=float, default=8.0)
|
||||
parser.add_argument("--sleep-ms", type=int, default=120)
|
||||
parser.add_argument("--pause-every", type=int, default=3)
|
||||
parser.add_argument("--pause-sleep-ms", type=int, default=120)
|
||||
parser.add_argument("--source-a", default="0")
|
||||
parser.add_argument("--source-b", default="2")
|
||||
parser.add_argument("--out", default="reports/live_stress_latest.json")
|
||||
parser.add_argument("--verbose", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
started_at = datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
results, summary = run_stress(args)
|
||||
|
||||
report = {
|
||||
"started_at": started_at,
|
||||
"base_url": args.base_url,
|
||||
"config": {
|
||||
"cycles": args.cycles,
|
||||
"request_timeout": args.request_timeout,
|
||||
"sleep_ms": args.sleep_ms,
|
||||
"pause_every": args.pause_every,
|
||||
"pause_sleep_ms": args.pause_sleep_ms,
|
||||
"source_a": args.source_a,
|
||||
"source_b": args.source_b,
|
||||
},
|
||||
"summary": summary,
|
||||
"errors": [asdict(r) for r in results if not r.ok],
|
||||
"tail": [asdict(r) for r in results[-10:]],
|
||||
"all_results": [asdict(r) for r in results],
|
||||
}
|
||||
|
||||
out_path = Path(args.out)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||
|
||||
print(
|
||||
"SUMMARY|"
|
||||
f"total={summary['total']}|ok={summary['ok']}|err={summary['err']}|"
|
||||
f"avg_ms={summary['avg_ms']}|max_ms={summary['max_ms']}|"
|
||||
f"over_500ms={summary['over_500ms']}|over_1000ms={summary['over_1000ms']}"
|
||||
)
|
||||
print(f"REPORT|{out_path.as_posix()}")
|
||||
|
||||
if summary["err"] > 0:
|
||||
print("ERRORS_BEGIN")
|
||||
for err in report["errors"][:8]:
|
||||
print(
|
||||
f"ERR|{err['name']}|{err['duration_ms']}ms|"
|
||||
f"status={err['status_code']}|{err['body'][:180]}"
|
||||
)
|
||||
print("ERRORS_END")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user