Files
colibre/tests/benchmark_api.py
T
Colin Maudry 7571b9a984 API: script de benchmark tests/benchmark_api.py (#78)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 14:12:27 +02:00

209 lines
6.2 KiB
Python

#!/usr/bin/env python
"""Benchmark de l'endpoint /data de l'API privée decp.info.
Usage :
python tests/benchmark_api.py --url http://localhost:8050/api/v1/data --token decpinfo_xxx
python tests/benchmark_api.py --url https://decp.info/api/v1/data --token decpinfo_xxx --runs 20
Le script mesure le temps de réponse de chaque scénario et affiche un tableau
de statistiques (min / médiane / p95 / max).
"""
import argparse
import statistics
import sys
import time
import httpx
SCENARIOS: list[dict] = [
{
"name": "sans filtre (page 1, 50 résultats)",
"params": {"page": 1, "page_size": 50},
},
{
"name": "filtre __exact sur département",
"params": {"acheteur_departement_code__exact": "44", "page_size": 50},
},
{
"name": "filtre __contains sur objet",
"params": {"objet__contains": "informatique", "page_size": 50},
},
{
"name": "filtre __greater sur date",
"params": {"dateNotification__greater": "2024-01-01", "page_size": 50},
},
{
"name": "filtre __strictly_greater sur montant",
"params": {"montant__strictly_greater": "100000", "page_size": 50},
},
{
"name": "filtre __in (CPV multiples)",
"params": {"cpv_8__in": "72000000,72200000", "page_size": 50},
},
{
"name": "filtre __isnull",
"params": {"montant__isnull": "", "page_size": 50},
},
{
"name": "tri desc + colonnes sélectionnées",
"params": {
"dateNotification__sort": "desc",
"columns": "uid,objet,montant,dateNotification",
"page_size": 50,
},
},
{
"name": "filtres combinés",
"params": {
"acheteur_departement_code__exact": "75",
"dateNotification__greater": "2023-01-01",
"montant__strictly_greater": "50000",
"dateNotification__sort": "desc",
"page_size": 50,
},
},
{
"name": "count=false (économise COUNT(*))",
"params": {"page_size": 50, "count": "false"},
},
{
"name": "page 2",
"params": {"page": 2, "page_size": 50},
},
]
COL_NAME = 44
COL_STATUS = 8
COL_STAT = 10
def percentile(data: list[float], p: float) -> float:
if not data:
return float("nan")
sorted_data = sorted(data)
k = (len(sorted_data) - 1) * p / 100
lo, hi = int(k), min(int(k) + 1, len(sorted_data) - 1)
return sorted_data[lo] + (sorted_data[hi] - sorted_data[lo]) * (k - lo)
def run_benchmark(base_url: str, token: str | None, runs: int) -> None:
auth_header = {"Authorization": f"Bearer {token}"} if token else {}
results: list[dict] = []
print(f"\nBenchmark {base_url}")
print(f"Scénarios : {len(SCENARIOS)} | Répétitions : {runs}\n")
for scenario in SCENARIOS:
timings: list[float] = []
last_status = 0
for _ in range(runs):
headers = auth_header
url = base_url
params = scenario["params"]
try:
t0 = time.perf_counter()
resp = httpx.get(url, params=params, headers=headers, timeout=30)
elapsed = (time.perf_counter() - t0) * 1000
last_status = resp.status_code
timings.append(elapsed)
except httpx.RequestError as exc:
print(f" ERREUR réseau : {exc}")
last_status = 0
break
if timings:
results.append(
{
"name": scenario["name"],
"status": last_status,
"min": min(timings),
"median": percentile(timings, 50),
"p95": percentile(timings, 95),
"max": max(timings),
"mean": statistics.mean(timings),
"runs": len(timings),
}
)
status_str = f"[{last_status}]"
print(
f" {scenario['name'][:COL_NAME]:<{COL_NAME}}"
f" {status_str:<{COL_STATUS}}"
f" médiane {results[-1]['median']:>7.1f} ms"
f" p95 {results[-1]['p95']:>7.1f} ms"
)
else:
print(f" {scenario['name'][:COL_NAME]:<{COL_NAME}} ÉCHEC")
_print_summary(results)
def _print_summary(results: list[dict]) -> None:
if not results:
return
sep = "-" * (COL_NAME + COL_STATUS + 4 * (COL_STAT + 3) + 6)
header = (
f"\n{'Scénario':<{COL_NAME}} {'Status':<{COL_STATUS}}"
f" {'Min (ms)':>{COL_STAT}}"
f" {'Médiane (ms)':>{COL_STAT}}"
f" {'P95 (ms)':>{COL_STAT}}"
f" {'Max (ms)':>{COL_STAT}}"
)
print(f"\n{'=' * len(sep)}")
print("RÉSUMÉ")
print(f"{'=' * len(sep)}")
print(header)
print(sep)
for r in results:
status_str = f"[{r['status']}]"
print(
f"{r['name'][:COL_NAME]:<{COL_NAME}}"
f" {status_str:<{COL_STATUS}}"
f" {r['min']:>{COL_STAT}.1f}"
f" {r['median']:>{COL_STAT}.1f}"
f" {r['p95']:>{COL_STAT}.1f}"
f" {r['max']:>{COL_STAT}.1f}"
)
print(sep)
medians = [r["median"] for r in results]
print(
f"\nMédiane globale : {statistics.mean(medians):.1f} ms"
f" | Pire p95 : {max(r['p95'] for r in results):.1f} ms"
)
def main() -> None:
parser = argparse.ArgumentParser(description="Benchmark de l'API privée decp.info")
parser.add_argument(
"--url",
default="http://localhost:8050/api/v1/data",
help="URL complète de l'endpoint /data (défaut : http://localhost:8050/api/v1/data)",
)
parser.add_argument(
"--token",
default=None,
help="Token Bearer API (format : decpinfo_xxxxx) — omis si l'API n'exige pas d'auth",
)
parser.add_argument(
"--runs",
type=int,
default=5,
help="Nombre de répétitions par scénario (défaut : 5)",
)
args = parser.parse_args()
if args.runs < 1:
print("--runs doit être ≥ 1", file=sys.stderr)
sys.exit(1)
run_benchmark(args.url, args.token, args.runs)
if __name__ == "__main__":
main()