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.
- robot_parallel_requests/__init__.py +24 -0
- robot_parallel_requests/library.py +513 -0
- robot_parallel_requests/metrics.py +95 -0
- robot_parallel_requests/rate_limiter.py +76 -0
- robot_parallel_requests/response_store.py +33 -0
- robot_parallel_requests/retry.py +106 -0
- robot_parallel_requests/session.py +27 -0
- robot_parallel_requests/tasks.py +18 -0
- robot_parallel_requests/transport/__init__.py +3 -0
- robot_parallel_requests/transport/base.py +9 -0
- robot_parallel_requests/transport/httpx_async_future.py +82 -0
- robot_parallel_requests/transport/httpx_sync.py +53 -0
- robot_parallel_requests/worker.py +136 -0
- robotframework_parallel_requests-0.1.0.dist-info/METADATA +524 -0
- robotframework_parallel_requests-0.1.0.dist-info/RECORD +18 -0
- robotframework_parallel_requests-0.1.0.dist-info/WHEEL +5 -0
- robotframework_parallel_requests-0.1.0.dist-info/licenses/LICENSE +21 -0
- robotframework_parallel_requests-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|