7571b9a984
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
209 lines
6.2 KiB
Python
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()
|