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 +53 -0
- reqstorm/_client.py +560 -0
- reqstorm/_files.py +548 -0
- reqstorm/_legacy.py +95 -0
- reqstorm/_limits.py +73 -0
- reqstorm/_plan.py +60 -0
- reqstorm/_progress.py +82 -0
- reqstorm/_sync.py +135 -0
- reqstorm/py.typed +0 -0
- reqstorm-2.0.1.dist-info/METADATA +271 -0
- reqstorm-2.0.1.dist-info/RECORD +14 -0
- reqstorm-2.0.1.dist-info/WHEEL +5 -0
- reqstorm-2.0.1.dist-info/licenses/LICENSE +21 -0
- reqstorm-2.0.1.dist-info/top_level.txt +1 -0
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
|