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/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
|
+
)
|