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/exceptions.py ADDED
@@ -0,0 +1,207 @@
1
+ """HTTP client exception hierarchy and Gecko network error translation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from ._urls import safe_proxy, safe_url
9
+
10
+ _GECKO_ERRORS: dict[int, tuple[type[WprHttpError], str]] = {}
11
+
12
+
13
+ class WprHttpError(RuntimeError):
14
+ """Base exception, analogous to ``httpx.HTTPError``."""
15
+
16
+ def __init__(
17
+ self,
18
+ response: Mapping[str, Any],
19
+ *,
20
+ method: str | None = None,
21
+ url: str | None = None,
22
+ proxy: str | None = None,
23
+ request: Any = None,
24
+ message: str | None = None,
25
+ status_code: int | None = None,
26
+ ) -> None:
27
+ error = response.get("error")
28
+ self.worker_response = dict(response)
29
+ self.response = None
30
+ self.request = request
31
+ # True only when the client-side deadline/cancellation stopped the
32
+ # Worker process. Gecko-reported network timeouts leave it reusable.
33
+ self.worker_stopped = False
34
+ self.code = (
35
+ str(error.get("code", "HTTP_ERROR"))
36
+ if isinstance(error, Mapping)
37
+ else str(error or "HTTP_ERROR")
38
+ )
39
+ self.rv = error.get("rv") if isinstance(error, Mapping) else None
40
+ try:
41
+ self.rv_hex = f"0x{int(self.rv):08X}" if self.rv is not None else None
42
+ except (TypeError, ValueError):
43
+ self.rv_hex = None
44
+ self.method = method.upper() if method else None
45
+ self.url = safe_url(url) if url else None
46
+ self.proxy = safe_proxy(proxy) if proxy else None
47
+ self.status_code = status_code
48
+ if message is None:
49
+ request = (
50
+ f"{self.method} {self.url}" if self.method and self.url else "request"
51
+ )
52
+ message = f"{request} failed: {_rv_description(self.rv)}"
53
+ if self.proxy:
54
+ message += f" (proxy {self.proxy})"
55
+ message += "; no HTTP response was received"
56
+ message += f" [{self.code}"
57
+ if self.rv_hex:
58
+ message += f", rv={self.rv_hex}"
59
+ message += "]"
60
+ super().__init__(message)
61
+
62
+
63
+ class WprTransportError(WprHttpError):
64
+ """Base for failures before response headers arrive."""
65
+
66
+
67
+ class WprConnectError(WprTransportError):
68
+ """Connection, DNS, or socket failure."""
69
+
70
+
71
+ class WprProxyError(WprConnectError):
72
+ """Proxy connection, name-resolution, or authentication failure."""
73
+
74
+
75
+ class WprTimeoutError(WprTransportError):
76
+ """Request exceeded its configured deadline."""
77
+
78
+
79
+ class WprPoolTimeoutError(WprTimeoutError):
80
+ """Request exceeded the deadline while waiting for a Worker pool slot."""
81
+
82
+
83
+ class WprHTTPStatusError(WprHttpError):
84
+ """HTTP 4xx/5xx response raised by ``HttpResponse.raise_for_status``."""
85
+
86
+ def __init__(self, response: Any) -> None:
87
+ self.response = response
88
+ self.request = response.request
89
+ self.worker_response = None
90
+ self.status_code = response.status_code
91
+ self.code = "HTTP_STATUS_ERROR"
92
+ self.rv = self.rv_hex = self.proxy = None
93
+ self.method = response.request_method
94
+ self.url = safe_url(response.url)
95
+ request = f" for {self.method} {self.url}" if self.method and self.url else ""
96
+ reason = f" {response.reason}" if response.reason else ""
97
+ RuntimeError.__init__(self, f"HTTP {self.status_code}{reason}{request}")
98
+
99
+
100
+ _GECKO_ERRORS.update(
101
+ {
102
+ 0x804B000D: (
103
+ WprConnectError,
104
+ "connection refused by the peer (destination or proxy upstream)",
105
+ ),
106
+ 0x804B0014: (WprConnectError, "connection was reset before an HTTP response"),
107
+ 0x804B001E: (WprConnectError, "DNS lookup failed for the destination host"),
108
+ 0x804B002A: (WprProxyError, "DNS lookup failed for the proxy host"),
109
+ 0x804B0048: (WprProxyError, "connection to the proxy server was refused"),
110
+ 0x804B057F: (
111
+ WprProxyError,
112
+ "the proxy requires authentication or rejected its credentials",
113
+ ),
114
+ 0x804B000E: (WprTimeoutError, "network connection timed out"),
115
+ 0x804B0055: (WprTimeoutError, "network request timed out"),
116
+ 0x804B05E0: (WprTimeoutError, "the proxy gateway timed out"),
117
+ }
118
+ )
119
+
120
+
121
+ def _rv_description(rv: Any) -> str:
122
+ try:
123
+ mapped = _GECKO_ERRORS.get(int(rv))
124
+ except (TypeError, ValueError):
125
+ mapped = None
126
+ return (
127
+ mapped[1]
128
+ if mapped
129
+ else "network request failed before receiving an HTTP response"
130
+ )
131
+
132
+
133
+ def transport_error(
134
+ response: Mapping[str, Any],
135
+ *,
136
+ method: str,
137
+ url: str,
138
+ proxy: str | None,
139
+ request: Any = None,
140
+ ) -> WprHttpError:
141
+ error = response.get("error")
142
+ rv = error.get("rv") if isinstance(error, Mapping) else None
143
+ try:
144
+ error_type = _GECKO_ERRORS.get(int(rv), (WprTransportError, ""))[0]
145
+ except (TypeError, ValueError):
146
+ error_type = WprTransportError
147
+ return error_type(response, method=method, url=url, proxy=proxy, request=request)
148
+
149
+
150
+ def timeout_error(
151
+ method: str,
152
+ url: str,
153
+ timeout: float,
154
+ *,
155
+ request: Any = None,
156
+ worker_stopped: bool = True,
157
+ ) -> WprTimeoutError:
158
+ error = WprTimeoutError(
159
+ {"error": {"code": "REQUEST_TIMEOUT"}},
160
+ method=method,
161
+ url=url,
162
+ request=request,
163
+ message=(
164
+ f"{method.upper()} {safe_url(url)} exceeded the total timeout "
165
+ f"of {timeout:.3g}s; "
166
+ + (
167
+ "the network worker was stopped and will restart for the next request"
168
+ if worker_stopped
169
+ else "the request channel was cancelled; other requests remain active"
170
+ )
171
+ + " [REQUEST_TIMEOUT]"
172
+ ),
173
+ )
174
+ error.worker_stopped = worker_stopped
175
+ return error
176
+
177
+
178
+ def pool_timeout_error(
179
+ method: str, url: str, timeout: float, *, request: Any = None
180
+ ) -> WprPoolTimeoutError:
181
+ return WprPoolTimeoutError(
182
+ {"error": {"code": "POOL_TIMEOUT"}},
183
+ method=method,
184
+ url=url,
185
+ request=request,
186
+ message=(
187
+ f"{method.upper()} {safe_url(url)} timed out after {timeout:.3g}s "
188
+ "waiting for an available Worker slot; active requests "
189
+ "were left running [POOL_TIMEOUT]"
190
+ ),
191
+ )
192
+
193
+
194
+ def dispatch_timeout_error(
195
+ method: str, url: str, timeout: float, *, request: Any = None
196
+ ) -> WprTimeoutError:
197
+ return WprTimeoutError(
198
+ {"error": {"code": "REQUEST_TIMEOUT"}},
199
+ method=method,
200
+ url=url,
201
+ request=request,
202
+ message=(
203
+ f"{method.upper()} {safe_url(url)} exceeded its total timeout of "
204
+ f"{timeout:.3g}s while waiting to dispatch; the active Worker was left running "
205
+ "[REQUEST_TIMEOUT]"
206
+ ),
207
+ )
wpr_http/models.py ADDED
@@ -0,0 +1,331 @@
1
+ """Request timeout and response models for the network client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import codecs
6
+ import json
7
+ import logging
8
+ import math
9
+ from collections.abc import Iterable, Mapping, MutableMapping
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+ from wpr_runtime_core.data_plane import SharedBlob, SharedBlobDescriptor
14
+ from wpr_runtime_core.protocols import BodyLease, ReleaseSession
15
+
16
+ from ._urls import safe_url
17
+ from .exceptions import WprHTTPStatusError
18
+
19
+ _logger = logging.getLogger("wpr_http")
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Timeout:
24
+ """End-to-end request timeout in seconds.
25
+
26
+ ``total`` covers queueing plus the active request. ``pool`` separately
27
+ limits waiting for an available Worker slot. ``None``
28
+ disables the corresponding deadline. Socket phase limits require Worker
29
+ protocol support.
30
+ """
31
+
32
+ total: float | None = 30.0
33
+ pool: float | None = None
34
+
35
+ def __post_init__(self) -> None:
36
+ for name in ("total", "pool"):
37
+ value = getattr(self, name)
38
+ if value is not None and (not math.isfinite(value) or value < 0):
39
+ raise ValueError(f"timeout.{name} must be finite and >= 0, or None")
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class NetworkTiming:
44
+ request_start_ms: int
45
+ response_headers_ms: int
46
+ response_end_ms: int
47
+
48
+
49
+ class Headers(MutableMapping[str, str]):
50
+ """Case-insensitive header mapping that also preserves repeated values."""
51
+
52
+ def __init__(
53
+ self,
54
+ values: Mapping[str, str] | Iterable[tuple[str, str]] | None = None,
55
+ ) -> None:
56
+ self._values: dict[str, tuple[str, list[str]]] = {}
57
+ source = values.items() if isinstance(values, Mapping) else (values or ())
58
+ for name, value in source:
59
+ self.add(str(name), str(value))
60
+
61
+ def add(self, name: str, value: str) -> None:
62
+ key = name.lower()
63
+ if key in self._values:
64
+ original, values = self._values[key]
65
+ values.append(str(value))
66
+ self._values[key] = (original, values)
67
+ else:
68
+ self._values[key] = (str(name), [str(value)])
69
+
70
+ def get_list(self, name: str) -> list[str]:
71
+ value = self._values.get(name.lower())
72
+ return list(value[1]) if value else []
73
+
74
+ def __getitem__(self, name: str) -> str:
75
+ try:
76
+ values = self._values[name.lower()][1]
77
+ except KeyError:
78
+ raise KeyError(name) from None
79
+ return ", ".join(values)
80
+
81
+ def __setitem__(self, name: str, value: str) -> None:
82
+ self._values[name.lower()] = (str(name), [str(value)])
83
+
84
+ def __delitem__(self, name: str) -> None:
85
+ del self._values[name.lower()]
86
+
87
+ def __iter__(self):
88
+ return (original for original, _values in self._values.values())
89
+
90
+ def __len__(self) -> int:
91
+ return len(self._values)
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class Request:
96
+ """Fully prepared request, separate from sending it through a Worker."""
97
+
98
+ method: str
99
+ url: str
100
+ headers: Mapping[str, str] = field(default_factory=Headers)
101
+ content: str | None = None
102
+ timeout: Timeout | float | None = field(default_factory=Timeout)
103
+ follow_redirects: bool = True
104
+
105
+ def __post_init__(self) -> None:
106
+ object.__setattr__(self, "method", self.method.upper())
107
+ object.__setattr__(self, "headers", Headers(self.headers))
108
+ if not isinstance(self.timeout, Timeout):
109
+ value = None if self.timeout is None else float(self.timeout)
110
+ object.__setattr__(self, "timeout", Timeout(total=value))
111
+
112
+ def __repr__(self) -> str:
113
+ return f"<Request [{self.method}] {safe_url(self.url)}>"
114
+
115
+
116
+ HttpRequest = Request
117
+
118
+
119
+ class HttpResponse:
120
+ """Response metadata and a shared-memory body lease."""
121
+
122
+ def __init__(
123
+ self,
124
+ session: ReleaseSession | None,
125
+ value: Mapping[str, Any],
126
+ *,
127
+ request: Request | None = None,
128
+ request_method: str | None = None,
129
+ request_url: str | None = None,
130
+ rpc_timings: Mapping[str, Any] | None = None,
131
+ body: BodyLease | None = None,
132
+ ) -> None:
133
+ self._session = session
134
+ self.request = request or Request(
135
+ method=request_method or "GET",
136
+ url=request_url or str(value.get("final_url", "")),
137
+ headers={},
138
+ content=None,
139
+ timeout=Timeout(total=None),
140
+ follow_redirects=True,
141
+ )
142
+ self.request_method = self.request.method
143
+ self.request_url = self.request.url
144
+ self.status_code = int(value.get("status", 0))
145
+ self.reason = str(value.get("status_text", ""))
146
+ self.url = str(value.get("final_url", ""))
147
+ self.headers = _headers(value.get("headers"))
148
+ self.raw_headers = tuple(
149
+ (str(item.get("name", "")), str(item.get("value", "")))
150
+ for item in value.get("headers", [])
151
+ if isinstance(item, Mapping)
152
+ )
153
+ self.redirects = tuple(value.get("redirects") or ())
154
+ timing = value.get("timings") or {}
155
+ self.timing = NetworkTiming(
156
+ int(timing.get("request_start_ms", 0)),
157
+ int(timing.get("response_headers_ms", 0)),
158
+ int(timing.get("response_end_ms", 0)),
159
+ )
160
+ if body is None:
161
+ if session is None:
162
+ raise ValueError("response requires a body lease or a release session")
163
+ body = SharedBlob(SharedBlobDescriptor.from_value(value["body"]), session)
164
+ self._body = body
165
+ # Attach to the named mapping before the client releases this Worker
166
+ # slot. If the Worker later exits, Windows retains the section through
167
+ # this local mapping until the response closes.
168
+ self._body.open()
169
+ self._content: bytes | None = None
170
+ self._on_close = None
171
+ self._closed = False
172
+ self.encoding = _response_encoding(self.headers, self._body.descriptor.encoding)
173
+ self._wpr_diagnostics: dict[str, Any] = {
174
+ "fetch_rpc": dict(rpc_timings or {}),
175
+ "worker": dict(value.get("_wpr_timing") or {}),
176
+ "mmap_open_ms": self._body.mapping_open_ms,
177
+ }
178
+
179
+ @property
180
+ def content(self) -> bytes:
181
+ if self._content is None:
182
+ if self._closed:
183
+ raise RuntimeError("response body is closed")
184
+ self._body.open()
185
+ self._content = self._body.read_bytes()
186
+ self._wpr_diagnostics["body_copy_ms"] = self._body.copy_ms
187
+ return self._content
188
+
189
+ @property
190
+ def is_success(self) -> bool:
191
+ return 200 <= self.status_code < 300
192
+
193
+ @property
194
+ def ok(self) -> bool:
195
+ return self.status_code < 400
196
+
197
+ @property
198
+ def is_redirect(self) -> bool:
199
+ return 300 <= self.status_code < 400
200
+
201
+ @property
202
+ def is_error(self) -> bool:
203
+ return self.status_code >= 400
204
+
205
+ @property
206
+ def is_closed(self) -> bool:
207
+ return self._closed
208
+
209
+ async def aread(self) -> bytes:
210
+ data = self.content
211
+ await self.aclose()
212
+ return data
213
+
214
+ async def read(self) -> bytes:
215
+ """Read and release the shared response body."""
216
+ return await self.aread()
217
+
218
+ async def atext(self, encoding: str | None = None) -> str:
219
+ return (await self.aread()).decode(encoding or self.encoding)
220
+
221
+ async def text(self, encoding: str | None = None) -> str:
222
+ return await self.atext(encoding)
223
+
224
+ async def ajson(self) -> Any:
225
+ return json.loads(await self.atext())
226
+
227
+ async def json(self) -> Any:
228
+ return await self.ajson()
229
+
230
+ def raise_for_status(self) -> None:
231
+ if self.status_code >= 400:
232
+ _logger.warning(
233
+ "http.status_error status_code=%s method=%s url=%s",
234
+ self.status_code,
235
+ self.request_method or "?",
236
+ safe_url(self.url),
237
+ )
238
+ raise WprHTTPStatusError(self)
239
+
240
+ async def aclose(self) -> None:
241
+ if self._closed:
242
+ return
243
+ try:
244
+ await self._body.release()
245
+ self._wpr_diagnostics["mmap_open_ms"] = self._body.mapping_open_ms
246
+ self._wpr_diagnostics["body_copy_ms"] = self._body.copy_ms
247
+ self._wpr_diagnostics["release_rpc"] = dict(self._body.release_timings)
248
+ finally:
249
+ self._closed = True
250
+ if self._on_close is not None:
251
+ self._on_close(self)
252
+
253
+ def _set_on_close(self, callback) -> None:
254
+ self._on_close = callback
255
+
256
+ async def __aenter__(self) -> "HttpResponse": # noqa: PYI034, UP037
257
+ self._body.open()
258
+ return self
259
+
260
+ async def __aexit__(self, exc_type, exc, tb) -> None:
261
+ await self.aclose()
262
+
263
+ def __repr__(self) -> str:
264
+ reason = f" {self.reason}" if self.reason else ""
265
+ return f"<HttpResponse [{self.status_code}{reason}]>"
266
+
267
+ @property
268
+ def diagnostics(self) -> dict[str, Any]:
269
+ """Return measured client/RPC phases and optional Worker phases."""
270
+ return {
271
+ **self._wpr_diagnostics,
272
+ "fetch_rpc": dict(self._wpr_diagnostics.get("fetch_rpc") or {}),
273
+ "worker": dict(self._wpr_diagnostics.get("worker") or {}),
274
+ "release_rpc": dict(self._wpr_diagnostics.get("release_rpc") or {}),
275
+ }
276
+
277
+
278
+ def _headers(value: Any) -> Headers:
279
+ result = Headers()
280
+ if isinstance(value, list):
281
+ for item in value:
282
+ if isinstance(item, Mapping):
283
+ name = str(item.get("name", "")).lower()
284
+ if name:
285
+ result.add(name, str(item.get("value", "")))
286
+ return result
287
+
288
+
289
+ def _response_encoding(headers: Headers, fallback: str) -> str:
290
+ from email.message import Message
291
+
292
+ for content_type in headers.get_list("content-type"):
293
+ for value in _split_quoted_values(content_type, ","):
294
+ message = Message()
295
+ message["content-type"] = value
296
+ charset = message.get_content_charset()
297
+ if charset:
298
+ encoding = _known_encoding(charset)
299
+ if encoding:
300
+ return encoding
301
+
302
+ return _known_encoding(fallback) or "utf-8"
303
+
304
+
305
+ def _split_quoted_values(value: str, separator: str) -> list[str]:
306
+ """Split a combined header value without breaking quoted parameters."""
307
+ parts: list[str] = []
308
+ start = 0
309
+ quoted = False
310
+ escaped = False
311
+ for index, character in enumerate(value):
312
+ if escaped:
313
+ escaped = False
314
+ elif quoted and character == "\\":
315
+ escaped = True
316
+ elif character == '"':
317
+ quoted = not quoted
318
+ elif character == separator and not quoted:
319
+ parts.append(value[start:index].strip())
320
+ start = index + 1
321
+ parts.append(value[start:].strip())
322
+ return [part for part in parts if part]
323
+
324
+
325
+ def _known_encoding(value: str | None) -> str | None:
326
+ if not value:
327
+ return None
328
+ try:
329
+ return codecs.lookup(value.strip()).name
330
+ except LookupError:
331
+ return None
wpr_http/transport.py ADDED
@@ -0,0 +1,121 @@
1
+ """Adapter between the stable HTTP API and the versioned Gecko Worker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+
8
+ from wpr_runtime_core.data_plane import SharedBlob, SharedBlobDescriptor
9
+ from wpr_runtime_core.session import AsyncWorkerSession as AsyncGeckoWorkerSession
10
+
11
+ from ._urls import safe_url
12
+ from .exceptions import timeout_error, transport_error
13
+ from .models import HttpResponse, Request, Timeout
14
+
15
+ _logger = logging.getLogger("wpr_http")
16
+
17
+
18
+ class GeckoNetworkTransport:
19
+ """One-request adapter; all Gecko protocol details stay below the client."""
20
+
21
+ async def send(
22
+ self,
23
+ session: AsyncGeckoWorkerSession,
24
+ *,
25
+ request: Request,
26
+ proxy: str | None,
27
+ timeout: Timeout | None = None,
28
+ ) -> HttpResponse:
29
+ active_timeout = timeout or request.timeout
30
+ fetch_kwargs = {
31
+ "method": request.method,
32
+ "headers": dict(request.headers),
33
+ "body": request.content,
34
+ "follow_redirects": request.follow_redirects,
35
+ }
36
+ timed_fetch = getattr(session, "network_fetch_with_timing", None)
37
+ if timed_fetch is None:
38
+
39
+ async def legacy_fetch():
40
+ return await session.network_fetch(request.url, **fetch_kwargs), {}
41
+
42
+ operation = legacy_fetch()
43
+ else:
44
+ operation = timed_fetch(request.url, **fetch_kwargs)
45
+ try:
46
+ if active_timeout.total is None:
47
+ raw, rpc_timings = await operation
48
+ else:
49
+ raw, rpc_timings = await asyncio.wait_for(
50
+ operation, active_timeout.total
51
+ )
52
+ except asyncio.TimeoutError as exc:
53
+ # Multiplex cancellation completes in the IOCP request coroutine;
54
+ # legacy synchronous requests still require stopping the Worker.
55
+ granular = (
56
+ getattr(session, "supports_request_cancel", False) and session.is_open
57
+ )
58
+ if not granular:
59
+ await session.abort()
60
+ timeout_exc = timeout_error(
61
+ request.method,
62
+ request.url,
63
+ active_timeout.total or 0,
64
+ request=request,
65
+ worker_stopped=not granular,
66
+ )
67
+ _logger.warning(
68
+ "http.timeout method=%s url=%s timeout_total=%s status_code=None "
69
+ "worker_action=%s message=%s",
70
+ request.method,
71
+ timeout_exc.url,
72
+ round(active_timeout.total, 3)
73
+ if active_timeout.total is not None
74
+ else None,
75
+ "cancel_channel" if granular else "abort_and_restart",
76
+ str(timeout_exc),
77
+ )
78
+ raise timeout_exc from exc
79
+ except asyncio.CancelledError:
80
+ # Keep multiplex siblings alive, but unblock legacy executor reads
81
+ # by stopping their Worker.
82
+ granular = (
83
+ getattr(session, "supports_request_cancel", False) and session.is_open
84
+ )
85
+ if not granular:
86
+ await session.abort()
87
+ _logger.warning(
88
+ "http.cancelled method=%s url=%s worker_action=%s",
89
+ request.method,
90
+ safe_url(request.url),
91
+ "cancel_channel" if granular else "abort_and_restart",
92
+ )
93
+ raise
94
+
95
+ if not raw.get("ok"):
96
+ exc = transport_error(
97
+ raw,
98
+ method=request.method,
99
+ url=request.url,
100
+ proxy=proxy,
101
+ request=request,
102
+ )
103
+ _logger.warning(
104
+ "http.transport_error method=%s url=%s status_code=None "
105
+ "exception=%s code=%s rv=%s proxy=%s message=%s",
106
+ request.method,
107
+ exc.url,
108
+ type(exc).__name__,
109
+ exc.code,
110
+ exc.rv_hex or exc.rv,
111
+ exc.proxy or "none",
112
+ str(exc),
113
+ )
114
+ raise exc
115
+ return HttpResponse(
116
+ session,
117
+ raw,
118
+ request=request,
119
+ rpc_timings=rpc_timings,
120
+ body=SharedBlob(SharedBlobDescriptor.from_value(raw["body"]), session),
121
+ )