overload-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. overload/__init__.py +3 -0
  2. overload/__main__.py +5 -0
  3. overload/cli.py +393 -0
  4. overload/collection/__init__.py +1 -0
  5. overload/collection/environment.py +23 -0
  6. overload/collection/models.py +88 -0
  7. overload/collection/parser.py +220 -0
  8. overload/collection/variables.py +84 -0
  9. overload/config_file.py +73 -0
  10. overload/engine/__init__.py +1 -0
  11. overload/engine/assertions.py +151 -0
  12. overload/engine/auth.py +87 -0
  13. overload/engine/events.py +50 -0
  14. overload/engine/http_client.py +274 -0
  15. overload/engine/load_patterns.py +730 -0
  16. overload/engine/models.py +254 -0
  17. overload/engine/rate_limiter.py +124 -0
  18. overload/engine/runner.py +86 -0
  19. overload/report/__init__.py +1 -0
  20. overload/report/exporters.py +77 -0
  21. overload/report/generator.py +71 -0
  22. overload/report/templates/report.html +369 -0
  23. overload/utils/__init__.py +1 -0
  24. overload/utils/naming.py +26 -0
  25. overload/web/__init__.py +1 -0
  26. overload/web/app.py +38 -0
  27. overload/web/routes/__init__.py +1 -0
  28. overload/web/routes/api.py +461 -0
  29. overload/web/routes/ws.py +77 -0
  30. overload/web/static/css/app.css +242 -0
  31. overload/web/static/js/app.js +241 -0
  32. overload/web/static/js/charts.js +385 -0
  33. overload/web/static/js/collection.js +344 -0
  34. overload/web/static/js/runner.js +625 -0
  35. overload/web/templates/index.html +23 -0
  36. overload_cli-0.1.0.dist-info/METADATA +267 -0
  37. overload_cli-0.1.0.dist-info/RECORD +40 -0
  38. overload_cli-0.1.0.dist-info/WHEEL +4 -0
  39. overload_cli-0.1.0.dist-info/entry_points.txt +2 -0
  40. overload_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,730 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import random
6
+ import time
7
+ from collections.abc import Callable, Coroutine
8
+ from typing import Any, Protocol
9
+
10
+ from overload.collection.models import ParsedRequest
11
+ from overload.collection.variables import VariableContext
12
+ from overload.engine.http_client import HttpClient
13
+ from overload.engine.models import (
14
+ PatternConfig,
15
+ RequestDistribution,
16
+ RequestResult,
17
+ RunProgress,
18
+ Stats,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ ProgressCallback = Callable[[RunProgress], Coroutine[Any, Any, None]]
24
+
25
+
26
+ class LoadPattern(Protocol):
27
+ async def execute(
28
+ self,
29
+ client: HttpClient,
30
+ requests: list[ParsedRequest],
31
+ variables: VariableContext,
32
+ config: PatternConfig,
33
+ run_id: str,
34
+ cancel_event: asyncio.Event,
35
+ on_progress: ProgressCallback | None = None,
36
+ ) -> list[RequestResult]: ...
37
+
38
+
39
+ def _pick_request(
40
+ requests: list[ParsedRequest],
41
+ index: int,
42
+ distribution: RequestDistribution,
43
+ ) -> ParsedRequest:
44
+ if distribution == RequestDistribution.RANDOM:
45
+ return random.choice(requests)
46
+ return requests[index % len(requests)]
47
+
48
+
49
+ async def _fire_one(
50
+ client: HttpClient,
51
+ request: ParsedRequest,
52
+ variables: VariableContext,
53
+ semaphore: asyncio.Semaphore,
54
+ ) -> RequestResult:
55
+ async with semaphore:
56
+ return await client.execute(request, variables)
57
+
58
+
59
+ _last_emit_state: dict[str, tuple[int, float]] = {}
60
+
61
+
62
+ async def _emit_progress(
63
+ callback: ProgressCallback | None,
64
+ run_id: str,
65
+ results: list[RequestResult],
66
+ total: int,
67
+ phase: str,
68
+ start_time: float,
69
+ ) -> None:
70
+ if callback is None:
71
+ return
72
+
73
+ elapsed = time.monotonic() - start_time
74
+ completed = len(results)
75
+
76
+ prev_count, prev_time = _last_emit_state.get(run_id, (0, start_time))
77
+ dt = time.monotonic() - prev_time
78
+ dr = completed - prev_count
79
+ instant_rps = round(dr / max(dt, 0.1), 1) if dt > 0.05 else 0.0
80
+ _last_emit_state[run_id] = (completed, time.monotonic())
81
+
82
+ status_codes: dict[int, int] = {}
83
+ total_latency = 0.0
84
+ error_count = 0
85
+ for r in results:
86
+ status_codes[r.status_code] = status_codes.get(r.status_code, 0) + 1
87
+ total_latency += r.latency_ms
88
+ if r.status_code < 200 or r.status_code >= 400:
89
+ error_count += 1
90
+
91
+ recent_slice = results[-20:] if results else []
92
+ base_idx = max(0, completed - len(recent_slice))
93
+ recent = [
94
+ {
95
+ "idx": base_idx + i,
96
+ "name": r.request_name,
97
+ "method": r.method,
98
+ "status": r.status_code,
99
+ "latency": round(r.latency_ms, 1),
100
+ "url": r.url[:100],
101
+ "error": r.error,
102
+ }
103
+ for i, r in enumerate(recent_slice)
104
+ ]
105
+
106
+ try:
107
+ await callback(RunProgress(
108
+ run_id=run_id,
109
+ total_requests=total,
110
+ completed_requests=completed,
111
+ current_rps=instant_rps,
112
+ phase=phase,
113
+ elapsed_seconds=round(elapsed, 1),
114
+ error_count=error_count,
115
+ status_codes=status_codes,
116
+ avg_latency_ms=round(total_latency / max(completed, 1), 1),
117
+ recent_results=recent,
118
+ ))
119
+ except Exception:
120
+ logger.exception("Error in progress callback")
121
+
122
+
123
+ async def _cancel_tasks(tasks: list[asyncio.Task]) -> None:
124
+ for t in tasks:
125
+ if not t.done():
126
+ t.cancel()
127
+ await asyncio.gather(*tasks, return_exceptions=True)
128
+
129
+
130
+ class BurstPattern:
131
+ async def execute(
132
+ self,
133
+ client: HttpClient,
134
+ requests: list[ParsedRequest],
135
+ variables: VariableContext,
136
+ config: PatternConfig,
137
+ run_id: str,
138
+ cancel_event: asyncio.Event,
139
+ on_progress: ProgressCallback | None = None,
140
+ ) -> list[RequestResult]:
141
+ n = config.total_requests
142
+ sem = asyncio.Semaphore(config.concurrency)
143
+ results: list[RequestResult] = []
144
+ start_time = time.monotonic()
145
+
146
+ logger.info("Burst: %d requests, concurrency=%d", n, config.concurrency)
147
+
148
+ await _emit_progress(on_progress, run_id, results, n, "Preparing burst...", start_time)
149
+
150
+ tasks = [
151
+ asyncio.create_task(
152
+ _fire_one(
153
+ client,
154
+ _pick_request(requests, i, config.distribution),
155
+ variables,
156
+ sem,
157
+ )
158
+ )
159
+ for i in range(n)
160
+ ]
161
+
162
+ await _emit_progress(on_progress, run_id, results, n, f"Firing {n} requests...", start_time)
163
+
164
+ progress_interval = max(n // 20, 1)
165
+ for i, coro in enumerate(asyncio.as_completed(tasks)):
166
+ if cancel_event.is_set():
167
+ await _cancel_tasks(tasks)
168
+ break
169
+ result = await coro
170
+ results.append(result)
171
+ if (i + 1) % progress_interval == 0 or i == n - 1:
172
+ await _emit_progress(on_progress, run_id, results, n, "running", start_time)
173
+
174
+ return results
175
+
176
+
177
+ class RampPattern:
178
+ async def execute(
179
+ self,
180
+ client: HttpClient,
181
+ requests: list[ParsedRequest],
182
+ variables: VariableContext,
183
+ config: PatternConfig,
184
+ run_id: str,
185
+ cancel_event: asyncio.Event,
186
+ on_progress: ProgressCallback | None = None,
187
+ ) -> list[RequestResult]:
188
+ start_rps = config.ramp_start_rps
189
+ end_rps = config.ramp_end_rps
190
+ step = config.step_rps
191
+ step_dur = config.step_duration_seconds
192
+ sem = asyncio.Semaphore(config.concurrency)
193
+ all_results: list[RequestResult] = []
194
+ start_time = time.monotonic()
195
+ request_idx = 0
196
+
197
+ total_estimate = sum(
198
+ rps * step_dur
199
+ for rps in range(start_rps, end_rps + 1, step)
200
+ )
201
+
202
+ logger.info("Ramp: %d -> %d req/s, step=%d, step_duration=%ds", start_rps, end_rps, step, step_dur)
203
+
204
+ await _emit_progress(on_progress, run_id, all_results, total_estimate, "Preparing ramp test...", start_time)
205
+
206
+ for rps in range(start_rps, end_rps + 1, step):
207
+ if cancel_event.is_set():
208
+ break
209
+
210
+ phase = f"Ramping: {rps} req/s"
211
+ interval = 1.0 / rps
212
+ batch_tasks: list[asyncio.Task] = []
213
+ batch_start = time.monotonic()
214
+
215
+ for i in range(rps * step_dur):
216
+ if cancel_event.is_set():
217
+ break
218
+ delay = batch_start + i * interval - time.monotonic()
219
+ if delay > 0:
220
+ await asyncio.sleep(delay)
221
+ req = _pick_request(requests, request_idx, config.distribution)
222
+ request_idx += 1
223
+ batch_tasks.append(
224
+ asyncio.create_task(_fire_one(client, req, variables, sem))
225
+ )
226
+
227
+ if cancel_event.is_set():
228
+ await _cancel_tasks(batch_tasks)
229
+ break
230
+
231
+ batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True)
232
+ for r in batch_results:
233
+ if isinstance(r, RequestResult):
234
+ all_results.append(r)
235
+
236
+ await _emit_progress(on_progress, run_id, all_results, total_estimate, phase, start_time)
237
+
238
+ return all_results
239
+
240
+
241
+ class LoadTestPattern:
242
+ async def execute(
243
+ self,
244
+ client: HttpClient,
245
+ requests: list[ParsedRequest],
246
+ variables: VariableContext,
247
+ config: PatternConfig,
248
+ run_id: str,
249
+ cancel_event: asyncio.Event,
250
+ on_progress: ProgressCallback | None = None,
251
+ ) -> list[RequestResult]:
252
+ target_rps = config.target_rps
253
+ ramp_up = config.ramp_up_seconds
254
+ hold = config.hold_duration_seconds
255
+ ramp_down = config.ramp_down_seconds
256
+ sem = asyncio.Semaphore(config.concurrency)
257
+ all_results: list[RequestResult] = []
258
+ in_flight: list[asyncio.Task] = []
259
+ start_time = time.monotonic()
260
+ request_idx = 0
261
+
262
+ total_duration = ramp_up + hold + ramp_down
263
+ total_estimate = int(target_rps * (ramp_up / 2 + hold + ramp_down / 2))
264
+
265
+ logger.info(
266
+ "Load: target=%d req/s, ramp_up=%ds, hold=%ds, ramp_down=%ds",
267
+ target_rps, ramp_up, hold, ramp_down,
268
+ )
269
+
270
+ await _emit_progress(on_progress, run_id, all_results, total_estimate, "Preparing load test...", start_time)
271
+
272
+ async def _run_at_rps(rps: int, duration: float, phase: str) -> None:
273
+ nonlocal request_idx
274
+ if rps <= 0 or duration <= 0:
275
+ return
276
+ interval = 1.0 / rps
277
+ phase_start = time.monotonic()
278
+ i = 0
279
+ while time.monotonic() - phase_start < duration:
280
+ if cancel_event.is_set():
281
+ return
282
+ delay = phase_start + i * interval - time.monotonic()
283
+ if delay > 0:
284
+ await asyncio.sleep(delay)
285
+ req = _pick_request(requests, request_idx, config.distribution)
286
+ request_idx += 1
287
+ task = asyncio.create_task(_fire_one(client, req, variables, sem))
288
+ task.add_done_callback(lambda t: all_results.append(t.result()) if not t.cancelled() and t.exception() is None else None)
289
+ in_flight.append(task)
290
+ i += 1
291
+ if i % max(rps // 2, 1) == 0:
292
+ await _emit_progress(on_progress, run_id, all_results, total_estimate, phase, start_time)
293
+
294
+ # Ramp up
295
+ if ramp_up > 0:
296
+ steps = max(ramp_up, 1)
297
+ for s in range(steps):
298
+ if cancel_event.is_set():
299
+ break
300
+ current_rps = max(1, int(target_rps * (s + 1) / steps))
301
+ await _run_at_rps(current_rps, 1.0, f"Ramping up: {current_rps} req/s")
302
+
303
+ # Hold
304
+ if not cancel_event.is_set():
305
+ await _run_at_rps(target_rps, hold, f"Holding at {target_rps} req/s")
306
+
307
+ # Ramp down
308
+ if ramp_down > 0 and not cancel_event.is_set():
309
+ steps = max(ramp_down, 1)
310
+ for s in range(steps):
311
+ if cancel_event.is_set():
312
+ break
313
+ current_rps = max(1, int(target_rps * (steps - s) / steps))
314
+ await _run_at_rps(current_rps, 1.0, f"Ramping down: {current_rps} req/s")
315
+
316
+ if cancel_event.is_set():
317
+ await _cancel_tasks(in_flight)
318
+ else:
319
+ pending = [t for t in in_flight if not t.done()]
320
+ if pending:
321
+ await asyncio.gather(*pending, return_exceptions=True)
322
+
323
+ await _emit_progress(on_progress, run_id, all_results, total_estimate, "complete", start_time)
324
+ return all_results
325
+
326
+
327
+ class StressPattern:
328
+ async def execute(
329
+ self,
330
+ client: HttpClient,
331
+ requests: list[ParsedRequest],
332
+ variables: VariableContext,
333
+ config: PatternConfig,
334
+ run_id: str,
335
+ cancel_event: asyncio.Event,
336
+ on_progress: ProgressCallback | None = None,
337
+ ) -> list[RequestResult]:
338
+ start_rps = config.start_rps
339
+ step = config.step_rps
340
+ step_dur = config.step_duration_seconds
341
+ max_rps = config.max_rps
342
+ failure_threshold = config.failure_threshold_pct / 100.0
343
+ sem = asyncio.Semaphore(config.concurrency)
344
+ all_results: list[RequestResult] = []
345
+ start_time = time.monotonic()
346
+ request_idx = 0
347
+
348
+ logger.info(
349
+ "Stress: start=%d, step=%d, max=%d, failure_threshold=%.0f%%",
350
+ start_rps, step, max_rps, config.failure_threshold_pct,
351
+ )
352
+
353
+ await _emit_progress(on_progress, run_id, all_results, 0, "Preparing stress test...", start_time)
354
+
355
+ rps = start_rps
356
+ breaking_point = 0
357
+
358
+ while rps <= max_rps:
359
+ if cancel_event.is_set():
360
+ break
361
+
362
+ phase = f"Stress: {rps} req/s"
363
+ interval = 1.0 / rps
364
+ batch_tasks: list[asyncio.Task] = []
365
+ batch_start = time.monotonic()
366
+
367
+ for i in range(rps * step_dur):
368
+ if cancel_event.is_set():
369
+ break
370
+ delay = batch_start + i * interval - time.monotonic()
371
+ if delay > 0:
372
+ await asyncio.sleep(delay)
373
+ req = _pick_request(requests, request_idx, config.distribution)
374
+ request_idx += 1
375
+ batch_tasks.append(
376
+ asyncio.create_task(_fire_one(client, req, variables, sem))
377
+ )
378
+
379
+ if cancel_event.is_set():
380
+ await _cancel_tasks(batch_tasks)
381
+ break
382
+
383
+ batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True)
384
+ step_results = [r for r in batch_results if isinstance(r, RequestResult)]
385
+ all_results.extend(step_results)
386
+
387
+ if step_results:
388
+ errors = sum(1 for r in step_results if r.status_code < 200 or r.status_code >= 400)
389
+ error_rate = errors / len(step_results)
390
+ logger.info("Stress step %d req/s: error_rate=%.1f%%", rps, error_rate * 100)
391
+
392
+ if error_rate >= failure_threshold:
393
+ breaking_point = rps
394
+ logger.info("Breaking point found at %d req/s (%.1f%% errors)", rps, error_rate * 100)
395
+ break
396
+
397
+ await _emit_progress(on_progress, run_id, all_results, 0, phase, start_time)
398
+ rps += step
399
+
400
+ if breaking_point == 0 and not cancel_event.is_set():
401
+ breaking_point = rps - step
402
+
403
+ await _emit_progress(
404
+ on_progress, run_id, all_results, 0,
405
+ f"complete (breaking point: {breaking_point} req/s)", start_time,
406
+ )
407
+ return all_results
408
+
409
+
410
+ class SpikePattern:
411
+ async def execute(
412
+ self,
413
+ client: HttpClient,
414
+ requests: list[ParsedRequest],
415
+ variables: VariableContext,
416
+ config: PatternConfig,
417
+ run_id: str,
418
+ cancel_event: asyncio.Event,
419
+ on_progress: ProgressCallback | None = None,
420
+ ) -> list[RequestResult]:
421
+ baseline_rps = config.baseline_rps
422
+ spike_rps = config.spike_rps
423
+ baseline_dur = config.baseline_duration_seconds
424
+ spike_dur = config.spike_duration_seconds
425
+ recovery_dur = config.recovery_duration_seconds
426
+ sem = asyncio.Semaphore(config.concurrency)
427
+ all_results: list[RequestResult] = []
428
+ start_time = time.monotonic()
429
+ request_idx = 0
430
+
431
+ total_estimate = baseline_rps * baseline_dur + spike_rps * spike_dur + baseline_rps * recovery_dur
432
+
433
+ logger.info(
434
+ "Spike: baseline=%d, spike=%d, baseline_dur=%ds, spike_dur=%ds, recovery=%ds",
435
+ baseline_rps, spike_rps, baseline_dur, spike_dur, recovery_dur,
436
+ )
437
+
438
+ await _emit_progress(on_progress, run_id, all_results, total_estimate, "Preparing spike test...", start_time)
439
+
440
+ async def _run_phase(rps: int, duration: float, phase: str) -> None:
441
+ nonlocal request_idx
442
+ if rps <= 0 or duration <= 0:
443
+ return
444
+ interval = 1.0 / rps
445
+ phase_start = time.monotonic()
446
+ tasks: list[asyncio.Task] = []
447
+ i = 0
448
+ while time.monotonic() - phase_start < duration:
449
+ if cancel_event.is_set():
450
+ await _cancel_tasks(tasks)
451
+ return
452
+ delay = phase_start + i * interval - time.monotonic()
453
+ if delay > 0:
454
+ await asyncio.sleep(delay)
455
+ req = _pick_request(requests, request_idx, config.distribution)
456
+ request_idx += 1
457
+ tasks.append(asyncio.create_task(_fire_one(client, req, variables, sem)))
458
+ i += 1
459
+ if i % max(rps, 1) == 0:
460
+ await _emit_progress(on_progress, run_id, all_results, total_estimate, phase, start_time)
461
+
462
+ if cancel_event.is_set():
463
+ await _cancel_tasks(tasks)
464
+ return
465
+
466
+ results = await asyncio.gather(*tasks, return_exceptions=True)
467
+ for r in results:
468
+ if isinstance(r, RequestResult):
469
+ all_results.append(r)
470
+ await _emit_progress(on_progress, run_id, all_results, total_estimate, phase, start_time)
471
+
472
+ await _run_phase(baseline_rps, baseline_dur, f"Baseline: {baseline_rps} req/s")
473
+ if not cancel_event.is_set():
474
+ await _run_phase(spike_rps, spike_dur, f"SPIKE: {spike_rps} req/s")
475
+ if not cancel_event.is_set():
476
+ await _run_phase(baseline_rps, recovery_dur, f"Recovery: {baseline_rps} req/s")
477
+
478
+ return all_results
479
+
480
+
481
+ class SoakPattern:
482
+ async def execute(
483
+ self,
484
+ client: HttpClient,
485
+ requests: list[ParsedRequest],
486
+ variables: VariableContext,
487
+ config: PatternConfig,
488
+ run_id: str,
489
+ cancel_event: asyncio.Event,
490
+ on_progress: ProgressCallback | None = None,
491
+ ) -> list[RequestResult]:
492
+ rps = config.soak_rps
493
+ duration = config.soak_duration_seconds
494
+ sem = asyncio.Semaphore(config.concurrency)
495
+ all_results: list[RequestResult] = []
496
+ start_time = time.monotonic()
497
+ request_idx = 0
498
+ total_estimate = rps * duration
499
+
500
+ logger.info("Soak: %d req/s for %ds", rps, duration)
501
+
502
+ await _emit_progress(on_progress, run_id, all_results, total_estimate, "Preparing soak test...", start_time)
503
+
504
+ interval = 1.0 / rps
505
+ phase_start = time.monotonic()
506
+ tasks: list[asyncio.Task] = []
507
+ i = 0
508
+
509
+ while time.monotonic() - phase_start < duration:
510
+ if cancel_event.is_set():
511
+ break
512
+ delay = phase_start + i * interval - time.monotonic()
513
+ if delay > 0:
514
+ await asyncio.sleep(delay)
515
+ req = _pick_request(requests, request_idx, config.distribution)
516
+ request_idx += 1
517
+ task = asyncio.create_task(_fire_one(client, req, variables, sem))
518
+ task.add_done_callback(
519
+ lambda t: all_results.append(t.result()) if not t.cancelled() and t.exception() is None else None
520
+ )
521
+ tasks.append(task)
522
+ i += 1
523
+
524
+ if i % (rps * 5) == 0:
525
+ elapsed_min = (time.monotonic() - phase_start) / 60
526
+ phase = f"Soaking: {rps} req/s ({elapsed_min:.1f}m elapsed)"
527
+ await _emit_progress(on_progress, run_id, all_results, total_estimate, phase, start_time)
528
+
529
+ if cancel_event.is_set():
530
+ await _cancel_tasks(tasks)
531
+ else:
532
+ pending = [t for t in tasks if not t.done()]
533
+ if pending:
534
+ await asyncio.gather(*pending, return_exceptions=True)
535
+
536
+ await _emit_progress(on_progress, run_id, all_results, total_estimate, "complete", start_time)
537
+ return all_results
538
+
539
+
540
+ class BreakpointPattern:
541
+ async def execute(
542
+ self,
543
+ client: HttpClient,
544
+ requests: list[ParsedRequest],
545
+ variables: VariableContext,
546
+ config: PatternConfig,
547
+ run_id: str,
548
+ cancel_event: asyncio.Event,
549
+ on_progress: ProgressCallback | None = None,
550
+ ) -> list[RequestResult]:
551
+ start_rps = config.start_rps
552
+ precision = config.precision_rps
553
+ latency_threshold = config.latency_threshold_ms
554
+ error_threshold = config.error_threshold_pct / 100.0
555
+ max_rps = config.max_rps
556
+ sem = asyncio.Semaphore(config.concurrency)
557
+ all_results: list[RequestResult] = []
558
+ start_time = time.monotonic()
559
+ request_idx = 0
560
+
561
+ logger.info(
562
+ "Breakpoint: start=%d, precision=%d, latency_threshold=%.0fms, error_threshold=%.0f%%",
563
+ start_rps, precision, latency_threshold, config.error_threshold_pct,
564
+ )
565
+
566
+ await _emit_progress(on_progress, run_id, all_results, 0, "Preparing breakpoint test...", start_time)
567
+
568
+ low = start_rps
569
+ high = max_rps
570
+ last_good = start_rps
571
+ breakpoint_rps = 0
572
+
573
+ async def _probe(rps: int) -> tuple[float, float]:
574
+ nonlocal request_idx
575
+ interval = 1.0 / rps
576
+ probe_tasks = []
577
+ probe_start = time.monotonic()
578
+ probe_count = rps * 5
579
+
580
+ for idx in range(probe_count):
581
+ if cancel_event.is_set():
582
+ break
583
+ delay = probe_start + idx * interval - time.monotonic()
584
+ if delay > 0:
585
+ await asyncio.sleep(delay)
586
+ req = _pick_request(requests, request_idx, config.distribution)
587
+ request_idx += 1
588
+ probe_tasks.append(asyncio.create_task(_fire_one(client, req, variables, sem)))
589
+
590
+ probe_results = await asyncio.gather(*probe_tasks, return_exceptions=True)
591
+ valid = [r for r in probe_results if isinstance(r, RequestResult)]
592
+ all_results.extend(valid)
593
+
594
+ if not valid:
595
+ return 0.0, 1.0
596
+
597
+ latencies = sorted(r.latency_ms for r in valid)
598
+ p95_idx = int(len(latencies) * 0.95)
599
+ p95 = latencies[min(p95_idx, len(latencies) - 1)]
600
+
601
+ errors = sum(1 for r in valid if r.status_code < 200 or r.status_code >= 400)
602
+ error_rate = errors / len(valid)
603
+
604
+ return p95, error_rate
605
+
606
+ # Binary search for breakpoint
607
+ while high - low > precision:
608
+ if cancel_event.is_set():
609
+ break
610
+
611
+ mid = (low + high) // 2
612
+ phase = f"Probing: {mid} req/s"
613
+ await _emit_progress(on_progress, run_id, all_results, 0, phase, start_time)
614
+
615
+ p95, error_rate = await _probe(mid)
616
+ logger.info("Probe %d req/s: p95=%.1fms, error_rate=%.1f%%", mid, p95, error_rate * 100)
617
+
618
+ if p95 > latency_threshold or error_rate > error_threshold:
619
+ high = mid
620
+ breakpoint_rps = mid
621
+ else:
622
+ low = mid
623
+ last_good = mid
624
+
625
+ if breakpoint_rps == 0:
626
+ breakpoint_rps = high
627
+
628
+ await _emit_progress(
629
+ on_progress, run_id, all_results, 0,
630
+ f"complete (breakpoint: {breakpoint_rps} req/s, last good: {last_good} req/s)",
631
+ start_time,
632
+ )
633
+ return all_results
634
+
635
+
636
+ class CustomPattern:
637
+ async def execute(
638
+ self,
639
+ client: HttpClient,
640
+ requests: list[ParsedRequest],
641
+ variables: VariableContext,
642
+ config: PatternConfig,
643
+ run_id: str,
644
+ cancel_event: asyncio.Event,
645
+ on_progress: ProgressCallback | None = None,
646
+ ) -> list[RequestResult]:
647
+ stages = config.stages
648
+ if not stages:
649
+ logger.warning("Custom pattern: no stages defined")
650
+ return []
651
+
652
+ sem = asyncio.Semaphore(config.concurrency)
653
+ all_results: list[RequestResult] = []
654
+ start_time = time.monotonic()
655
+ request_idx = 0
656
+
657
+ total_estimate = sum(
658
+ s.get("rps", 0) * s.get("duration", 0) for s in stages
659
+ )
660
+
661
+ logger.info("Custom: %d stages, estimated %d requests", len(stages), total_estimate)
662
+
663
+ await _emit_progress(on_progress, run_id, all_results, total_estimate, "Preparing custom test...", start_time)
664
+
665
+ for stage_num, stage in enumerate(stages, 1):
666
+ if cancel_event.is_set():
667
+ break
668
+
669
+ rps = stage.get("rps", 10)
670
+ duration = stage.get("duration", 30)
671
+ phase = f"Stage {stage_num}/{len(stages)}: {rps} req/s for {duration}s"
672
+
673
+ if rps <= 0 or duration <= 0:
674
+ continue
675
+
676
+ interval = 1.0 / rps
677
+ stage_start = time.monotonic()
678
+ tasks: list[asyncio.Task] = []
679
+ i = 0
680
+
681
+ while time.monotonic() - stage_start < duration:
682
+ if cancel_event.is_set():
683
+ break
684
+ delay = stage_start + i * interval - time.monotonic()
685
+ if delay > 0:
686
+ await asyncio.sleep(delay)
687
+ req = _pick_request(requests, request_idx, config.distribution)
688
+ request_idx += 1
689
+ task = asyncio.create_task(_fire_one(client, req, variables, sem))
690
+ task.add_done_callback(
691
+ lambda t: all_results.append(t.result()) if not t.cancelled() and t.exception() is None else None
692
+ )
693
+ tasks.append(task)
694
+ i += 1
695
+
696
+ if i % max(rps, 1) == 0:
697
+ await _emit_progress(on_progress, run_id, all_results, total_estimate, phase, start_time)
698
+
699
+ if cancel_event.is_set():
700
+ await _cancel_tasks(tasks)
701
+ break
702
+
703
+ pending = [t for t in tasks if not t.done()]
704
+ if pending:
705
+ await asyncio.gather(*pending, return_exceptions=True)
706
+
707
+ await _emit_progress(on_progress, run_id, all_results, total_estimate, phase, start_time)
708
+
709
+ await _emit_progress(on_progress, run_id, all_results, total_estimate, "complete", start_time)
710
+ return all_results
711
+
712
+
713
+ PATTERNS: dict[str, LoadPattern] = {
714
+ "burst": BurstPattern(),
715
+ "ramp": RampPattern(),
716
+ "load": LoadTestPattern(),
717
+ "stress": StressPattern(),
718
+ "spike": SpikePattern(),
719
+ "soak": SoakPattern(),
720
+ "breakpoint": BreakpointPattern(),
721
+ "custom": CustomPattern(),
722
+ }
723
+
724
+
725
+ def get_pattern(name: str) -> LoadPattern:
726
+ pattern = PATTERNS.get(name.lower())
727
+ if pattern is None:
728
+ available = ", ".join(PATTERNS.keys())
729
+ raise ValueError(f"Unknown pattern: {name}. Available: {available}")
730
+ return pattern