reqstorm 2.0.1__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.
reqstorm/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ """reqstorm: send large numbers of HTTP requests concurrently with asyncio.
2
+
3
+ import asyncio
4
+ import reqstorm
5
+
6
+ async def main():
7
+ results = await reqstorm.fetch_all(["https://example.com", "https://example.org"])
8
+ for result in results:
9
+ print(result.url, result.status if result.ok else result.error)
10
+
11
+ asyncio.run(main())
12
+ """
13
+
14
+ from ._client import (
15
+ DEFAULT_RETRY_STATUSES,
16
+ Attempt,
17
+ HTTPStatusError,
18
+ Request,
19
+ Result,
20
+ Results,
21
+ fetch_all,
22
+ stream,
23
+ )
24
+ from ._files import Summary, fetch_to_db, fetch_to_file
25
+ from ._legacy import Reqt
26
+ from ._limits import parse_rate
27
+ from ._plan import Estimate, estimate
28
+ from ._sync import fetch_all_sync, fetch_to_db_sync, fetch_to_file_sync, stream_sync
29
+
30
+ __version__ = "2.0.1"
31
+
32
+ __all__ = [
33
+ "DEFAULT_RETRY_STATUSES",
34
+ "Attempt",
35
+ "Estimate",
36
+ "HTTPStatusError",
37
+ "Request",
38
+ "Reqt",
39
+ "Result",
40
+ "Results",
41
+ "Summary",
42
+ "estimate",
43
+ "fetch_all",
44
+ "fetch_all_sync",
45
+ "fetch_to_db",
46
+ "fetch_to_db_sync",
47
+ "fetch_to_file",
48
+ "fetch_to_file_sync",
49
+ "stream",
50
+ "parse_rate",
51
+ "stream_sync",
52
+ "__version__",
53
+ ]
reqstorm/_client.py ADDED
@@ -0,0 +1,560 @@
1
+ """Concurrent HTTP requests with per-request results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import json as jsonlib
8
+ import ssl as ssllib
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from typing import (
12
+ Any,
13
+ AsyncGenerator,
14
+ AsyncIterator,
15
+ Awaitable,
16
+ Callable,
17
+ Dict,
18
+ Iterable,
19
+ List,
20
+ Mapping,
21
+ Optional,
22
+ Sequence,
23
+ Union,
24
+ overload,
25
+ )
26
+
27
+ import aiohttp
28
+ from multidict import CIMultiDict, CIMultiDictProxy
29
+
30
+ from ._limits import HostRateLimiter, RateLimit
31
+ from ._progress import Progress, ProgressTarget
32
+
33
+ __all__ = ["Attempt", "HTTPStatusError", "Request", "Result", "Results", "fetch_all", "stream"]
34
+
35
+ DEFAULT_RETRY_STATUSES = (429, 500, 502, 503, 504)
36
+ _MAX_RETRY_AFTER = 60.0
37
+
38
+
39
+ class HTTPStatusError(Exception):
40
+ """Raised by ``Result.raise_for_error`` for a response status of 400 or above."""
41
+
42
+ def __init__(self, result: Result) -> None:
43
+ super().__init__(f"{result.status} for {result.request.method or 'GET'} {result.url}")
44
+ self.result = result
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Request:
49
+ """A single request. Fields left as ``None`` take the defaults given to ``fetch_all``."""
50
+
51
+ url: str
52
+ method: Optional[str] = None
53
+ headers: Optional[Mapping[str, str]] = None
54
+ params: Optional[Mapping[str, Any]] = None
55
+ json: Any = None
56
+ data: Any = None
57
+
58
+
59
+ def _describe(error: Optional[BaseException]) -> Optional[str]:
60
+ if error is None:
61
+ return None
62
+ message = str(error)
63
+ return f"{type(error).__name__}: {message}" if message else type(error).__name__
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Attempt:
68
+ """One try at a request: the status it got or the error it raised."""
69
+
70
+ number: int
71
+ status: Optional[int]
72
+ error: Optional[BaseException]
73
+ elapsed: float
74
+
75
+ def to_dict(self) -> Dict[str, Any]:
76
+ return {
77
+ "attempt": self.number,
78
+ "status": self.status,
79
+ "error": _describe(self.error),
80
+ "elapsed": round(self.elapsed, 3),
81
+ }
82
+
83
+
84
+ @dataclass
85
+ class Result:
86
+ """The outcome of one request: a response, or the error that prevented one."""
87
+
88
+ request: Request
89
+ index: int
90
+ status: Optional[int] = None
91
+ headers: Mapping[str, str] = field(default_factory=lambda: CIMultiDictProxy(CIMultiDict()))
92
+ body: bytes = b""
93
+ error: Optional[BaseException] = None
94
+ attempts: int = 0
95
+ elapsed: float = 0.0
96
+ final_url: Optional[str] = None
97
+ history: List[Attempt] = field(default_factory=list)
98
+
99
+ @property
100
+ def url(self) -> str:
101
+ return self.request.url
102
+
103
+ @property
104
+ def method(self) -> str:
105
+ return self.request.method or "GET"
106
+
107
+ @property
108
+ def ok(self) -> bool:
109
+ """True when a response arrived and its status is below 400."""
110
+ return self.error is None and self.status is not None and self.status < 400
111
+
112
+ def text(self, encoding: Optional[str] = None) -> str:
113
+ return self.body.decode(encoding or _charset(self.headers) or "utf-8", errors="replace")
114
+
115
+ def json(self) -> Any:
116
+ return jsonlib.loads(self.body)
117
+
118
+ def raise_for_error(self) -> None:
119
+ """Raise the request's error, or ``HTTPStatusError`` for a status of 400 or above."""
120
+ if self.error is not None:
121
+ raise self.error
122
+ if self.status is not None and self.status >= 400:
123
+ raise HTTPStatusError(self)
124
+
125
+ def to_dict(self, body: str = "none", include_headers: bool = False) -> Dict[str, Any]:
126
+ """A JSON-serialisable summary, as written by ``fetch_to_file``.
127
+
128
+ Args:
129
+ body: ``"none"`` (default), ``"text"`` or ``"base64"``.
130
+ include_headers: Include the response headers.
131
+ """
132
+ record: Dict[str, Any] = {
133
+ "index": self.index,
134
+ "method": self.method,
135
+ "url": self.url,
136
+ "status": self.status,
137
+ "ok": self.ok,
138
+ "error": _describe(self.error),
139
+ "attempts": self.attempts,
140
+ "elapsed": round(self.elapsed, 3),
141
+ "final_url": self.final_url,
142
+ "history": [attempt.to_dict() for attempt in self.history],
143
+ }
144
+ if include_headers:
145
+ record["headers"] = dict(self.headers)
146
+ if body == "text":
147
+ record["body"] = self.text()
148
+ elif body == "base64":
149
+ record["body"] = base64.b64encode(self.body).decode("ascii")
150
+ elif body != "none":
151
+ raise ValueError("body must be 'none', 'text' or 'base64'")
152
+ return record
153
+
154
+ def __repr__(self) -> str:
155
+ outcome = f"status={self.status}" if self.error is None else f"error={self.error!r}"
156
+ return f"<Result {self.method} {self.url} {outcome} attempts={self.attempts}>"
157
+
158
+
159
+ class Results(List[Result]):
160
+ """The list returned by ``fetch_all``, in input order, with helpers for reporting."""
161
+
162
+ @property
163
+ def succeeded(self) -> List[Result]:
164
+ return [result for result in self if result.ok]
165
+
166
+ @property
167
+ def failed(self) -> List[Result]:
168
+ return [result for result in self if not result.ok]
169
+
170
+ def errors(self) -> List[Dict[str, Any]]:
171
+ """Every failed request as a dict: index, method, url, status, error, attempts and history."""
172
+ return [result.to_dict() for result in self.failed]
173
+
174
+ def summary(self) -> Dict[str, Any]:
175
+ """Counts of the batch: total, ok, failed, and failures grouped by status or error type."""
176
+ reasons: Dict[str, int] = {}
177
+ for result in self.failed:
178
+ reason = type(result.error).__name__ if result.error is not None else f"HTTP {result.status}"
179
+ reasons[reason] = reasons.get(reason, 0) + 1
180
+ ok = sum(1 for result in self if result.ok)
181
+ return {"total": len(self), "ok": ok, "failed": len(self) - ok, "failures": reasons}
182
+
183
+ def to_dicts(self, body: str = "none", include_headers: bool = False) -> List[Dict[str, Any]]:
184
+ return [result.to_dict(body=body, include_headers=include_headers) for result in self]
185
+
186
+
187
+ Callback = Callable[[Result], Union[None, Awaitable[None]]]
188
+
189
+
190
+ def _charset(headers: Mapping[str, str]) -> Optional[str]:
191
+ content_type = headers.get("Content-Type", "")
192
+ for part in content_type.split(";")[1:]:
193
+ key, _, value = part.strip().partition("=")
194
+ if key.lower() == "charset" and value:
195
+ return value.strip('"')
196
+ return None
197
+
198
+
199
+ @dataclass(frozen=True)
200
+ class _Options:
201
+ method: str
202
+ headers: Optional[Mapping[str, str]]
203
+ params: Optional[Mapping[str, Any]]
204
+ json: Any
205
+ data: Any
206
+ timeout: aiohttp.ClientTimeout
207
+ retries: int
208
+ backoff: float
209
+ retry_statuses: frozenset
210
+ ssl: Union[bool, ssllib.SSLContext]
211
+ rate_limiter: Optional[HostRateLimiter]
212
+
213
+
214
+ def _resolve(request: Union[str, Request], options: _Options) -> Request:
215
+ if isinstance(request, str):
216
+ request = Request(request)
217
+ headers = dict(options.headers or {})
218
+ headers.update(request.headers or {})
219
+ return Request(
220
+ url=request.url,
221
+ method=(request.method or options.method).upper(),
222
+ headers=headers or None,
223
+ params=request.params if request.params is not None else options.params,
224
+ json=request.json if request.json is not None else options.json,
225
+ data=request.data if request.data is not None else options.data,
226
+ )
227
+
228
+
229
+ def _is_retryable_error(error: BaseException) -> bool:
230
+ if isinstance(error, (aiohttp.InvalidURL, ValueError)):
231
+ return False
232
+ return isinstance(error, (aiohttp.ClientError, asyncio.TimeoutError))
233
+
234
+
235
+ def _should_retry(result: Result, retry_statuses: frozenset) -> bool:
236
+ if result.error is not None:
237
+ return _is_retryable_error(result.error)
238
+ return result.status in retry_statuses
239
+
240
+
241
+ def _retry_after(headers: Mapping[str, str]) -> Optional[float]:
242
+ value = headers.get("Retry-After")
243
+ if value is None:
244
+ return None
245
+ try:
246
+ return min(max(float(value), 0.0), _MAX_RETRY_AFTER)
247
+ except ValueError:
248
+ return None
249
+
250
+
251
+ async def _send(session: aiohttp.ClientSession, request: Request, index: int, options: _Options) -> Result:
252
+ result = Result(request=request, index=index)
253
+ started = time.monotonic()
254
+ for attempt in range(1, options.retries + 2):
255
+ result.attempts = attempt
256
+ delay = options.backoff * (2 ** (attempt - 1))
257
+ if options.rate_limiter is not None:
258
+ await options.rate_limiter.wait(request.url)
259
+ attempt_started = time.monotonic()
260
+ try:
261
+ async with session.request(
262
+ request.method or "GET",
263
+ request.url,
264
+ headers=request.headers,
265
+ params=request.params,
266
+ json=request.json,
267
+ data=request.data,
268
+ timeout=options.timeout,
269
+ ssl=options.ssl,
270
+ ) as response:
271
+ body = await response.read()
272
+ result.status = response.status
273
+ result.headers = response.headers
274
+ result.body = body
275
+ result.final_url = str(response.url)
276
+ result.error = None
277
+ result.history.append(Attempt(attempt, response.status, None, time.monotonic() - attempt_started))
278
+ if response.status not in options.retry_statuses or attempt > options.retries:
279
+ break
280
+ delay = _retry_after(response.headers) or delay
281
+ except asyncio.CancelledError:
282
+ raise
283
+ except Exception as error: # one failing request must not stop the others
284
+ result.error = error
285
+ result.status = None
286
+ result.history.append(Attempt(attempt, None, error, time.monotonic() - attempt_started))
287
+ if not _is_retryable_error(error) or attempt > options.retries:
288
+ break
289
+ await asyncio.sleep(delay)
290
+ result.elapsed = time.monotonic() - started
291
+ return result
292
+
293
+
294
+ async def stream(
295
+ urls: Iterable[Union[str, Request]],
296
+ method: str = "GET",
297
+ *,
298
+ headers: Optional[Mapping[str, str]] = None,
299
+ params: Optional[Mapping[str, Any]] = None,
300
+ json: Any = None,
301
+ data: Any = None,
302
+ concurrency: int = 100,
303
+ timeout: Optional[float] = 30.0,
304
+ retries: int = 0,
305
+ backoff: float = 0.5,
306
+ retry_statuses: Sequence[int] = DEFAULT_RETRY_STATUSES,
307
+ verify_ssl: bool = True,
308
+ ssl: Optional[ssllib.SSLContext] = None,
309
+ rate_limit: Optional[RateLimit] = None,
310
+ concurrency_per_host: int = 0,
311
+ session: Optional[aiohttp.ClientSession] = None,
312
+ ) -> AsyncGenerator[Result, None]:
313
+ """Send the requests concurrently and yield each ``Result`` as soon as it completes.
314
+
315
+ ``urls`` may be any iterable, including a generator; it is consumed lazily, so
316
+ millions of requests can be streamed without building them all in memory.
317
+ See ``fetch_all`` for the parameters. Retry rounds are not available here;
318
+ use ``fetch_all`` or ``fetch_to_file`` for those.
319
+ """
320
+ if concurrency < 1:
321
+ raise ValueError("concurrency must be at least 1")
322
+ if retries < 0:
323
+ raise ValueError("retries must not be negative")
324
+ if concurrency_per_host < 0:
325
+ raise ValueError("concurrency_per_host must not be negative")
326
+ options = _Options(
327
+ method=method,
328
+ headers=headers,
329
+ params=params,
330
+ json=json,
331
+ data=data,
332
+ timeout=aiohttp.ClientTimeout(total=timeout),
333
+ retries=retries,
334
+ backoff=backoff,
335
+ retry_statuses=frozenset(retry_statuses),
336
+ ssl=ssl if ssl is not None else verify_ssl,
337
+ rate_limiter=HostRateLimiter(rate_limit) if rate_limit is not None else None,
338
+ )
339
+
340
+ owns_session = session is None
341
+ if session is None:
342
+ session = aiohttp.ClientSession(
343
+ connector=aiohttp.TCPConnector(limit=concurrency, limit_per_host=concurrency_per_host)
344
+ )
345
+
346
+ pending = enumerate(urls)
347
+ queue: asyncio.Queue = asyncio.Queue(maxsize=concurrency * 2)
348
+ finished = object()
349
+
350
+ async def worker() -> None:
351
+ for index, request in pending:
352
+ await queue.put(await _send(session, _resolve(request, options), index, options))
353
+
354
+ async def supervise() -> None:
355
+ try:
356
+ await asyncio.gather(*(worker() for _ in range(concurrency)))
357
+ except BaseException as error: # surfaces a failure while reading `urls`
358
+ await queue.put(error)
359
+ else:
360
+ await queue.put(finished)
361
+
362
+ supervisor = asyncio.ensure_future(supervise())
363
+ try:
364
+ while True:
365
+ item = await queue.get()
366
+ if item is finished:
367
+ break
368
+ if isinstance(item, BaseException):
369
+ raise item
370
+ yield item
371
+ finally:
372
+ supervisor.cancel()
373
+ try:
374
+ await supervisor
375
+ except (asyncio.CancelledError, Exception):
376
+ pass
377
+ if owns_session:
378
+ await session.close()
379
+
380
+
381
+ async def _execute(
382
+ urls: Iterable[Union[str, Request]],
383
+ retry_rounds: int,
384
+ retry_round_delay: float,
385
+ stream_options: Dict[str, Any],
386
+ ) -> AsyncIterator[Result]:
387
+ """``stream`` plus retry rounds: yields each request's final result exactly once.
388
+
389
+ Results that failed for a retryable reason are held back and sent again in up to
390
+ ``retry_rounds`` further rounds, after everything else has finished.
391
+ """
392
+ if retry_rounds < 0:
393
+ raise ValueError("retry_rounds must not be negative")
394
+ retry_statuses = frozenset(stream_options.get("retry_statuses", DEFAULT_RETRY_STATUSES))
395
+ deferred: List[Result] = []
396
+ async for result in stream(urls, **stream_options):
397
+ if retry_rounds and _should_retry(result, retry_statuses):
398
+ deferred.append(result)
399
+ else:
400
+ yield result
401
+
402
+ for round_number in range(1, retry_rounds + 1):
403
+ if not deferred:
404
+ break
405
+ await asyncio.sleep(retry_round_delay)
406
+ previous = deferred
407
+ deferred = []
408
+ async for retried in stream([earlier.request for earlier in previous], **stream_options):
409
+ earlier = previous[retried.index]
410
+ retried.index = earlier.index
411
+ retried.history = earlier.history + [
412
+ Attempt(earlier.attempts + attempt.number, attempt.status, attempt.error, attempt.elapsed)
413
+ for attempt in retried.history
414
+ ]
415
+ retried.attempts += earlier.attempts
416
+ retried.elapsed += earlier.elapsed
417
+ if round_number < retry_rounds and _should_retry(retried, retry_statuses):
418
+ deferred.append(retried)
419
+ else:
420
+ yield retried
421
+
422
+
423
+ @overload
424
+ async def fetch_all(
425
+ urls: Iterable[Union[str, Request]],
426
+ method: str = ...,
427
+ *,
428
+ headers: Optional[Mapping[str, str]] = ...,
429
+ params: Optional[Mapping[str, Any]] = ...,
430
+ json: Any = ...,
431
+ data: Any = ...,
432
+ concurrency: int = ...,
433
+ timeout: Optional[float] = ...,
434
+ retries: int = ...,
435
+ backoff: float = ...,
436
+ retry_statuses: Sequence[int] = ...,
437
+ retry_rounds: int = ...,
438
+ retry_round_delay: float = ...,
439
+ verify_ssl: bool = ...,
440
+ ssl: Optional[ssllib.SSLContext] = ...,
441
+ rate_limit: Optional[RateLimit] = ...,
442
+ concurrency_per_host: int = ...,
443
+ callback: Optional[Callback] = ...,
444
+ progress: ProgressTarget = ...,
445
+ session: Optional[aiohttp.ClientSession] = ...,
446
+ ) -> Results: ...
447
+
448
+
449
+ @overload
450
+ async def fetch_all(
451
+ urls: Iterable[str],
452
+ method: Callable[..., Any],
453
+ *,
454
+ headers: Optional[Mapping[str, str]] = ...,
455
+ verify_ssl: bool = ...,
456
+ request_type: str = ...,
457
+ semaphore_limit: int = ...,
458
+ ) -> None: ...
459
+
460
+
461
+ async def fetch_all(
462
+ urls: Iterable[Union[str, Request]],
463
+ method: Union[str, Callable[..., Any]] = "GET",
464
+ *,
465
+ headers: Optional[Mapping[str, str]] = None,
466
+ params: Optional[Mapping[str, Any]] = None,
467
+ json: Any = None,
468
+ data: Any = None,
469
+ concurrency: int = 100,
470
+ timeout: Optional[float] = 30.0,
471
+ retries: int = 0,
472
+ backoff: float = 0.5,
473
+ retry_statuses: Sequence[int] = DEFAULT_RETRY_STATUSES,
474
+ retry_rounds: int = 0,
475
+ retry_round_delay: float = 5.0,
476
+ verify_ssl: bool = True,
477
+ ssl: Optional[ssllib.SSLContext] = None,
478
+ rate_limit: Optional[RateLimit] = None,
479
+ concurrency_per_host: int = 0,
480
+ callback: Optional[Callback] = None,
481
+ progress: ProgressTarget = False,
482
+ session: Optional[aiohttp.ClientSession] = None,
483
+ **legacy: Any,
484
+ ) -> Optional[Results]:
485
+ """Send all requests concurrently and return their results in the order given.
486
+
487
+ Args:
488
+ urls: URLs, or ``Request`` objects for per-request method, headers, body or params.
489
+ method: HTTP method for plain URLs (default ``"GET"``).
490
+ headers: Headers sent with every request; a ``Request``'s own headers are merged on top.
491
+ params: Query parameters for every request, unless a ``Request`` sets its own.
492
+ json: JSON body for every request, unless a ``Request`` sets its own.
493
+ data: Form or raw body for every request, unless a ``Request`` sets its own.
494
+ concurrency: Maximum number of requests in flight at once.
495
+ timeout: Seconds allowed per attempt, including reading the body. ``None`` disables it.
496
+ retries: How many times to retry a request right away after a connection error,
497
+ a timeout or a status in ``retry_statuses``. Invalid URLs are not retried.
498
+ backoff: Delay before the first retry in seconds, doubled for each further retry.
499
+ A ``Retry-After`` header (in seconds, up to 60) takes precedence.
500
+ retry_rounds: After all requests have finished, send the ones that still failed
501
+ for a retryable reason again, up to this many more rounds.
502
+ retry_round_delay: Seconds to wait before each retry round.
503
+ verify_ssl: Verify TLS certificates (default). Only disable this for hosts you control.
504
+ ssl: A custom ``ssl.SSLContext``, for example to trust a private certificate authority.
505
+ rate_limit: Maximum request rate per host (``host:port``), retries included:
506
+ requests per second as a number, or a string such as ``"100/min"``,
507
+ ``"30/5min"`` or ``"1000/h"``, or ``(count, seconds)``.
508
+ concurrency_per_host: Maximum requests in flight to each host; 0 means no per-host limit.
509
+ callback: Called with each final ``Result`` as soon as it is known. May be a coroutine function.
510
+ progress: ``True`` to print progress to stderr, or a text stream to print it to.
511
+ session: An existing ``aiohttp.ClientSession`` to use instead of creating one.
512
+
513
+ Returns a ``Results`` list; ``results.errors()`` and ``results.summary()`` report the failures.
514
+ A failing request never stops the others: its ``Result.error`` holds the exception and
515
+ ``Result.history`` lists every attempt.
516
+ """
517
+ if callable(method) or legacy:
518
+ from ._legacy import legacy_fetch_all
519
+
520
+ await legacy_fetch_all(urls, method, headers=headers, verify_ssl=verify_ssl, **legacy) # type: ignore[arg-type]
521
+ return None
522
+
523
+ results = Results()
524
+ tracker = Progress.create(progress, total=_length(urls))
525
+ stream_options: Dict[str, Any] = dict(
526
+ method=method,
527
+ headers=headers,
528
+ params=params,
529
+ json=json,
530
+ data=data,
531
+ concurrency=concurrency,
532
+ timeout=timeout,
533
+ retries=retries,
534
+ backoff=backoff,
535
+ retry_statuses=retry_statuses,
536
+ verify_ssl=verify_ssl,
537
+ ssl=ssl,
538
+ rate_limit=rate_limit,
539
+ concurrency_per_host=concurrency_per_host,
540
+ session=session,
541
+ )
542
+ async for result in _execute(urls, retry_rounds, retry_round_delay, stream_options):
543
+ results.append(result)
544
+ if tracker is not None:
545
+ tracker.update(result)
546
+ if callback is not None:
547
+ outcome = callback(result)
548
+ if asyncio.iscoroutine(outcome):
549
+ await outcome
550
+ if tracker is not None:
551
+ tracker.close()
552
+ results.sort(key=lambda r: r.index)
553
+ return results
554
+
555
+
556
+ def _length(urls: Iterable[Any]) -> Optional[int]:
557
+ try:
558
+ return len(urls) # type: ignore[arg-type]
559
+ except TypeError:
560
+ return None