wpr-http 0.2.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.
- wpr_http/__init__.py +60 -0
- wpr_http/__main__.py +28 -0
- wpr_http/_urls.py +31 -0
- wpr_http/client.py +588 -0
- wpr_http/exceptions.py +207 -0
- wpr_http/models.py +331 -0
- wpr_http/transport.py +121 -0
- wpr_http-0.2.0.dist-info/METADATA +52 -0
- wpr_http-0.2.0.dist-info/RECORD +12 -0
- wpr_http-0.2.0.dist-info/WHEEL +5 -0
- wpr_http-0.2.0.dist-info/entry_points.txt +2 -0
- wpr_http-0.2.0.dist-info/top_level.txt +1 -0
wpr_http/client.py
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
"""Async HTTP client facade with prepared Request objects and bounded Workers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json as jsonlib
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from operator import index as integer_index
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit
|
|
12
|
+
|
|
13
|
+
from wpr_runtime_core.registry import resolve_runtime
|
|
14
|
+
from wpr_runtime_core.session import AsyncWorkerSession as AsyncGeckoWorkerSession
|
|
15
|
+
from wpr_runtime_core.session import RuntimeConfig
|
|
16
|
+
|
|
17
|
+
from .exceptions import (
|
|
18
|
+
WprTimeoutError,
|
|
19
|
+
dispatch_timeout_error,
|
|
20
|
+
pool_timeout_error,
|
|
21
|
+
)
|
|
22
|
+
from .models import Headers, HttpResponse, Request, Timeout
|
|
23
|
+
from .transport import GeckoNetworkTransport
|
|
24
|
+
|
|
25
|
+
_UNSET = object()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(eq=False)
|
|
29
|
+
class _WorkerSlot:
|
|
30
|
+
session: AsyncGeckoWorkerSession
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _timeout(value: float | Timeout | None) -> Timeout:
|
|
34
|
+
if isinstance(value, Timeout):
|
|
35
|
+
return value
|
|
36
|
+
return Timeout(total=None if value is None else float(value))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _append_params(
|
|
40
|
+
url: str,
|
|
41
|
+
params: Mapping[str, Any] | Sequence[tuple[str, Any]] | None,
|
|
42
|
+
) -> str:
|
|
43
|
+
if not params:
|
|
44
|
+
return url
|
|
45
|
+
query = urlencode(params, doseq=True)
|
|
46
|
+
parsed = urlsplit(url)
|
|
47
|
+
merged = "&".join(part for part in (parsed.query, query) if part)
|
|
48
|
+
return urlunsplit(
|
|
49
|
+
(parsed.scheme, parsed.netloc, parsed.path, merged, parsed.fragment)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _merge_headers(*groups: Mapping[str, str] | None) -> dict[str, str]:
|
|
54
|
+
values = Headers()
|
|
55
|
+
for group in groups:
|
|
56
|
+
if not group:
|
|
57
|
+
continue
|
|
58
|
+
for name, value in group.items():
|
|
59
|
+
values[str(name)] = str(value)
|
|
60
|
+
return dict(values.items())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AsyncWprHttpClient:
|
|
64
|
+
"""Async HTTP client backed by a bounded pool of Gecko Network Workers.
|
|
65
|
+
|
|
66
|
+
Defaults use one Gecko cookie/profile session and up to twenty Channels.
|
|
67
|
+
Increasing the Worker bound adds independent concurrent Worker sessions;
|
|
68
|
+
each slot has its own Gecko CookieService/profile state.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
*,
|
|
74
|
+
config: RuntimeConfig | None = None,
|
|
75
|
+
firefox_version: str | int | None = None,
|
|
76
|
+
runtime_home: str | None = None,
|
|
77
|
+
proxy: str | None = None,
|
|
78
|
+
headers: Mapping[str, str] | None = None,
|
|
79
|
+
base_url: str | None = None,
|
|
80
|
+
timeout: float | Timeout | None = 30.0,
|
|
81
|
+
follow_redirects: bool = True,
|
|
82
|
+
max_workers: int = 1,
|
|
83
|
+
release_mode: str = "piggyback",
|
|
84
|
+
ipc_backend: str = "iocp",
|
|
85
|
+
max_channels_per_worker: int = 20,
|
|
86
|
+
) -> None:
|
|
87
|
+
try:
|
|
88
|
+
worker_limit = integer_index(max_workers)
|
|
89
|
+
except TypeError:
|
|
90
|
+
raise ValueError("max_workers must be a positive integer") from None
|
|
91
|
+
if isinstance(max_workers, bool) or worker_limit < 1:
|
|
92
|
+
raise ValueError("max_workers must be a positive integer")
|
|
93
|
+
self.max_workers = worker_limit
|
|
94
|
+
if release_mode not in {"immediate", "piggyback"}:
|
|
95
|
+
raise ValueError("release_mode must be 'immediate' or 'piggyback'")
|
|
96
|
+
self.release_mode = release_mode
|
|
97
|
+
if ipc_backend not in {"threaded", "iocp"}:
|
|
98
|
+
raise ValueError("ipc_backend must be 'threaded' or 'iocp'")
|
|
99
|
+
self.ipc_backend = ipc_backend
|
|
100
|
+
if (
|
|
101
|
+
type(max_channels_per_worker) is not int
|
|
102
|
+
or not 1 <= max_channels_per_worker <= 64
|
|
103
|
+
):
|
|
104
|
+
raise ValueError(
|
|
105
|
+
"max_channels_per_worker must be an integer between 1 and 64"
|
|
106
|
+
)
|
|
107
|
+
if max_channels_per_worker > 1 and ipc_backend != "iocp":
|
|
108
|
+
raise ValueError("multiple channels require ipc_backend='iocp'")
|
|
109
|
+
self.max_channels_per_worker = max_channels_per_worker
|
|
110
|
+
self._request_capacity = self.max_workers * max_channels_per_worker
|
|
111
|
+
self._startup_lock = asyncio.Lock()
|
|
112
|
+
if config is not None and (
|
|
113
|
+
firefox_version is not None or runtime_home is not None
|
|
114
|
+
):
|
|
115
|
+
raise ValueError(
|
|
116
|
+
"config is a developer override; use it separately from firefox_version/runtime_home"
|
|
117
|
+
)
|
|
118
|
+
self.runtime_manifest = None
|
|
119
|
+
if firefox_version is not None or runtime_home is not None:
|
|
120
|
+
self.runtime_manifest = resolve_runtime(
|
|
121
|
+
firefox_version or "155", runtime_home=runtime_home
|
|
122
|
+
)
|
|
123
|
+
config = self.runtime_manifest.config()
|
|
124
|
+
self.config = config
|
|
125
|
+
self.proxy = proxy if proxy is not None else (config.proxy if config else None)
|
|
126
|
+
self.headers = dict(headers or {})
|
|
127
|
+
self.base_url = base_url
|
|
128
|
+
self.timeout = _timeout(timeout)
|
|
129
|
+
self.follow_redirects = bool(follow_redirects)
|
|
130
|
+
self._slots: list[_WorkerSlot] = []
|
|
131
|
+
self._available: asyncio.Queue[_WorkerSlot] | None = None
|
|
132
|
+
self._capacity: asyncio.Semaphore | None = None
|
|
133
|
+
self._responses: set[HttpResponse] = set()
|
|
134
|
+
self._transport = GeckoNetworkTransport()
|
|
135
|
+
self._entered = False
|
|
136
|
+
self._requests_started = False
|
|
137
|
+
self._warming_up = False
|
|
138
|
+
|
|
139
|
+
async def __aenter__(self) -> AsyncWprHttpClient: # noqa: PYI034
|
|
140
|
+
if self._entered:
|
|
141
|
+
raise RuntimeError("AsyncWprHttpClient is already open")
|
|
142
|
+
self._available = asyncio.Queue()
|
|
143
|
+
self._capacity = asyncio.Semaphore(self._request_capacity)
|
|
144
|
+
self._requests_started = False
|
|
145
|
+
self._warming_up = False
|
|
146
|
+
try:
|
|
147
|
+
first_slot = await self._start_session()
|
|
148
|
+
for _ in range(self.max_channels_per_worker):
|
|
149
|
+
self._available.put_nowait(first_slot)
|
|
150
|
+
except Exception:
|
|
151
|
+
self._available = None
|
|
152
|
+
self._capacity = None
|
|
153
|
+
self._entered = False
|
|
154
|
+
raise
|
|
155
|
+
self._entered = True
|
|
156
|
+
return self
|
|
157
|
+
|
|
158
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
159
|
+
await self.aclose()
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def is_closed(self) -> bool:
|
|
163
|
+
return not self._entered
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def transport_stats(self) -> dict[str, Any]:
|
|
167
|
+
"""Snapshot of the currently resident Worker pool, not retired Workers."""
|
|
168
|
+
calls: dict[str, int] = {}
|
|
169
|
+
pending_count = pending_bytes = 0
|
|
170
|
+
for slot in self._slots:
|
|
171
|
+
stats = slot.session.transport_stats
|
|
172
|
+
for method, count in stats["rpc_dispatches"].items():
|
|
173
|
+
calls[method] = calls.get(method, 0) + count
|
|
174
|
+
pending_count += stats["pending_blob_count"]
|
|
175
|
+
pending_bytes += stats["pending_blob_bytes"]
|
|
176
|
+
return {
|
|
177
|
+
"worker_count": len(self._slots),
|
|
178
|
+
"max_channels_per_worker": self.max_channels_per_worker,
|
|
179
|
+
"rpc_dispatches": calls,
|
|
180
|
+
"pending_blob_count": pending_count,
|
|
181
|
+
"pending_blob_bytes": pending_bytes,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async def flush_releases(self) -> None:
|
|
185
|
+
"""Confirm all queued Worker-side releases; unread responses stay live."""
|
|
186
|
+
await asyncio.gather(
|
|
187
|
+
*(slot.session.flush_body_releases() for slot in self._slots)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
async def aclose(self) -> None:
|
|
191
|
+
"""Drain active sends, release response leases, and close all Workers."""
|
|
192
|
+
if not self._entered and not self._slots:
|
|
193
|
+
return
|
|
194
|
+
self._entered = False
|
|
195
|
+
capacity = self._capacity
|
|
196
|
+
if capacity is not None:
|
|
197
|
+
for _ in range(self._request_capacity):
|
|
198
|
+
await capacity.acquire()
|
|
199
|
+
errors: list[Exception] = []
|
|
200
|
+
for response in tuple(self._responses):
|
|
201
|
+
try:
|
|
202
|
+
await response.aclose()
|
|
203
|
+
except Exception as exc: # noqa: BLE001 -- continue closing all resources
|
|
204
|
+
errors.append(exc)
|
|
205
|
+
for slot in tuple(self._slots):
|
|
206
|
+
try:
|
|
207
|
+
await slot.session.__aexit__(None, None, None)
|
|
208
|
+
except Exception as exc: # noqa: BLE001 -- continue closing all Workers
|
|
209
|
+
errors.append(exc)
|
|
210
|
+
self._slots.clear()
|
|
211
|
+
self._responses.clear()
|
|
212
|
+
self._available = None
|
|
213
|
+
self._capacity = None
|
|
214
|
+
if errors:
|
|
215
|
+
raise RuntimeError(
|
|
216
|
+
f"client cleanup encountered {len(errors)} error(s): {errors[0]}"
|
|
217
|
+
) from errors[0]
|
|
218
|
+
|
|
219
|
+
async def _start_session(self) -> _WorkerSlot:
|
|
220
|
+
session = AsyncGeckoWorkerSession(
|
|
221
|
+
self.config,
|
|
222
|
+
proxy=self.proxy,
|
|
223
|
+
network_only=True,
|
|
224
|
+
defer_body_releases=self.release_mode == "piggyback",
|
|
225
|
+
ipc_backend=self.ipc_backend,
|
|
226
|
+
network_channels=self.max_channels_per_worker,
|
|
227
|
+
)
|
|
228
|
+
await session.__aenter__()
|
|
229
|
+
slot = _WorkerSlot(session)
|
|
230
|
+
self._slots.append(slot)
|
|
231
|
+
return slot
|
|
232
|
+
|
|
233
|
+
async def warmup(self, url: str) -> list[int]:
|
|
234
|
+
"""Start every Worker and make one real GET through each channel slot.
|
|
235
|
+
|
|
236
|
+
Call this immediately after entering the client and before normal
|
|
237
|
+
requests. It performs ``max_workers * max_channels_per_worker`` GETs, reads and releases their
|
|
238
|
+
bodies, and keeps the Worker sessions and network connections alive.
|
|
239
|
+
Choose a safe, idempotent URL; response cookies are retained by each
|
|
240
|
+
Worker's separate profile.
|
|
241
|
+
"""
|
|
242
|
+
if not self._entered:
|
|
243
|
+
raise RuntimeError("AsyncWprHttpClient is not open")
|
|
244
|
+
if self._requests_started:
|
|
245
|
+
raise RuntimeError("warmup must run before the first client request")
|
|
246
|
+
if self._warming_up:
|
|
247
|
+
raise RuntimeError("client warmup is already in progress")
|
|
248
|
+
self._warming_up = True
|
|
249
|
+
try:
|
|
250
|
+
assert self._available is not None
|
|
251
|
+
idle_slots = []
|
|
252
|
+
while True:
|
|
253
|
+
try:
|
|
254
|
+
idle_slots.append(self._available.get_nowait())
|
|
255
|
+
except asyncio.QueueEmpty:
|
|
256
|
+
break
|
|
257
|
+
missing = max(0, self.max_workers - len(self._slots))
|
|
258
|
+
started = await asyncio.gather(
|
|
259
|
+
*(self._start_session() for _ in range(missing)),
|
|
260
|
+
return_exceptions=True,
|
|
261
|
+
)
|
|
262
|
+
new_slots = [item for item in started if isinstance(item, _WorkerSlot)]
|
|
263
|
+
warm_slots = list(dict.fromkeys(idle_slots)) + new_slots
|
|
264
|
+
startup_errors = [
|
|
265
|
+
item for item in started if isinstance(item, BaseException)
|
|
266
|
+
]
|
|
267
|
+
if startup_errors:
|
|
268
|
+
for slot in warm_slots:
|
|
269
|
+
for _ in range(self.max_channels_per_worker):
|
|
270
|
+
self._available.put_nowait(slot)
|
|
271
|
+
raise RuntimeError(
|
|
272
|
+
f"could not start {len(startup_errors)} Worker(s) for warmup"
|
|
273
|
+
) from startup_errors[0]
|
|
274
|
+
|
|
275
|
+
request = self.build_request("GET", url)
|
|
276
|
+
|
|
277
|
+
async def warm_slot(slot: _WorkerSlot) -> int:
|
|
278
|
+
response = await self._transport.send(
|
|
279
|
+
slot.session,
|
|
280
|
+
request=request,
|
|
281
|
+
proxy=self.proxy,
|
|
282
|
+
timeout=request.timeout,
|
|
283
|
+
)
|
|
284
|
+
try:
|
|
285
|
+
await response.aread()
|
|
286
|
+
return response.status_code
|
|
287
|
+
finally:
|
|
288
|
+
await response.aclose()
|
|
289
|
+
|
|
290
|
+
warm_tokens = [
|
|
291
|
+
slot for slot in warm_slots for _ in range(self.max_channels_per_worker)
|
|
292
|
+
]
|
|
293
|
+
results = await asyncio.gather(
|
|
294
|
+
*(warm_slot(slot) for slot in warm_tokens), return_exceptions=True
|
|
295
|
+
)
|
|
296
|
+
errors = [item for item in results if isinstance(item, BaseException)]
|
|
297
|
+
stopped_slots = {
|
|
298
|
+
slot
|
|
299
|
+
for slot, result in zip(warm_tokens, results, strict=True)
|
|
300
|
+
if not slot.session.is_open
|
|
301
|
+
or getattr(result, "worker_stopped", False)
|
|
302
|
+
or (
|
|
303
|
+
isinstance(result, asyncio.CancelledError)
|
|
304
|
+
and not slot.session.supports_request_cancel
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
reusable_slots = []
|
|
308
|
+
for slot in warm_slots:
|
|
309
|
+
if slot in stopped_slots:
|
|
310
|
+
await slot.session.abort()
|
|
311
|
+
try:
|
|
312
|
+
self._slots.remove(slot)
|
|
313
|
+
except ValueError:
|
|
314
|
+
pass
|
|
315
|
+
else:
|
|
316
|
+
reusable_slots.append(slot)
|
|
317
|
+
for slot in reusable_slots:
|
|
318
|
+
for _ in range(self.max_channels_per_worker):
|
|
319
|
+
self._available.put_nowait(slot)
|
|
320
|
+
if errors:
|
|
321
|
+
raise RuntimeError(
|
|
322
|
+
f"warmup GET failed in {len(errors)} Worker(s)"
|
|
323
|
+
) from errors[0]
|
|
324
|
+
return [int(item) for item in results]
|
|
325
|
+
finally:
|
|
326
|
+
self._warming_up = False
|
|
327
|
+
|
|
328
|
+
def _resolve_url(self, url: str) -> str:
|
|
329
|
+
return urljoin(self.base_url.rstrip("/") + "/", url) if self.base_url else url
|
|
330
|
+
|
|
331
|
+
def build_request(
|
|
332
|
+
self,
|
|
333
|
+
method: str,
|
|
334
|
+
url: str,
|
|
335
|
+
*,
|
|
336
|
+
headers: Mapping[str, str] | None = None,
|
|
337
|
+
content: str | None = None,
|
|
338
|
+
data: Any = None,
|
|
339
|
+
json: Any = None,
|
|
340
|
+
params: Mapping[str, Any] | Sequence[tuple[str, Any]] | None = None,
|
|
341
|
+
timeout: float | Timeout | None | object = _UNSET,
|
|
342
|
+
follow_redirects: bool | None = None,
|
|
343
|
+
) -> Request:
|
|
344
|
+
"""Merge client defaults and return a prepared, inspectable request."""
|
|
345
|
+
if sum(value is not None for value in (content, data, json)) > 1:
|
|
346
|
+
raise ValueError("pass only one of content, data, or json")
|
|
347
|
+
if isinstance(content, bytes):
|
|
348
|
+
raise TypeError(
|
|
349
|
+
"binary request bodies are not supported by the current Worker protocol; pass UTF-8 text"
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
request_url = _append_params(self._resolve_url(url), params)
|
|
353
|
+
request_headers = _merge_headers(self.headers, headers)
|
|
354
|
+
request_body = content
|
|
355
|
+
if json is not None:
|
|
356
|
+
request_body = jsonlib.dumps(json, separators=(",", ":"))
|
|
357
|
+
if not any(name.lower() == "content-type" for name in request_headers):
|
|
358
|
+
request_headers["content-type"] = "application/json"
|
|
359
|
+
elif data is not None:
|
|
360
|
+
if isinstance(data, Mapping) or (
|
|
361
|
+
isinstance(data, Sequence)
|
|
362
|
+
and not isinstance(data, (str, bytes, bytearray))
|
|
363
|
+
):
|
|
364
|
+
request_body = urlencode(data, doseq=True)
|
|
365
|
+
if not any(name.lower() == "content-type" for name in request_headers):
|
|
366
|
+
request_headers["content-type"] = (
|
|
367
|
+
"application/x-www-form-urlencoded"
|
|
368
|
+
)
|
|
369
|
+
elif isinstance(data, str):
|
|
370
|
+
request_body = data
|
|
371
|
+
elif isinstance(data, bytes):
|
|
372
|
+
raise TypeError(
|
|
373
|
+
"binary request bodies are not supported by the current Worker protocol"
|
|
374
|
+
)
|
|
375
|
+
else:
|
|
376
|
+
raise TypeError("data must be a mapping, sequence of pairs, or str")
|
|
377
|
+
|
|
378
|
+
selected_timeout = self.timeout if timeout is _UNSET else _timeout(timeout)
|
|
379
|
+
return Request(
|
|
380
|
+
method=method,
|
|
381
|
+
url=request_url,
|
|
382
|
+
headers=request_headers,
|
|
383
|
+
content=request_body,
|
|
384
|
+
timeout=selected_timeout,
|
|
385
|
+
follow_redirects=(
|
|
386
|
+
self.follow_redirects
|
|
387
|
+
if follow_redirects is None
|
|
388
|
+
else bool(follow_redirects)
|
|
389
|
+
),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
async def _acquire_slot(
|
|
393
|
+
self,
|
|
394
|
+
request: Request,
|
|
395
|
+
queued_at: float,
|
|
396
|
+
total_deadline: float | None,
|
|
397
|
+
) -> _WorkerSlot:
|
|
398
|
+
assert self._capacity is not None and self._available is not None
|
|
399
|
+
loop = asyncio.get_running_loop()
|
|
400
|
+
pool_deadline = (
|
|
401
|
+
queued_at + request.timeout.pool
|
|
402
|
+
if request.timeout.pool is not None
|
|
403
|
+
else None
|
|
404
|
+
)
|
|
405
|
+
wait_deadlines = [d for d in (pool_deadline, total_deadline) if d is not None]
|
|
406
|
+
wait_deadline = min(wait_deadlines) if wait_deadlines else None
|
|
407
|
+
wait_budget = max(0.0, wait_deadline - loop.time()) if wait_deadline else None
|
|
408
|
+
pool_limit_is_first = pool_deadline is not None and (
|
|
409
|
+
total_deadline is None or pool_deadline <= total_deadline
|
|
410
|
+
)
|
|
411
|
+
try:
|
|
412
|
+
if self._capacity.locked():
|
|
413
|
+
if wait_budget is None:
|
|
414
|
+
await self._capacity.acquire()
|
|
415
|
+
else:
|
|
416
|
+
await asyncio.wait_for(self._capacity.acquire(), wait_budget)
|
|
417
|
+
else:
|
|
418
|
+
await self._capacity.acquire()
|
|
419
|
+
except asyncio.TimeoutError as exc:
|
|
420
|
+
elapsed = loop.time() - queued_at
|
|
421
|
+
if pool_limit_is_first:
|
|
422
|
+
raise pool_timeout_error(
|
|
423
|
+
request.method,
|
|
424
|
+
request.url,
|
|
425
|
+
request.timeout.pool or elapsed,
|
|
426
|
+
request=request,
|
|
427
|
+
) from exc
|
|
428
|
+
raise dispatch_timeout_error(
|
|
429
|
+
request.method,
|
|
430
|
+
request.url,
|
|
431
|
+
request.timeout.total or elapsed,
|
|
432
|
+
request=request,
|
|
433
|
+
) from exc
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
if not self._entered:
|
|
437
|
+
raise RuntimeError("AsyncWprHttpClient is closed")
|
|
438
|
+
if wait_deadline is None:
|
|
439
|
+
return await self._take_slot()
|
|
440
|
+
if not hasattr(asyncio, "timeout_at"):
|
|
441
|
+
return await asyncio.wait_for(
|
|
442
|
+
self._take_slot(), max(0.0, wait_deadline - loop.time())
|
|
443
|
+
)
|
|
444
|
+
async with asyncio.timeout_at(wait_deadline):
|
|
445
|
+
return await self._take_slot()
|
|
446
|
+
except asyncio.TimeoutError as exc:
|
|
447
|
+
self._capacity.release()
|
|
448
|
+
elapsed = loop.time() - queued_at
|
|
449
|
+
if pool_limit_is_first:
|
|
450
|
+
raise pool_timeout_error(
|
|
451
|
+
request.method,
|
|
452
|
+
request.url,
|
|
453
|
+
request.timeout.pool or elapsed,
|
|
454
|
+
request=request,
|
|
455
|
+
) from exc
|
|
456
|
+
raise dispatch_timeout_error(
|
|
457
|
+
request.method,
|
|
458
|
+
request.url,
|
|
459
|
+
request.timeout.total or elapsed,
|
|
460
|
+
request=request,
|
|
461
|
+
) from exc
|
|
462
|
+
except BaseException:
|
|
463
|
+
self._capacity.release()
|
|
464
|
+
raise
|
|
465
|
+
|
|
466
|
+
async def _take_slot(self) -> _WorkerSlot:
|
|
467
|
+
assert self._available is not None
|
|
468
|
+
while True:
|
|
469
|
+
async with self._startup_lock:
|
|
470
|
+
try:
|
|
471
|
+
slot = self._available.get_nowait()
|
|
472
|
+
except asyncio.QueueEmpty:
|
|
473
|
+
if len(self._slots) < self.max_workers:
|
|
474
|
+
slot = await self._start_session()
|
|
475
|
+
for _ in range(self.max_channels_per_worker - 1):
|
|
476
|
+
self._available.put_nowait(slot)
|
|
477
|
+
return slot
|
|
478
|
+
else:
|
|
479
|
+
if slot.session.is_open:
|
|
480
|
+
return slot
|
|
481
|
+
self._retire_slot(slot)
|
|
482
|
+
continue
|
|
483
|
+
slot = await self._available.get()
|
|
484
|
+
if slot.session.is_open:
|
|
485
|
+
return slot
|
|
486
|
+
self._retire_slot(slot)
|
|
487
|
+
|
|
488
|
+
def _retire_slot(self, slot: _WorkerSlot) -> None:
|
|
489
|
+
if slot in self._slots:
|
|
490
|
+
self._slots.remove(slot)
|
|
491
|
+
if self._available is not None:
|
|
492
|
+
keep = []
|
|
493
|
+
while not self._available.empty():
|
|
494
|
+
token = self._available.get_nowait()
|
|
495
|
+
if token is not slot:
|
|
496
|
+
keep.append(token)
|
|
497
|
+
for token in keep:
|
|
498
|
+
self._available.put_nowait(token)
|
|
499
|
+
|
|
500
|
+
async def send(self, request: Request) -> HttpResponse:
|
|
501
|
+
"""Send a prepared request and return its response object."""
|
|
502
|
+
if not self._entered:
|
|
503
|
+
raise RuntimeError("AsyncWprHttpClient is not open")
|
|
504
|
+
if not isinstance(request, Request):
|
|
505
|
+
raise TypeError("request must be a wpr_http.Request")
|
|
506
|
+
if self._warming_up:
|
|
507
|
+
raise RuntimeError("normal requests are paused during client warmup")
|
|
508
|
+
self._requests_started = True
|
|
509
|
+
loop = asyncio.get_running_loop()
|
|
510
|
+
queued_at = loop.time()
|
|
511
|
+
total_deadline = (
|
|
512
|
+
queued_at + request.timeout.total
|
|
513
|
+
if request.timeout.total is not None
|
|
514
|
+
else None
|
|
515
|
+
)
|
|
516
|
+
slot = await self._acquire_slot(request, queued_at, total_deadline)
|
|
517
|
+
pool_wait_ms = (loop.time() - queued_at) * 1000
|
|
518
|
+
reusable = True
|
|
519
|
+
try:
|
|
520
|
+
if not self._entered:
|
|
521
|
+
raise RuntimeError("AsyncWprHttpClient is closed")
|
|
522
|
+
active_timeout = request.timeout
|
|
523
|
+
if total_deadline is not None:
|
|
524
|
+
remaining = max(0.0, total_deadline - loop.time())
|
|
525
|
+
if remaining == 0:
|
|
526
|
+
raise dispatch_timeout_error(
|
|
527
|
+
request.method,
|
|
528
|
+
request.url,
|
|
529
|
+
request.timeout.total or loop.time() - queued_at,
|
|
530
|
+
request=request,
|
|
531
|
+
)
|
|
532
|
+
active_timeout = Timeout(total=remaining, pool=request.timeout.pool)
|
|
533
|
+
response = await self._transport.send(
|
|
534
|
+
slot.session,
|
|
535
|
+
request=request,
|
|
536
|
+
proxy=self.proxy,
|
|
537
|
+
timeout=active_timeout,
|
|
538
|
+
)
|
|
539
|
+
response._wpr_diagnostics["pool_wait_ms"] = pool_wait_ms
|
|
540
|
+
self._responses.add(response)
|
|
541
|
+
response._set_on_close(self._responses.discard)
|
|
542
|
+
return response
|
|
543
|
+
except WprTimeoutError as exc:
|
|
544
|
+
if exc.worker_stopped:
|
|
545
|
+
reusable = False
|
|
546
|
+
await slot.session.abort()
|
|
547
|
+
raise
|
|
548
|
+
except asyncio.CancelledError:
|
|
549
|
+
if not slot.session.supports_request_cancel:
|
|
550
|
+
reusable = False
|
|
551
|
+
await slot.session.abort()
|
|
552
|
+
raise
|
|
553
|
+
finally:
|
|
554
|
+
reusable = reusable and slot.session.is_open
|
|
555
|
+
if reusable and self._entered and self._available is not None:
|
|
556
|
+
self._available.put_nowait(slot)
|
|
557
|
+
elif not reusable:
|
|
558
|
+
self._retire_slot(slot)
|
|
559
|
+
if self._capacity is not None:
|
|
560
|
+
self._capacity.release()
|
|
561
|
+
|
|
562
|
+
async def request(self, method: str, url: str, **kwargs: Any) -> HttpResponse:
|
|
563
|
+
return await self.send(self.build_request(method, url, **kwargs))
|
|
564
|
+
|
|
565
|
+
async def get(self, url: str, **kwargs: Any) -> HttpResponse:
|
|
566
|
+
return await self.request("GET", url, **kwargs)
|
|
567
|
+
|
|
568
|
+
async def post(self, url: str, **kwargs: Any) -> HttpResponse:
|
|
569
|
+
return await self.request("POST", url, **kwargs)
|
|
570
|
+
|
|
571
|
+
async def put(self, url: str, **kwargs: Any) -> HttpResponse:
|
|
572
|
+
return await self.request("PUT", url, **kwargs)
|
|
573
|
+
|
|
574
|
+
async def patch(self, url: str, **kwargs: Any) -> HttpResponse:
|
|
575
|
+
return await self.request("PATCH", url, **kwargs)
|
|
576
|
+
|
|
577
|
+
async def delete(self, url: str, **kwargs: Any) -> HttpResponse:
|
|
578
|
+
return await self.request("DELETE", url, **kwargs)
|
|
579
|
+
|
|
580
|
+
async def head(self, url: str, **kwargs: Any) -> HttpResponse:
|
|
581
|
+
return await self.request("HEAD", url, **kwargs)
|
|
582
|
+
|
|
583
|
+
async def options(self, url: str, **kwargs: Any) -> HttpResponse:
|
|
584
|
+
return await self.request("OPTIONS", url, **kwargs)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
class WprHttpClient(AsyncWprHttpClient):
|
|
588
|
+
"""Naming alias; this client remains asynchronous."""
|