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/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."""