syshttp 0.1.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.
- syshttp/AGENTS.md +817 -0
- syshttp/README.md +177 -0
- syshttp/__init__.py +71 -0
- syshttp/_fileio.py +78 -0
- syshttp/_parse.py +30 -0
- syshttp/auth.py +17 -0
- syshttp/autopatch.py +382 -0
- syshttp/client/__init__.py +70 -0
- syshttp/client/_body.py +156 -0
- syshttp/client/_client.py +413 -0
- syshttp/client/_context.py +107 -0
- syshttp/client/_deadline.py +94 -0
- syshttp/client/_default.py +72 -0
- syshttp/client/_fetch.py +98 -0
- syshttp/client/_follow.py +243 -0
- syshttp/client/_headers.py +238 -0
- syshttp/client/_pool.py +170 -0
- syshttp/client/_prepare.py +77 -0
- syshttp/client/_response.py +301 -0
- syshttp/client/_route.py +75 -0
- syshttp/client/_settings.py +12 -0
- syshttp/client/_state.py +322 -0
- syshttp/client/_status.py +59 -0
- syshttp/client/_tlstunnel.py +114 -0
- syshttp/client/_transport.py +550 -0
- syshttp/cookies.py +391 -0
- syshttp/env.py +144 -0
- syshttp/exceptions.py +29 -0
- syshttp/hooks.py +314 -0
- syshttp/jar.py +133 -0
- syshttp/proxy.py +341 -0
- syshttp/py.typed +0 -0
- syshttp/retry.py +34 -0
- syshttp/tls.py +60 -0
- syshttp-0.1.0.dist-info/METADATA +219 -0
- syshttp-0.1.0.dist-info/RECORD +39 -0
- syshttp-0.1.0.dist-info/WHEEL +4 -0
- syshttp-0.1.0.dist-info/entry_points.txt +6 -0
- syshttp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
"""The default transport: HTTP/1.1 on `http.client`, with no third-party code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno as _errno
|
|
6
|
+
import http.client as _client
|
|
7
|
+
import logging as _logging
|
|
8
|
+
import os as _os
|
|
9
|
+
import select as _select
|
|
10
|
+
import socket as _socket
|
|
11
|
+
import ssl as _ssl
|
|
12
|
+
import threading as _threading
|
|
13
|
+
import time as _time
|
|
14
|
+
import typing as _ty
|
|
15
|
+
|
|
16
|
+
import proxylib
|
|
17
|
+
|
|
18
|
+
from .. import proxy as _proxy
|
|
19
|
+
from ..env import HTTPEnv
|
|
20
|
+
from . import _pool, _route
|
|
21
|
+
from ._body import prepare_body
|
|
22
|
+
from ._context import TLSChoice
|
|
23
|
+
from ._headers import (
|
|
24
|
+
Target,
|
|
25
|
+
bracket,
|
|
26
|
+
check_body,
|
|
27
|
+
check_headers,
|
|
28
|
+
check_method,
|
|
29
|
+
check_timeout,
|
|
30
|
+
check_url,
|
|
31
|
+
wants_decoding,
|
|
32
|
+
)
|
|
33
|
+
from ._response import HTTPResponse
|
|
34
|
+
from ._tlstunnel import TunneledTLS
|
|
35
|
+
|
|
36
|
+
_log = _logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@_ty.runtime_checkable
|
|
40
|
+
class Transport(_ty.Protocol):
|
|
41
|
+
"""What `HTTPClient` sends through: one request, one response, no redirect,
|
|
42
|
+
no credentials added.
|
|
43
|
+
|
|
44
|
+
`send()` takes the arguments `prepare_request()` checks and returns an
|
|
45
|
+
`HTTPResponse` (`stream=False`: its body already read; `stream=True`: the
|
|
46
|
+
caller closes it); `close()` releases whatever the transport holds. The
|
|
47
|
+
client does the redirects, cookies, tokens, hooks, retries and deadline
|
|
48
|
+
above it. Routing (the proxy map, `CONNECT`, gateways), TLS verification,
|
|
49
|
+
connection reuse and decoding a `gzip` or `deflate` body are the
|
|
50
|
+
transport's: `HTTPTransport` does them with syshttp's proxy map and the
|
|
51
|
+
system's TLS, and a transport on another library brings that library's."""
|
|
52
|
+
|
|
53
|
+
def send(
|
|
54
|
+
self,
|
|
55
|
+
method: str,
|
|
56
|
+
url: str,
|
|
57
|
+
*,
|
|
58
|
+
headers: "_ty.Mapping[str, str]",
|
|
59
|
+
body: "bytes | _ty.BinaryIO | _ty.Iterable[bytes] | None" = None,
|
|
60
|
+
timeout: "float | tuple | None",
|
|
61
|
+
stream: bool = False,
|
|
62
|
+
) -> HTTPResponse: ...
|
|
63
|
+
|
|
64
|
+
def close(self) -> None: ...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
REUSE_WINDOW = 1.0
|
|
68
|
+
"""A request that cannot be sent again uses an idle connection only if the
|
|
69
|
+
connection carried a response within this many seconds; an older one may have
|
|
70
|
+
been dropped by the server, and the request would be lost with it."""
|
|
71
|
+
|
|
72
|
+
_IDEMPOTENT = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE", "PROPFIND"})
|
|
73
|
+
|
|
74
|
+
_now = _time.monotonic
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _failure(error: BaseException) -> BaseException:
|
|
78
|
+
"""`error` of the socket, TLS or `http.client` layer as the exception a
|
|
79
|
+
transport raises: `TimeoutError`, `ConnectionError`, `ssl.SSLError`, or
|
|
80
|
+
else `OSError(EIO)`. The text never holds a URL or a header."""
|
|
81
|
+
if isinstance(error, (_socket.timeout, TimeoutError)):
|
|
82
|
+
return TimeoutError(_errno.ETIMEDOUT, "the request timed out")
|
|
83
|
+
if isinstance(error, _ssl.SSLError):
|
|
84
|
+
if "timed out" in str(error):
|
|
85
|
+
return TimeoutError(_errno.ETIMEDOUT, "the request timed out")
|
|
86
|
+
return error
|
|
87
|
+
if isinstance(error, _client.RemoteDisconnected):
|
|
88
|
+
return ConnectionResetError(_errno.ECONNRESET, "the server closed the connection without a response")
|
|
89
|
+
if isinstance(error, ConnectionError):
|
|
90
|
+
return error
|
|
91
|
+
if isinstance(error, _socket.gaierror):
|
|
92
|
+
return ConnectionError(_errno.EHOSTUNREACH, "the host name could not be resolved")
|
|
93
|
+
if isinstance(error, _client.NotConnected):
|
|
94
|
+
return ConnectionAbortedError(_errno.ECONNABORTED, "the connection was closed")
|
|
95
|
+
if isinstance(error, _client.IncompleteRead):
|
|
96
|
+
return OSError(_errno.EIO, "the response ended before its stated length")
|
|
97
|
+
if isinstance(error, _client.HTTPException):
|
|
98
|
+
return OSError(_errno.EIO, f"the response is not valid HTTP ({type(error).__name__})")
|
|
99
|
+
return error
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _dropped(error: BaseException) -> bool:
|
|
103
|
+
"""Whether `error` says the server closed an idle connection (as opposed to
|
|
104
|
+
a request that went wrong): nothing of a response had arrived."""
|
|
105
|
+
return isinstance(
|
|
106
|
+
error,
|
|
107
|
+
(
|
|
108
|
+
ConnectionResetError,
|
|
109
|
+
ConnectionAbortedError,
|
|
110
|
+
BrokenPipeError,
|
|
111
|
+
_ssl.SSLEOFError,
|
|
112
|
+
_ssl.SSLZeroReturnError,
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _unusable(sock) -> bool:
|
|
118
|
+
"""Whether an idle connection must not carry a request: the server closed
|
|
119
|
+
it, or sent bytes nobody asked for, which the next reply would start with."""
|
|
120
|
+
if sock is None:
|
|
121
|
+
return True
|
|
122
|
+
try:
|
|
123
|
+
if getattr(sock, "pending", None) is not None and sock.pending():
|
|
124
|
+
return True
|
|
125
|
+
return bool(_select.select([sock], [], [], 0)[0])
|
|
126
|
+
except (OSError, ValueError):
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
MAX_INTERIM_REPLIES = 8
|
|
131
|
+
"""Most interim (1xx) replies the transport skips before the reply it returns;
|
|
132
|
+
a server that sends more is not answering."""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _skip_headers(fp) -> None:
|
|
136
|
+
"""Reads the header lines of an interim reply, up to its blank line."""
|
|
137
|
+
for _ in range(100):
|
|
138
|
+
line = fp.readline(65537)
|
|
139
|
+
if len(line) > 65536:
|
|
140
|
+
raise _client.LineTooLong("header line")
|
|
141
|
+
if line in (b"\r\n", b"\n", b""):
|
|
142
|
+
return
|
|
143
|
+
raise _client.HTTPException("got more than 100 headers")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class _Reply(_client.HTTPResponse):
|
|
147
|
+
"""A response that goes past every interim (1xx) reply, not only `100`
|
|
148
|
+
(`http.client` takes any other as the final one, and the real reply is then
|
|
149
|
+
read as the answer to the next request), and at most `MAX_INTERIM_REPLIES`
|
|
150
|
+
of them."""
|
|
151
|
+
|
|
152
|
+
_interim = 0
|
|
153
|
+
|
|
154
|
+
def _read_status(self):
|
|
155
|
+
while True:
|
|
156
|
+
version, status, reason = super()._read_status()
|
|
157
|
+
if not 100 <= status < 200:
|
|
158
|
+
return version, status, reason
|
|
159
|
+
self._interim += 1
|
|
160
|
+
if self._interim > MAX_INTERIM_REPLIES:
|
|
161
|
+
raise _client.HTTPException("too many interim replies")
|
|
162
|
+
if status == _client.CONTINUE:
|
|
163
|
+
return version, status, reason # `begin()` skips its headers
|
|
164
|
+
_skip_headers(self.fp)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _Dropped(Exception):
|
|
168
|
+
"""An idle connection the server had closed."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, error: BaseException):
|
|
171
|
+
super().__init__()
|
|
172
|
+
self.error = error
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class _Payload:
|
|
176
|
+
"""The request body, whether it can be sent twice, and the
|
|
177
|
+
`Content-Length` to add to the request (`None`: none to add)."""
|
|
178
|
+
|
|
179
|
+
def __init__(self, body, pairs):
|
|
180
|
+
self.repeatable = check_body(body)
|
|
181
|
+
self.value, self.length = prepare_body(body, pairs)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class _Body:
|
|
185
|
+
"""The body of one response, read from its connection. The connection goes
|
|
186
|
+
back to the pool once the body has been read to its end; one that is
|
|
187
|
+
closed earlier, fails, or ends short is closed."""
|
|
188
|
+
|
|
189
|
+
def __init__(self, owner, pool, connection, response, expected: "int | None"):
|
|
190
|
+
# The transport stays alive while its reply is open: collecting it
|
|
191
|
+
# would close the connection the reply is reading.
|
|
192
|
+
self._owner = owner
|
|
193
|
+
self._pool = pool
|
|
194
|
+
self._connection = connection
|
|
195
|
+
self._response = response
|
|
196
|
+
self._expected = expected
|
|
197
|
+
self._received = 0
|
|
198
|
+
self._done = False
|
|
199
|
+
if expected == 0:
|
|
200
|
+
self._finish()
|
|
201
|
+
|
|
202
|
+
def _finish(self) -> None:
|
|
203
|
+
if self._done:
|
|
204
|
+
return
|
|
205
|
+
self._done = True
|
|
206
|
+
# Closing the response closes its file object, not the socket; a
|
|
207
|
+
# response left open would make `http.client` refuse the next request.
|
|
208
|
+
self._response.close()
|
|
209
|
+
if self._response.will_close:
|
|
210
|
+
self._pool.discard(self._connection)
|
|
211
|
+
else:
|
|
212
|
+
self._pool.give_back(self._connection, _now())
|
|
213
|
+
|
|
214
|
+
def _abort(self) -> None:
|
|
215
|
+
if not self._done:
|
|
216
|
+
self._done = True
|
|
217
|
+
self._response.close()
|
|
218
|
+
self._pool.discard(self._connection)
|
|
219
|
+
|
|
220
|
+
def read(self, amount: int) -> bytes:
|
|
221
|
+
if self._done:
|
|
222
|
+
return b""
|
|
223
|
+
try:
|
|
224
|
+
chunk = self._response.read1(amount)
|
|
225
|
+
except (OSError, _client.HTTPException) as error:
|
|
226
|
+
self._abort()
|
|
227
|
+
raise _failure(error) from None
|
|
228
|
+
except BaseException:
|
|
229
|
+
self._abort()
|
|
230
|
+
raise
|
|
231
|
+
if chunk:
|
|
232
|
+
self._received += len(chunk)
|
|
233
|
+
if self._response.isclosed():
|
|
234
|
+
self._finish()
|
|
235
|
+
return chunk
|
|
236
|
+
if self._expected is not None and self._received < self._expected:
|
|
237
|
+
self._abort()
|
|
238
|
+
raise OSError(
|
|
239
|
+
_errno.EIO,
|
|
240
|
+
f"the response ended after {self._received} of {self._expected} bytes",
|
|
241
|
+
)
|
|
242
|
+
self._finish()
|
|
243
|
+
return b""
|
|
244
|
+
|
|
245
|
+
def close(self) -> None:
|
|
246
|
+
self._abort()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class _Route(_ty.NamedTuple):
|
|
250
|
+
"""Where a request is sent and how it is written for the first server it
|
|
251
|
+
reaches."""
|
|
252
|
+
|
|
253
|
+
wire: Target # the server written for: the origin, or a gateway
|
|
254
|
+
proxy: "proxylib.Proxy | None" # the http or https proxy it passes through
|
|
255
|
+
gateway: "proxylib.Proxy | None" # the URL-prefix gateway it is handed to
|
|
256
|
+
key: tuple # what a pooled connection is keyed by
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class HTTPTransport:
|
|
260
|
+
"""Sends HTTP requests on the standard library: `http.client` over a socket,
|
|
261
|
+
with TLS from `ssl`, and no third-party code.
|
|
262
|
+
|
|
263
|
+
`send()` makes exactly one request: it follows no redirect, adds no
|
|
264
|
+
credentials of the caller's and retries only as described below. A
|
|
265
|
+
transport is a context manager, and `close()` closes every connection it
|
|
266
|
+
holds.
|
|
267
|
+
|
|
268
|
+
**Routing.** Each request asks `proxymap` (a proxylib proxy map; default
|
|
269
|
+
`proxy.proxy_map(env=env)`: `SYSHTTP_PROXY`, the `*_PROXY` variables,
|
|
270
|
+
`NO_PROXY`, then the system's configuration) which entries it may use, and
|
|
271
|
+
takes the first that accepts a connection (`proxylib.Probed`; with no entry
|
|
272
|
+
reachable and no direct one, `proxylib.NoReachableProxyError`). A direct
|
|
273
|
+
entry connects to the origin. An `http:` or `https:` proxy gets an
|
|
274
|
+
absolute-form request for an `http:` origin and a `CONNECT` for an `https:`
|
|
275
|
+
one; its headers (a credential) go to it alone, in the `CONNECT` or on the
|
|
276
|
+
plain-http request, never through the tunnel. A `prefix+http(s)` gateway is
|
|
277
|
+
asked for `<gateway><path><URL>` with its credential as a header of that
|
|
278
|
+
request, and the response keeps the URL asked for. SOCKS entries are not
|
|
279
|
+
spoken (`proxylib.UnsupportedProxyError` when nothing else is offered). A
|
|
280
|
+
map with no opinion for a URL, `{}` included, means a direct connection.
|
|
281
|
+
|
|
282
|
+
**TLS.** `https:` origins are verified against `ssl_context` when given,
|
|
283
|
+
else the CA bundle named by the `CA_BUNDLE` setting, else the system's
|
|
284
|
+
context (`tls.os_ssl_context()`). `verify=False` is the one way to turn
|
|
285
|
+
verification off, `verify=<path>` names a bundle, and an `ssl_context`
|
|
286
|
+
beside either is `ValueError`. An `https:` proxy or gateway is spoken to
|
|
287
|
+
with `proxy_ssl_context`, else the system's context.
|
|
288
|
+
|
|
289
|
+
Each thread keeps one idle connection per route (at most 8), which a later
|
|
290
|
+
request reuses unless the server has closed it or sent bytes on it since;
|
|
291
|
+
the connections of a thread are closed when it ends. A
|
|
292
|
+
request that fails before any response byte on a reused connection because
|
|
293
|
+
the server had closed it is sent once more on a new connection, but only
|
|
294
|
+
if it is repeatable (`GET`, `HEAD`, `OPTIONS`, `PUT`, `DELETE`,
|
|
295
|
+
`PROPFIND`) and its body is `None` or in memory. Any other request reuses a
|
|
296
|
+
connection only if it carried a response within the last second."""
|
|
297
|
+
|
|
298
|
+
def __init__(
|
|
299
|
+
self,
|
|
300
|
+
*,
|
|
301
|
+
proxymap: "_ty.Any" = None,
|
|
302
|
+
env: "HTTPEnv | None" = None,
|
|
303
|
+
ssl_context: "_ssl.SSLContext | None" = None,
|
|
304
|
+
proxy_ssl_context: "_ssl.SSLContext | None" = None,
|
|
305
|
+
verify: "bool | str | _os.PathLike[str]" = True,
|
|
306
|
+
):
|
|
307
|
+
self._env = HTTPEnv() if env is None else env
|
|
308
|
+
self._tls = TLSChoice(self._env, ssl_context, proxy_ssl_context, verify)
|
|
309
|
+
if proxymap is None:
|
|
310
|
+
# Decided at every request; a malformed setting is refused now.
|
|
311
|
+
_proxy.configured_proxy(env=self._env)
|
|
312
|
+
self._proxymap: "_ty.Any" = _proxy.proxy_map(env=self._env)
|
|
313
|
+
else:
|
|
314
|
+
self._proxymap = _ty.cast(_ty.Any, proxylib.ProxyMap)(proxymap)
|
|
315
|
+
if self._tls.unverified:
|
|
316
|
+
_log.warning("HTTPTransport(verify=False): TLS certificates are not verified")
|
|
317
|
+
self._pool = _pool.Pool()
|
|
318
|
+
self._finalizer = _pool.finalize_with(self, self._pool)
|
|
319
|
+
|
|
320
|
+
def __repr__(self) -> str:
|
|
321
|
+
return f"HTTPTransport(verify={not self._tls.unverified})"
|
|
322
|
+
|
|
323
|
+
def __enter__(self) -> "HTTPTransport":
|
|
324
|
+
return self
|
|
325
|
+
|
|
326
|
+
def __exit__(self, *exc_info) -> None:
|
|
327
|
+
self.close()
|
|
328
|
+
|
|
329
|
+
def close(self) -> None:
|
|
330
|
+
"""Closes every connection, idle or in use. Harmless when called again;
|
|
331
|
+
a later request connects afresh."""
|
|
332
|
+
self._pool.close()
|
|
333
|
+
|
|
334
|
+
def send(
|
|
335
|
+
self,
|
|
336
|
+
method: str,
|
|
337
|
+
url: str,
|
|
338
|
+
*,
|
|
339
|
+
headers: "_ty.Mapping[str, str]",
|
|
340
|
+
body: "bytes | _ty.BinaryIO | _ty.Iterable[bytes] | None" = None,
|
|
341
|
+
timeout: "float | tuple | None",
|
|
342
|
+
stream: bool = False,
|
|
343
|
+
) -> HTTPResponse:
|
|
344
|
+
"""One request, one response.
|
|
345
|
+
|
|
346
|
+
`headers` are sent as given; `Accept-Encoding: gzip, deflate` is added
|
|
347
|
+
(and a coded body decoded) unless they name an `Accept-Encoding`.
|
|
348
|
+
A `body` whose length is known without reading it goes under a
|
|
349
|
+
`Content-Length`, any other chunked; a `Content-Length` in `headers`
|
|
350
|
+
is used as it is. `timeout` is seconds for the connection and for each wait on the
|
|
351
|
+
server: a number, a `(connect, read)` pair, or `None` to wait for ever.
|
|
352
|
+
With `stream=False` the body has been read when this returns (a body
|
|
353
|
+
that decodes to more than `MAX_REPLY_BYTES` is `OSError(EFBIG)`); with
|
|
354
|
+
`stream=True` it is read from the response, which must be closed.
|
|
355
|
+
|
|
356
|
+
Raises `ValueError` before anything is sent for a header or URL with
|
|
357
|
+
a control character, a URL with credentials or another scheme;
|
|
358
|
+
`TimeoutError`, `ConnectionError` and `ssl.SSLError` for the network;
|
|
359
|
+
`OSError(EIO)` for a reply that is not valid HTTP or ends early. The
|
|
360
|
+
route adds `proxylib.NoReachableProxyError`,
|
|
361
|
+
`proxylib.UnsupportedProxyError` and, for a setting that cannot be used,
|
|
362
|
+
`ConfigurationError`."""
|
|
363
|
+
method = check_method(method)
|
|
364
|
+
target = check_url(url)
|
|
365
|
+
pairs = check_headers(headers)
|
|
366
|
+
connect_timeout, read_timeout = check_timeout(timeout)
|
|
367
|
+
payload = _Payload(body, pairs)
|
|
368
|
+
names = {name.lower() for name, _ in pairs}
|
|
369
|
+
decode = wants_decoding(pairs)
|
|
370
|
+
# As the server will see it: no fragment, "/" for an empty path.
|
|
371
|
+
plain_url = f"{target.scheme}://{target.authority}{target.path}"
|
|
372
|
+
scope = _pool.scope_id()
|
|
373
|
+
route = self._route(target, plain_url, connect_timeout, scope)
|
|
374
|
+
response = self._attempt(
|
|
375
|
+
route, method, pairs, names, payload, connect_timeout, read_timeout, plain_url, decode, scope
|
|
376
|
+
)
|
|
377
|
+
return response if stream else response.buffered()
|
|
378
|
+
|
|
379
|
+
def _plan(self, entry: "proxylib.Proxy | None", target: Target, plain_url: str) -> _Route:
|
|
380
|
+
if entry is None:
|
|
381
|
+
return _Route(target, None, None, (target.scheme, target.host, target.port, None))
|
|
382
|
+
if entry.is_gateway:
|
|
383
|
+
wire = check_url(entry.gateway_url(plain_url))
|
|
384
|
+
return _Route(wire, None, entry, ("gateway", wire.scheme, wire.host, wire.port))
|
|
385
|
+
peer = (entry.scheme, entry.host, _route.port(entry))
|
|
386
|
+
return _Route(target, entry, None, (target.scheme, target.host, target.port, peer))
|
|
387
|
+
|
|
388
|
+
def _route(self, target: Target, plain_url: str, connect_timeout, scope: int) -> _Route:
|
|
389
|
+
"""The route of a request: the first entry the proxy map answers whose
|
|
390
|
+
proxy accepts a connection. One this thread already holds a connection
|
|
391
|
+
to is known to answer, and is not probed again."""
|
|
392
|
+
entries = _route.entries(self._proxymap, plain_url)
|
|
393
|
+
first = self._plan(entries[0], target, plain_url)
|
|
394
|
+
if entries[0] is None or self._pool.has_idle(first.key, scope):
|
|
395
|
+
return first
|
|
396
|
+
chosen = _route.reachable(entries, plain_url, connect_timeout)
|
|
397
|
+
return first if chosen == entries[0] else self._plan(chosen, target, plain_url)
|
|
398
|
+
|
|
399
|
+
def _attempt(
|
|
400
|
+
self,
|
|
401
|
+
route: _Route,
|
|
402
|
+
method: str,
|
|
403
|
+
pairs,
|
|
404
|
+
names,
|
|
405
|
+
payload: _Payload,
|
|
406
|
+
connect_timeout,
|
|
407
|
+
read_timeout,
|
|
408
|
+
url: str,
|
|
409
|
+
decode: bool,
|
|
410
|
+
scope: int,
|
|
411
|
+
) -> HTTPResponse:
|
|
412
|
+
repeatable = method in _IDEMPOTENT and payload.repeatable
|
|
413
|
+
connection = self._pool.take(route.key, scope)
|
|
414
|
+
if connection is not None and not repeatable and _now() - connection.released_at > REUSE_WINDOW:
|
|
415
|
+
self._pool.discard(connection)
|
|
416
|
+
connection = None
|
|
417
|
+
if connection is not None and _unusable(connection.http.sock):
|
|
418
|
+
self._pool.discard(connection)
|
|
419
|
+
connection = None
|
|
420
|
+
reused = connection is not None
|
|
421
|
+
while True:
|
|
422
|
+
if connection is None:
|
|
423
|
+
connection = self._open(route, scope, connect_timeout, read_timeout)
|
|
424
|
+
reused = False
|
|
425
|
+
sock = connection.http.sock
|
|
426
|
+
try:
|
|
427
|
+
if sock is None:
|
|
428
|
+
raise ConnectionAbortedError(_errno.ECONNABORTED, "the connection was closed")
|
|
429
|
+
sock.settimeout(read_timeout)
|
|
430
|
+
except OSError:
|
|
431
|
+
# An idle socket that is already closed: nothing was sent.
|
|
432
|
+
self._pool.discard(connection)
|
|
433
|
+
if not reused:
|
|
434
|
+
raise
|
|
435
|
+
connection, reused = None, False
|
|
436
|
+
continue
|
|
437
|
+
try:
|
|
438
|
+
return self._exchange(connection, route, method, pairs, names, payload, url, decode, reused)
|
|
439
|
+
except _Dropped as dropped:
|
|
440
|
+
if reused and repeatable:
|
|
441
|
+
connection, reused = None, False
|
|
442
|
+
continue
|
|
443
|
+
raise dropped.error from None
|
|
444
|
+
|
|
445
|
+
def _headers(self, route: _Route, pairs, names, payload) -> "dict[str, str]":
|
|
446
|
+
sent = dict(pairs)
|
|
447
|
+
if payload.length is not None:
|
|
448
|
+
sent["Content-Length"] = str(payload.length)
|
|
449
|
+
if "host" not in names:
|
|
450
|
+
sent["Host"] = route.wire.authority
|
|
451
|
+
if "accept-encoding" not in names:
|
|
452
|
+
sent["Accept-Encoding"] = "gzip, deflate"
|
|
453
|
+
# What a proxy or gateway is given for itself rides on a request it
|
|
454
|
+
# reads: not on one inside a tunnel.
|
|
455
|
+
owner = route.gateway or (route.proxy if route.wire.scheme == "http" else None)
|
|
456
|
+
if owner is not None:
|
|
457
|
+
for name, value in _route.credentials(owner):
|
|
458
|
+
if name.lower() not in names:
|
|
459
|
+
sent[name] = value
|
|
460
|
+
return sent
|
|
461
|
+
|
|
462
|
+
def _exchange(self, connection, route: _Route, method, pairs, names, payload, url, decode, reused) -> HTTPResponse:
|
|
463
|
+
http = connection.http
|
|
464
|
+
wire = route.wire
|
|
465
|
+
line = f"http://{wire.authority}{wire.path}" if route.proxy is not None and wire.scheme == "http" else wire.path
|
|
466
|
+
try:
|
|
467
|
+
http.request(method, line, body=payload.value, headers=self._headers(route, pairs, names, payload))
|
|
468
|
+
response = http.getresponse()
|
|
469
|
+
except (OSError, _client.HTTPException) as error:
|
|
470
|
+
self._pool.discard(connection)
|
|
471
|
+
if reused and _dropped(error):
|
|
472
|
+
raise _Dropped(_failure(error)) from None
|
|
473
|
+
raise _failure(error) from None
|
|
474
|
+
except BaseException:
|
|
475
|
+
self._pool.discard(connection)
|
|
476
|
+
raise
|
|
477
|
+
headers = response.getheaders()
|
|
478
|
+
body = _Body(self, self._pool, connection, response, response.length)
|
|
479
|
+
return HTTPResponse(response.status, response.reason, headers, url, body, decode=decode)
|
|
480
|
+
|
|
481
|
+
def _open(self, route: _Route, scope: int, connect_timeout, read_timeout):
|
|
482
|
+
proxy, wire = route.proxy, route.wire
|
|
483
|
+
host, port = (proxy.host, _route.port(proxy)) if proxy is not None else (wire.host, wire.port)
|
|
484
|
+
try:
|
|
485
|
+
raw = _socket.create_connection((host, port), timeout=connect_timeout)
|
|
486
|
+
except (OSError, _client.HTTPException) as error:
|
|
487
|
+
raise _failure(error) from None
|
|
488
|
+
sock: _ty.Any = raw
|
|
489
|
+
try:
|
|
490
|
+
raw.setsockopt(_socket.IPPROTO_TCP, _socket.TCP_NODELAY, 1)
|
|
491
|
+
sock = self._layers(raw, route)
|
|
492
|
+
sock.settimeout(read_timeout)
|
|
493
|
+
except (OSError, _client.HTTPException) as error:
|
|
494
|
+
sock.close()
|
|
495
|
+
raise _failure(error) from None
|
|
496
|
+
except BaseException:
|
|
497
|
+
sock.close()
|
|
498
|
+
raise
|
|
499
|
+
http = _client.HTTPConnection(wire.host, wire.port)
|
|
500
|
+
# A connection that was closed stays closed: `http.client` would
|
|
501
|
+
# otherwise reconnect by itself, in plain TCP, outside the TLS session,
|
|
502
|
+
# the proxy and the timeout this request was given.
|
|
503
|
+
http.auto_open = 0
|
|
504
|
+
http.response_class = _Reply
|
|
505
|
+
http.sock = sock
|
|
506
|
+
connection = _pool.Connection(http, route.key, scope, self._pool.generation)
|
|
507
|
+
self._pool.register(connection)
|
|
508
|
+
return connection
|
|
509
|
+
|
|
510
|
+
def _layers(self, raw, route: _Route):
|
|
511
|
+
"""`raw`, connected to the first server of `route`, with the tunnel and
|
|
512
|
+
the TLS the route needs on it. What it made is closed when a step fails."""
|
|
513
|
+
proxy, wire = route.proxy, route.wire
|
|
514
|
+
sock: _ty.Any = raw
|
|
515
|
+
try:
|
|
516
|
+
if proxy is not None and proxy.scheme == "https":
|
|
517
|
+
sock = self._tls.proxy().wrap_socket(raw, server_hostname=proxy.host)
|
|
518
|
+
if proxy is not None and wire.scheme == "https":
|
|
519
|
+
self._tunnel(sock, wire, proxy)
|
|
520
|
+
if proxy.scheme == "https":
|
|
521
|
+
# The origin's TLS cannot be wrapped around another TLS socket.
|
|
522
|
+
sock = TunneledTLS(sock, self._tls.origin(), wire.host)
|
|
523
|
+
else:
|
|
524
|
+
sock = self._tls.origin().wrap_socket(sock, server_hostname=wire.host)
|
|
525
|
+
elif route.gateway is not None:
|
|
526
|
+
if wire.scheme == "https":
|
|
527
|
+
sock = self._tls.proxy().wrap_socket(raw, server_hostname=wire.host)
|
|
528
|
+
elif wire.scheme == "https":
|
|
529
|
+
sock = self._tls.origin().wrap_socket(raw, server_hostname=wire.host)
|
|
530
|
+
return sock
|
|
531
|
+
except BaseException:
|
|
532
|
+
sock.close()
|
|
533
|
+
raise
|
|
534
|
+
|
|
535
|
+
@staticmethod
|
|
536
|
+
def _tunnel(sock, target: Target, proxy: "proxylib.Proxy") -> None:
|
|
537
|
+
"""Asks the proxy to connect `sock` to the server (RFC 9110 9.3.6). Only
|
|
538
|
+
what the proxy is configured with goes in the request."""
|
|
539
|
+
authority = f"{bracket(target.host)}:{target.port}"
|
|
540
|
+
lines = [f"CONNECT {authority} HTTP/1.1", f"Host: {authority}"]
|
|
541
|
+
lines += [f"{name}: {value}" for name, value in _route.credentials(proxy)]
|
|
542
|
+
sock.sendall(("\r\n".join(lines) + "\r\n\r\n").encode("ascii"))
|
|
543
|
+
reply = _client.HTTPResponse(sock, method="CONNECT")
|
|
544
|
+
try:
|
|
545
|
+
reply.begin()
|
|
546
|
+
status = reply.status
|
|
547
|
+
finally:
|
|
548
|
+
reply.close()
|
|
549
|
+
if not 200 <= status < 300:
|
|
550
|
+
raise ConnectionRefusedError(_errno.ECONNREFUSED, f"the proxy refused CONNECT (HTTP {status})")
|