robotframework-parallel-requests 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.
@@ -0,0 +1,24 @@
1
+ """robot_parallel_requests package
2
+
3
+ This package exposes the `ParallelRequests` library class used by Robot Framework.
4
+
5
+ By default we export the class at the package level so users can import either:
6
+
7
+ ```
8
+ Library robot_parallel_requests.ParallelRequests
9
+ ```
10
+
11
+ As a convenience, we also provide a module-level alias named ``robot_parallel_requests``
12
+ pointing to the class so importing the package name alone may work with some Robot
13
+ Framework import behaviours (see README). The explicit import above is still the
14
+ recommended, unambiguous form.
15
+ """
16
+
17
+ from .library import ParallelRequests
18
+
19
+ # Convenience alias: allow `Library robot_parallel_requests` in addition to
20
+ # the explicit `robot_parallel_requests.ParallelRequests` form. This is optional
21
+ # but can be helpful for users who expect to import libraries by package name.
22
+ robot_parallel_requests = ParallelRequests
23
+
24
+ __all__ = ["ParallelRequests", "robot_parallel_requests"]
@@ -0,0 +1,513 @@
1
+ import re
2
+ from pathlib import Path
3
+ from typing import Optional, Any, Dict, List, Sequence, Mapping
4
+ from robot.api import logger
5
+
6
+ try:
7
+ from importlib.metadata import PackageNotFoundError, version as get_installed_version
8
+ except ImportError: # pragma: no cover
9
+ from importlib_metadata import PackageNotFoundError, version as get_installed_version
10
+ from .tasks import RequestTask
11
+ from .transport.httpx_sync import HttpxSyncTransport
12
+ from .worker import WorkerPool
13
+ from .session import Session
14
+ from .rate_limiter import TokenBucket
15
+ from .retry import RetryPolicy
16
+ from .metrics import MetricsCollector
17
+
18
+
19
+ def _discover_library_version() -> str:
20
+ """Resolve package version from installed metadata, then pyproject fallback."""
21
+ try:
22
+ return get_installed_version("robotframework-parallel-requests")
23
+ except PackageNotFoundError:
24
+ pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
25
+ if pyproject.exists():
26
+ content = pyproject.read_text(encoding="utf-8")
27
+ match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
28
+ if match:
29
+ return match.group(1)
30
+ return "0.0.0"
31
+
32
+
33
+ class ParallelRequests:
34
+ """Robot Framework library exposing parallelized request keywords.
35
+
36
+ All keywords are exposed with a `Parallel ` prefix (e.g. `Parallel Create Session`)
37
+ to clearly signal parallel execution semantics.
38
+
39
+ Args:
40
+ worker_count: Number of worker threads. Default is 5.
41
+ fail_on_timeout: If True, wait keywords raise when requests remain incomplete.
42
+ http2: Enable HTTP/2 on the shared httpx client (requires the optional ``h2`` package).
43
+ cancel_pending_on_timeout: Cancel futures that have not started when a wait times out.
44
+ """
45
+
46
+ ROBOT_LIBRARY_SCOPE = "TEST"
47
+ ROBOT_LIBRARY_VERSION = _discover_library_version()
48
+ ROBOT_LIBRARY_DOC_FORMAT = "REST"
49
+ ROBOT_LIBRARY_LISTENER = "SELF"
50
+ ROBOT_LISTENER_API_VERSION = 2
51
+
52
+ def __init__(
53
+ self,
54
+ worker_count: int = 5,
55
+ fail_on_timeout: bool = False,
56
+ http2: bool = False,
57
+ cancel_pending_on_timeout: bool = True,
58
+ ):
59
+ self.worker_count = int(worker_count)
60
+ self.fail_on_timeout = bool(fail_on_timeout)
61
+ self.http2 = bool(http2)
62
+ self.cancel_pending_on_timeout = bool(cancel_pending_on_timeout)
63
+ self.sessions: Dict[str, Session] = {}
64
+ self.rate_limiter: Optional[TokenBucket] = None
65
+ self.retry_policy: Optional[RetryPolicy] = None
66
+ self.metrics = MetricsCollector()
67
+ self.transport = self._build_transport(self.worker_count)
68
+ self.worker = WorkerPool(
69
+ self.transport,
70
+ max_workers=self.worker_count,
71
+ metrics_collector=self.metrics,
72
+ )
73
+ self._shutdown_done = False
74
+
75
+ def _build_transport(self, worker_count: int) -> HttpxSyncTransport:
76
+ return HttpxSyncTransport(max_workers=worker_count, http2=self.http2)
77
+
78
+ def _end_test(self, name, attrs): # Robot listener API
79
+ self.Parallel_Shutdown()
80
+
81
+ def _close(self): # Robot library close hook
82
+ self.Parallel_Shutdown()
83
+
84
+ def _resolve_session_alias(self, session: Optional[str]) -> Optional[str]:
85
+ if session:
86
+ return session
87
+ if "default" in self.sessions:
88
+ return "default"
89
+ return None
90
+
91
+ def _apply_session(self, url: str, session: Optional[str], kwargs: dict) -> tuple:
92
+ alias = self._resolve_session_alias(session)
93
+ if not alias:
94
+ return url, kwargs
95
+ if alias not in self.sessions:
96
+ raise ValueError(f"Session alias not found: {alias}")
97
+ sess = self.sessions[alias]
98
+ url = sess.resolve_url(url)
99
+ request_headers = kwargs.get("headers", {})
100
+ kwargs = dict(kwargs)
101
+ kwargs["headers"] = sess.merge_headers(request_headers)
102
+ return url, kwargs
103
+
104
+ def Parallel_Create_Session(
105
+ self,
106
+ alias: str = "default",
107
+ base_url: Optional[str] = None,
108
+ headers: Optional[dict] = None,
109
+ ):
110
+ """Parallel Create Session alias base_url=None headers=None
111
+
112
+ Create a named session with base URL and default headers.
113
+
114
+ When ``alias`` is ``default`` (the default), subsequent queue keywords
115
+ use this session automatically if ``session=`` is omitted.
116
+
117
+ Args:
118
+ alias: Session name for reference in requests
119
+ base_url: Base URL prepended to relative URLs
120
+ headers: Default headers merged with request headers
121
+ """
122
+ session = Session(alias=alias, base_url=base_url, headers=headers or {})
123
+ self.sessions[alias] = session
124
+ logger.info(f"Session '{alias}' created with base_url='{base_url}' and headers={headers}")
125
+
126
+ def Parallel_Queue_Request(
127
+ self,
128
+ method: str,
129
+ url: str,
130
+ session: Optional[str] = None,
131
+ id: Optional[str] = None,
132
+ **kwargs,
133
+ ) -> str:
134
+ """Parallel Queue Request method url session=None id=None kwargs
135
+
136
+ Queue a request to be sent by the worker pool and return a response id.
137
+
138
+ method is the HTTP method (GET, POST, PUT, DELETE, etc.).
139
+ url can be absolute or relative when a session base_url is provided.
140
+ session is an optional session alias. If omitted and a ``default`` session
141
+ exists, that session is used automatically.
142
+ id is an optional custom response id.
143
+ kwargs are forwarded to httpx.request (headers, params, json, data,
144
+ timeout, follow_redirects, auth, cookies, and other valid httpx options).
145
+
146
+ Example usage:
147
+ Parallel Queue Request GET /users session=api
148
+ Parallel Queue Request POST /users json={'name': 'John'} headers={'X-API-Key': 'secret'}
149
+ """
150
+ resolved_session = self._resolve_session_alias(session)
151
+ url, kwargs = self._apply_session(url, session, kwargs)
152
+
153
+ task = RequestTask(method=method, url=url, kwargs=kwargs, session_name=resolved_session)
154
+ if id:
155
+ task.id = id
156
+
157
+ # Rate limiting is enforced by workers at execution time, not enqueue time.
158
+ if self.rate_limiter:
159
+ task.rate_limiter = self.rate_limiter
160
+
161
+ # Apply retry policy if configured
162
+ if self.retry_policy:
163
+ task.retry_policy = self.retry_policy
164
+
165
+ rid = self.worker.submit(task)
166
+ return rid
167
+
168
+ def Parallel_Queue_Many(
169
+ self,
170
+ requests: Sequence[Any],
171
+ session: Optional[str] = None,
172
+ ) -> List[str]:
173
+ """Parallel Queue Many requests session=None
174
+
175
+ Queue many requests at once and return their response ids in order.
176
+
177
+ ``requests`` is a list of specs. Each spec may be:
178
+
179
+ - a mapping with ``method`` and ``url`` (plus optional ``id``, ``session``,
180
+ and httpx kwargs such as ``headers``, ``json``, ``params``)
181
+ - a two-item sequence ``[method, url]``
182
+
183
+ The optional top-level ``session`` is used when a spec omits its own session.
184
+
185
+ Example (Python-like list of dicts in Robot)::
186
+
187
+ @{reqs}= Create List
188
+ ... ${{"method": "GET", "url": "/users/1"}}
189
+ ... ${{"method": "GET", "url": "/users/2"}}
190
+ ${ids}= Parallel Queue Many ${reqs} session=api
191
+ """
192
+ if not isinstance(requests, Sequence) or isinstance(requests, (str, bytes)):
193
+ raise TypeError(
194
+ "requests must be a sequence of request specs (dicts or [method, url] pairs)"
195
+ )
196
+
197
+ ids: List[str] = []
198
+ for index, spec in enumerate(requests):
199
+ if isinstance(spec, Mapping):
200
+ data = dict(spec)
201
+ try:
202
+ method = data.pop("method")
203
+ url = data.pop("url")
204
+ except KeyError as exc:
205
+ raise ValueError(
206
+ f"Request spec at index {index} missing required key: {exc.args[0]}"
207
+ ) from exc
208
+ req_id = data.pop("id", None)
209
+ req_session = data.pop("session", session)
210
+ ids.append(
211
+ self.Parallel_Queue_Request(
212
+ method, url, session=req_session, id=req_id, **data
213
+ )
214
+ )
215
+ elif isinstance(spec, Sequence) and not isinstance(spec, (str, bytes)):
216
+ if len(spec) < 2:
217
+ raise ValueError(
218
+ f"Request spec at index {index} must be [method, url], got: {spec!r}"
219
+ )
220
+ method, url = spec[0], spec[1]
221
+ ids.append(self.Parallel_Queue_Request(method, url, session=session))
222
+ else:
223
+ raise TypeError(
224
+ f"Unsupported request spec at index {index}: {type(spec).__name__}"
225
+ )
226
+ return ids
227
+
228
+ def Parallel_Start_Workers(self):
229
+ """Parallel Start Workers (workers are started lazily in MVP)"""
230
+ # No-op for MVP since ThreadPool is ready on init
231
+ return
232
+
233
+ def _wait_pending_and_warn(
234
+ self,
235
+ timeout: Optional[float] = None,
236
+ fail_on_timeout: Optional[bool] = None,
237
+ ) -> list:
238
+ completed, incomplete, batch_ids = self.worker.wait_all(
239
+ timeout=timeout,
240
+ cancel_pending=self.cancel_pending_on_timeout,
241
+ )
242
+ if incomplete > 0:
243
+ total = completed + incomplete
244
+ message = (
245
+ f"{incomplete} of {total} pending requests did not complete within "
246
+ f"timeout={timeout!r}. Consider increasing the timeout."
247
+ )
248
+ should_fail = self.fail_on_timeout if fail_on_timeout is None else bool(fail_on_timeout)
249
+ if should_fail:
250
+ raise TimeoutError(message)
251
+ logger.warn(message)
252
+ return batch_ids
253
+
254
+ def Parallel_Wait_For_All_Requests(
255
+ self,
256
+ timeout: Optional[float] = None,
257
+ fail_on_timeout: Optional[bool] = None,
258
+ ):
259
+ """Parallel Wait For All Requests timeout=None fail_on_timeout=None
260
+
261
+ Wait for all queued requests to finish (optionally with timeout seconds).
262
+
263
+ By default logs a warning when the timeout expires. Pass
264
+ ``fail_on_timeout=${True}`` (or set the library init argument) to raise
265
+ ``TimeoutError`` instead. Futures that have not started yet are cancelled
266
+ when a timeout occurs (configurable via ``cancel_pending_on_timeout``).
267
+ """
268
+ self._wait_pending_and_warn(timeout=timeout, fail_on_timeout=fail_on_timeout)
269
+
270
+ def Parallel_Wait_For_All_And_Get_Responses(
271
+ self,
272
+ timeout: Optional[float] = None,
273
+ fail_on_timeout: Optional[bool] = None,
274
+ ):
275
+ """Parallel Wait For All And Get Responses timeout=None fail_on_timeout=None
276
+
277
+ Wait for all queued requests to finish (optionally with timeout seconds)
278
+ and return a list of response objects (or exceptions) in submission order.
279
+ """
280
+ batch_ids = self._wait_pending_and_warn(timeout=timeout, fail_on_timeout=fail_on_timeout)
281
+ return self.worker.get_responses_in_order(batch_ids)
282
+
283
+ def Parallel_Get_Response_Object(self, id: str) -> Any:
284
+ """Parallel Get Response Object id
285
+
286
+ Return the underlying httpx.Response or an Exception for the given id."""
287
+ return self.worker.get_response(id)
288
+
289
+ def Parallel_Get_Response_Status(self, id: str):
290
+ """Parallel Get Response Status id
291
+
292
+ Return the HTTP status code for the given response ID.
293
+
294
+ Raises the original exception if the request failed before a response was produced.
295
+ Returns None if no status code attribute is present.
296
+ """
297
+ resp = self.worker.get_response(id)
298
+ if isinstance(resp, Exception):
299
+ raise resp
300
+ return getattr(resp, "status_code", None)
301
+
302
+ def Parallel_Get_Response_Body(self, id: str):
303
+ """Parallel Get Response Body id
304
+
305
+ Return the response body (text) for the given response ID.
306
+
307
+ Raises the original exception if the request failed.
308
+ """
309
+ resp = self.worker.get_response(id)
310
+ if isinstance(resp, Exception):
311
+ raise resp
312
+ return resp.text
313
+
314
+ def Parallel_Get_Response_JSON(self, id: str):
315
+ """Parallel Get Response JSON id
316
+
317
+ Parse and return JSON payload for the given response ID.
318
+
319
+ Raises the original exception if the request failed or a JSON decoding
320
+ error if the body is not valid JSON.
321
+ """
322
+ resp = self.worker.get_response(id)
323
+ if isinstance(resp, Exception):
324
+ raise resp
325
+ return resp.json()
326
+
327
+ def Parallel_Set_Worker_Count(self, count: int):
328
+ """Parallel Set Worker Count count
329
+
330
+ Adjust worker count (recreates pool and transport; connection limits follow)."""
331
+ self.worker.shutdown()
332
+ self._shutdown_done = False
333
+ self.worker_count = int(count)
334
+ self.transport = self._build_transport(self.worker_count)
335
+ self.worker = WorkerPool(
336
+ self.transport,
337
+ max_workers=self.worker_count,
338
+ metrics_collector=self.metrics,
339
+ )
340
+
341
+ def Parallel_Shutdown(self):
342
+ """Parallel Shutdown
343
+
344
+ Shutdown the worker pool and transport clients.
345
+
346
+ Also invoked automatically at end of each test via the library listener.
347
+ """
348
+ if self._shutdown_done:
349
+ return
350
+ self.worker.shutdown()
351
+ self._shutdown_done = True
352
+
353
+ # HTTP Method Convenience Keywords
354
+
355
+ def Parallel_GET(self, url: str, session: Optional[str] = None, id: Optional[str] = None, **kwargs) -> str:
356
+ """Parallel GET url session=None id=None kwargs
357
+
358
+ Convenience keyword for GET requests. Equivalent to Parallel Queue Request with method=GET.
359
+ """
360
+ return self.Parallel_Queue_Request("GET", url, session=session, id=id, **kwargs)
361
+
362
+ def Parallel_POST(self, url: str, session: Optional[str] = None, id: Optional[str] = None, **kwargs) -> str:
363
+ """Parallel POST url session=None id=None kwargs
364
+
365
+ Convenience keyword for POST requests. Equivalent to Parallel Queue Request with method=POST.
366
+ """
367
+ return self.Parallel_Queue_Request("POST", url, session=session, id=id, **kwargs)
368
+
369
+ def Parallel_PUT(self, url: str, session: Optional[str] = None, id: Optional[str] = None, **kwargs) -> str:
370
+ """Parallel PUT url session=None id=None kwargs
371
+
372
+ Convenience keyword for PUT requests. Equivalent to Parallel Queue Request with method=PUT.
373
+ """
374
+ return self.Parallel_Queue_Request("PUT", url, session=session, id=id, **kwargs)
375
+
376
+ def Parallel_DELETE(self, url: str, session: Optional[str] = None, id: Optional[str] = None, **kwargs) -> str:
377
+ """Parallel DELETE url session=None id=None kwargs
378
+
379
+ Convenience keyword for DELETE requests. Equivalent to Parallel Queue Request with method=DELETE.
380
+ """
381
+ return self.Parallel_Queue_Request("DELETE", url, session=session, id=id, **kwargs)
382
+
383
+ def Parallel_PATCH(self, url: str, session: Optional[str] = None, id: Optional[str] = None, **kwargs) -> str:
384
+ """Parallel PATCH url session=None id=None kwargs
385
+
386
+ Convenience keyword for PATCH requests. Equivalent to Parallel Queue Request with method=PATCH.
387
+ """
388
+ return self.Parallel_Queue_Request("PATCH", url, session=session, id=id, **kwargs)
389
+
390
+ def Parallel_HEAD(self, url: str, session: Optional[str] = None, id: Optional[str] = None, **kwargs) -> str:
391
+ """Parallel HEAD url session=None id=None kwargs
392
+
393
+ Convenience keyword for HEAD requests. Equivalent to Parallel Queue Request with method=HEAD.
394
+ """
395
+ return self.Parallel_Queue_Request("HEAD", url, session=session, id=id, **kwargs)
396
+
397
+ def Parallel_OPTIONS(self, url: str, session: Optional[str] = None, id: Optional[str] = None, **kwargs) -> str:
398
+ """Parallel OPTIONS url session=None id=None kwargs
399
+
400
+ Convenience keyword for OPTIONS requests. Equivalent to Parallel Queue Request with method=OPTIONS.
401
+ """
402
+ return self.Parallel_Queue_Request("OPTIONS", url, session=session, id=id, **kwargs)
403
+
404
+ # Rate Limiting & Retry Configuration
405
+
406
+ def Parallel_Set_Rate_Limit(self, requests: float, per: str = "second", burst_size: Optional[int] = None):
407
+ """Parallel Set Rate Limit requests per="second" burst_size=None
408
+
409
+ Configure rate limiting for queued requests using token bucket algorithm.
410
+
411
+ Args:
412
+ requests: Maximum requests in the selected unit (e.g., 1.75 per second, 105 per minute)
413
+ per: Time unit for rate limiting (e.g., "second", "minute")
414
+ burst_size: Maximum burst size (defaults to requests + 1)
415
+
416
+ Example usage:
417
+ Parallel Set Rate Limit 1.75
418
+ Parallel Set Rate Limit 1.75 burst_size=10
419
+ Parallel Set Rate Limit 105 per="minute"
420
+ """
421
+ if requests <= 0:
422
+ raise ValueError(f"Rate limit requests must be positive, got: {requests}")
423
+
424
+ # Calculate rate in tokens per second based on 'per' unit
425
+ unit_multipliers = {"second": 1, "minute": 1 / 60, "hour": 1 / 3600}
426
+ if per not in unit_multipliers:
427
+ raise ValueError(f"Unsupported time unit for rate limiting: {per}")
428
+
429
+ if burst_size is None:
430
+ burst_size = max(1, int(requests) + 1)
431
+
432
+ rate = requests * unit_multipliers[per]
433
+ self.rate_limiter = TokenBucket(rate=rate, burst_size=burst_size)
434
+
435
+ def Parallel_Clear_Rate_Limit(self):
436
+ """Parallel Clear Rate Limit
437
+
438
+ Remove rate limiting configuration.
439
+ """
440
+ self.rate_limiter = None
441
+
442
+ def Parallel_Set_Retry_Policy(
443
+ self,
444
+ max_retries: int = 3,
445
+ backoff_factor: float = 2.0,
446
+ retry_statuses: Optional[str] = None,
447
+ jitter: float = 0.1,
448
+ ):
449
+ """Parallel Set Retry Policy max_retries=3 backoff_factor=2.0 retry_statuses=None jitter=0.1
450
+
451
+ Configure retry behavior with exponential backoff for failed requests.
452
+
453
+ By default retries status codes 429/500/502/503/504 and transport errors
454
+ (timeouts, network errors, remote protocol errors). Wait time is
455
+ ``backoff_factor ** attempt`` plus a small random jitter.
456
+
457
+ Args:
458
+ max_retries: Maximum number of retry attempts
459
+ backoff_factor: Multiplier for wait time between retries
460
+ retry_statuses: Comma-separated status codes to retry (defaults to 429,500,502,503,504)
461
+ jitter: Fraction of base wait added as random jitter (0 disables)
462
+
463
+ Example usage:
464
+ Parallel Set Retry Policy max_retries=3 backoff_factor=2.0
465
+ Parallel Set Retry Policy max_retries=5 retry_statuses=429 jitter=0
466
+ """
467
+ statuses = None
468
+ if retry_statuses:
469
+ statuses = [int(s.strip()) for s in retry_statuses.split(",")]
470
+
471
+ self.retry_policy = RetryPolicy(
472
+ max_retries=max_retries,
473
+ backoff_factor=backoff_factor,
474
+ retry_statuses=statuses,
475
+ jitter=float(jitter),
476
+ )
477
+
478
+ def Parallel_Clear_Retry_Policy(self):
479
+ """Parallel Clear Retry Policy
480
+
481
+ Remove retry policy configuration.
482
+ """
483
+ self.retry_policy = None
484
+
485
+ # Metrics
486
+
487
+ def Parallel_Get_Metrics(self) -> dict:
488
+ """Parallel Get Metrics
489
+
490
+ Get aggregated metrics summary for all completed requests.
491
+
492
+ Returns dict with:
493
+ - total_requests: Total number of requests
494
+ - successful_requests: Requests with 2xx or 3xx status
495
+ - failed_requests: Requests with errors or 4xx/5xx status
496
+ - avg_duration: Average request duration in seconds
497
+ - min_duration: Minimum request duration
498
+ - max_duration: Maximum request duration
499
+ - requests_per_second: Actual request rate in requests per second
500
+ - status_code_counts: Dict of status code frequencies
501
+
502
+ Example usage:
503
+ ${metrics}= Parallel Get Metrics
504
+ Log Total requests: ${metrics['total_requests']}
505
+ """
506
+ return self.metrics.get_summary()
507
+
508
+ def Parallel_Clear_Metrics(self):
509
+ """Parallel Clear Metrics
510
+
511
+ Clear all collected metrics.
512
+ """
513
+ self.metrics.clear()
@@ -0,0 +1,95 @@
1
+ """Metrics collection for parallel requests."""
2
+ import time
3
+ from typing import Dict, List, Optional
4
+ from dataclasses import dataclass, field
5
+ from threading import Lock
6
+
7
+
8
+ @dataclass
9
+ class RequestMetric:
10
+ """Metrics for a single request."""
11
+ request_id: str
12
+ method: str
13
+ url: str
14
+ status_code: Optional[int] = None
15
+ duration: Optional[float] = None # seconds
16
+ timestamp: float = field(default_factory=time.time)
17
+ completed_at: Optional[float] = None
18
+ error: Optional[str] = None
19
+ retries: int = 0
20
+
21
+
22
+ class MetricsCollector:
23
+ """Thread-safe metrics collection for parallel requests."""
24
+
25
+ def __init__(self):
26
+ self._metrics: List[RequestMetric] = []
27
+ self._lock = Lock()
28
+
29
+ def record_request(self, metric: RequestMetric):
30
+ """Record a request metric."""
31
+ with self._lock:
32
+ self._metrics.append(metric)
33
+
34
+ def get_metrics(self) -> List[RequestMetric]:
35
+ """Get all collected metrics."""
36
+ with self._lock:
37
+ return self._metrics.copy()
38
+
39
+ def get_summary(self) -> Dict:
40
+ """Get aggregated metrics summary."""
41
+ with self._lock:
42
+ if not self._metrics:
43
+ return {
44
+ 'total_requests': 0,
45
+ 'successful_requests': 0,
46
+ 'failed_requests': 0,
47
+ 'avg_duration': 0.0,
48
+ 'min_duration': 0.0,
49
+ 'max_duration': 0.0,
50
+ 'requests_per_second': 0.0,
51
+ }
52
+
53
+ successful = [
54
+ m for m in self._metrics
55
+ if m.status_code and 200 <= m.status_code < 400
56
+ ]
57
+ failed = [
58
+ m for m in self._metrics
59
+ if m.error or (m.status_code and m.status_code >= 400)
60
+ ]
61
+ durations = [m.duration for m in self._metrics if m.duration is not None]
62
+
63
+ # Calculate request rate using start-to-last-completion span
64
+ completed_times = [
65
+ m.completed_at for m in self._metrics if m.completed_at is not None
66
+ ]
67
+ if len(self._metrics) > 1 and completed_times:
68
+ time_span = max(completed_times) - min(m.timestamp for m in self._metrics)
69
+ requests_per_second = len(self._metrics) / time_span if time_span > 0 else 0.0
70
+ else:
71
+ requests_per_second = 0.0
72
+
73
+ return {
74
+ 'total_requests': len(self._metrics),
75
+ 'successful_requests': len(successful),
76
+ 'failed_requests': len(failed),
77
+ 'avg_duration': sum(durations) / len(durations) if durations else 0.0,
78
+ 'min_duration': min(durations) if durations else 0.0,
79
+ 'max_duration': max(durations) if durations else 0.0,
80
+ 'requests_per_second': requests_per_second,
81
+ 'status_code_counts': self._count_status_codes(),
82
+ }
83
+
84
+ def _count_status_codes(self) -> Dict[int, int]:
85
+ """Count occurrences of each status code."""
86
+ counts = {}
87
+ for metric in self._metrics:
88
+ if metric.status_code:
89
+ counts[metric.status_code] = counts.get(metric.status_code, 0) + 1
90
+ return counts
91
+
92
+ def clear(self):
93
+ """Clear all collected metrics."""
94
+ with self._lock:
95
+ self._metrics.clear()