without-http 0.0.2__tar.gz → 0.0.3__tar.gz
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.
- {without_http-0.0.2 → without_http-0.0.3}/PKG-INFO +16 -11
- {without_http-0.0.2 → without_http-0.0.3}/README.md +13 -8
- {without_http-0.0.2 → without_http-0.0.3}/pyproject.toml +3 -3
- {without_http-0.0.2 → without_http-0.0.3}/pyproject.toml.orig +3 -3
- {without_http-0.0.2 → without_http-0.0.3}/src/without_http/__init__.py +8 -2
- {without_http-0.0.2 → without_http-0.0.3}/src/without_http/client.py +173 -98
- {without_http-0.0.2 → without_http-0.0.3}/src/without_http/server.py +14 -9
- without_http-0.0.3/src/without_http/testing.py +587 -0
- {without_http-0.0.2 → without_http-0.0.3}/src/without_http/timeouts.py +5 -3
- {without_http-0.0.2 → without_http-0.0.3}/src/without_http/h11_wire.py +0 -0
- {without_http-0.0.2 → without_http-0.0.3}/src/without_http/h2_wire.py +0 -0
- {without_http-0.0.2 → without_http-0.0.3}/src/without_http/lifespan.py +0 -0
- {without_http-0.0.2 → without_http-0.0.3}/src/without_http/socket_options.py +0 -0
- {without_http-0.0.2 → without_http-0.0.3}/src/without_http/tls.py +0 -0
- {without_http-0.0.2 → without_http-0.0.3}/src/without_http/ws_wire.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: without-http
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.3
|
|
4
4
|
Summary: A sans-IO-backed ASGI server and HTTP client for without: h11/h2/wsproto over asyncio sockets.
|
|
5
5
|
Author: Josh Karpel
|
|
6
6
|
Author-email: Josh Karpel <josh.karpel@gmail.com>
|
|
@@ -16,8 +16,8 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
16
16
|
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
17
17
|
Classifier: Topic :: Software Development :: Libraries
|
|
18
18
|
Classifier: Typing :: Typed
|
|
19
|
-
Requires-Dist: without-core==0.0.
|
|
20
|
-
Requires-Dist: without-asgi==0.0.
|
|
19
|
+
Requires-Dist: without-core==0.0.3
|
|
20
|
+
Requires-Dist: without-asgi==0.0.3
|
|
21
21
|
Requires-Dist: h11>=0.16
|
|
22
22
|
Requires-Dist: h2>=4.1
|
|
23
23
|
Requires-Dist: wsproto>=1.2
|
|
@@ -43,15 +43,15 @@ translate between typed events and the ASGI dicts an app expects.
|
|
|
43
43
|
```python
|
|
44
44
|
from without import sleep_forever
|
|
45
45
|
from without_asgi import make_asgi_app
|
|
46
|
-
from without_http import ConnectionPool, serving
|
|
46
|
+
from without_http import ConnectionPool, request, serving
|
|
47
47
|
|
|
48
48
|
app = make_asgi_app(lifespan, http=router.dispatch, websocket=sockets.dispatch)
|
|
49
49
|
|
|
50
50
|
async with serving(app, host="127.0.0.1", port=8000):
|
|
51
|
-
await sleep_forever()
|
|
51
|
+
await sleep_forever() # run until cancelled
|
|
52
52
|
|
|
53
53
|
async with ConnectionPool() as pool:
|
|
54
|
-
async with
|
|
54
|
+
async with request(pool, "GET", "http://127.0.0.1:8000/items") as (head, body):
|
|
55
55
|
assert head.status == 200
|
|
56
56
|
data = await body.read()
|
|
57
57
|
```
|
|
@@ -59,11 +59,16 @@ async with ConnectionPool() as pool:
|
|
|
59
59
|
Because `without-http` speaks plain ASGI to the app, *any* ASGI app runs over it,
|
|
60
60
|
interchangeably with uvicorn. The server handles TLS, HTTP/2 (by ALPN or prior
|
|
61
61
|
knowledge), keep-alive, WebSockets over the HTTP/1.1 upgrade, per-handler
|
|
62
|
-
isolation, and flow control.
|
|
63
|
-
|
|
64
|
-
opt-in trailers, HTTP/2
|
|
65
|
-
request timeouts, consumer-driven
|
|
66
|
-
HTTP/2), and `stack`-composed middleware.
|
|
62
|
+
isolation, and flow control. A client is a function from a request to a response, and
|
|
63
|
+
a `ConnectionPool` is the one that answers over the network: a `(head, body)` response
|
|
64
|
+
split, buffered and streaming bodies in both directions, opt-in trailers, HTTP/2
|
|
65
|
+
multiplexing, per-host connection bounds, per-phase request timeouts, consumer-driven
|
|
66
|
+
duplex (with bidirectional streaming over HTTP/2), and `stack`-composed middleware.
|
|
67
|
+
|
|
68
|
+
`without_http.testing` carries the same interface into a test: a mock client that answers
|
|
69
|
+
from a function, an ASGI client that drives an app in memory, and a loopback client that
|
|
70
|
+
runs the real wire protocols over no socket at all, plus the raw in-memory endpoints
|
|
71
|
+
those are built from, for a test that writes frames rather than requests.
|
|
67
72
|
|
|
68
73
|
See the
|
|
69
74
|
[`without-http` guide](https://without.help/without-http/)
|
|
@@ -17,15 +17,15 @@ translate between typed events and the ASGI dicts an app expects.
|
|
|
17
17
|
```python
|
|
18
18
|
from without import sleep_forever
|
|
19
19
|
from without_asgi import make_asgi_app
|
|
20
|
-
from without_http import ConnectionPool, serving
|
|
20
|
+
from without_http import ConnectionPool, request, serving
|
|
21
21
|
|
|
22
22
|
app = make_asgi_app(lifespan, http=router.dispatch, websocket=sockets.dispatch)
|
|
23
23
|
|
|
24
24
|
async with serving(app, host="127.0.0.1", port=8000):
|
|
25
|
-
await sleep_forever()
|
|
25
|
+
await sleep_forever() # run until cancelled
|
|
26
26
|
|
|
27
27
|
async with ConnectionPool() as pool:
|
|
28
|
-
async with
|
|
28
|
+
async with request(pool, "GET", "http://127.0.0.1:8000/items") as (head, body):
|
|
29
29
|
assert head.status == 200
|
|
30
30
|
data = await body.read()
|
|
31
31
|
```
|
|
@@ -33,11 +33,16 @@ async with ConnectionPool() as pool:
|
|
|
33
33
|
Because `without-http` speaks plain ASGI to the app, *any* ASGI app runs over it,
|
|
34
34
|
interchangeably with uvicorn. The server handles TLS, HTTP/2 (by ALPN or prior
|
|
35
35
|
knowledge), keep-alive, WebSockets over the HTTP/1.1 upgrade, per-handler
|
|
36
|
-
isolation, and flow control.
|
|
37
|
-
|
|
38
|
-
opt-in trailers, HTTP/2
|
|
39
|
-
request timeouts, consumer-driven
|
|
40
|
-
HTTP/2), and `stack`-composed middleware.
|
|
36
|
+
isolation, and flow control. A client is a function from a request to a response, and
|
|
37
|
+
a `ConnectionPool` is the one that answers over the network: a `(head, body)` response
|
|
38
|
+
split, buffered and streaming bodies in both directions, opt-in trailers, HTTP/2
|
|
39
|
+
multiplexing, per-host connection bounds, per-phase request timeouts, consumer-driven
|
|
40
|
+
duplex (with bidirectional streaming over HTTP/2), and `stack`-composed middleware.
|
|
41
|
+
|
|
42
|
+
`without_http.testing` carries the same interface into a test: a mock client that answers
|
|
43
|
+
from a function, an ASGI client that drives an app in memory, and a loopback client that
|
|
44
|
+
runs the real wire protocols over no socket at all, plus the raw in-memory endpoints
|
|
45
|
+
those are built from, for a test that writes frames rather than requests.
|
|
41
46
|
|
|
42
47
|
See the
|
|
43
48
|
[`without-http` guide](https://without.help/without-http/)
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "without-http"
|
|
7
|
-
version = "0.0.
|
|
7
|
+
version = "0.0.3"
|
|
8
8
|
description = "A sans-IO-backed ASGI server and HTTP client for without: h11/h2/wsproto over asyncio sockets."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -23,8 +23,8 @@ classifiers = [
|
|
|
23
23
|
"Typing :: Typed",
|
|
24
24
|
]
|
|
25
25
|
dependencies = [
|
|
26
|
-
"without-core==0.0.
|
|
27
|
-
"without-asgi==0.0.
|
|
26
|
+
"without-core==0.0.3",
|
|
27
|
+
"without-asgi==0.0.3",
|
|
28
28
|
"h11>=0.16",
|
|
29
29
|
"h2>=4.1",
|
|
30
30
|
"wsproto>=1.2",
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "without-http"
|
|
7
|
-
version = "0.0.
|
|
7
|
+
version = "0.0.3"
|
|
8
8
|
description = "A sans-IO-backed ASGI server and HTTP client for without: h11/h2/wsproto over asyncio sockets."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -26,8 +26,8 @@ classifiers = [
|
|
|
26
26
|
"Typing :: Typed",
|
|
27
27
|
]
|
|
28
28
|
dependencies = [
|
|
29
|
-
"without-core==0.0.
|
|
30
|
-
"without-asgi==0.0.
|
|
29
|
+
"without-core==0.0.3",
|
|
30
|
+
"without-asgi==0.0.3",
|
|
31
31
|
"h11>=0.16",
|
|
32
32
|
"h2>=4.1",
|
|
33
33
|
"wsproto>=1.2",
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
from without_http.client import
|
|
1
|
+
from without_http.client import Client
|
|
2
2
|
from without_http.client import ClientMiddleware
|
|
3
3
|
from without_http.client import ClientRequest
|
|
4
4
|
from without_http.client import ClientResponse
|
|
5
|
+
from without_http.client import Connect
|
|
5
6
|
from without_http.client import ConnectionPool
|
|
6
7
|
from without_http.client import CookieJar
|
|
7
8
|
from without_http.client import ResponseBody
|
|
@@ -9,7 +10,9 @@ from without_http.client import ResponseHead
|
|
|
9
10
|
from without_http.client import ResponseTrailers
|
|
10
11
|
from without_http.client import add_headers
|
|
11
12
|
from without_http.client import cookies
|
|
13
|
+
from without_http.client import deadline
|
|
12
14
|
from without_http.client import follow_redirects
|
|
15
|
+
from without_http.client import request
|
|
13
16
|
from without_http.client import stack
|
|
14
17
|
from without_http.client import wrap
|
|
15
18
|
from without_http.h2_wire import early_hint_headers
|
|
@@ -42,10 +45,11 @@ from without_http.ws_wire import ws_events_from_outbound
|
|
|
42
45
|
|
|
43
46
|
__all__ = [
|
|
44
47
|
"ALPN_PROTOCOLS",
|
|
45
|
-
"
|
|
48
|
+
"Client",
|
|
46
49
|
"ClientMiddleware",
|
|
47
50
|
"ClientRequest",
|
|
48
51
|
"ClientResponse",
|
|
52
|
+
"Connect",
|
|
49
53
|
"ConnectTimeout",
|
|
50
54
|
"ConnectionPool",
|
|
51
55
|
"CookieJar",
|
|
@@ -62,12 +66,14 @@ __all__ = [
|
|
|
62
66
|
"WriteTimeout",
|
|
63
67
|
"add_headers",
|
|
64
68
|
"cookies",
|
|
69
|
+
"deadline",
|
|
65
70
|
"early_hint_headers",
|
|
66
71
|
"follow_redirects",
|
|
67
72
|
"h11_events_from_outbound",
|
|
68
73
|
"inbound_from_event",
|
|
69
74
|
"is_websocket_upgrade",
|
|
70
75
|
"receive_buffer_size",
|
|
76
|
+
"request",
|
|
71
77
|
"request_headers",
|
|
72
78
|
"response_headers",
|
|
73
79
|
"response_status_and_headers",
|
|
@@ -16,6 +16,7 @@ from datetime import UTC
|
|
|
16
16
|
from datetime import datetime
|
|
17
17
|
from email.utils import parsedate_to_datetime
|
|
18
18
|
from typing import NamedTuple
|
|
19
|
+
from typing import Protocol
|
|
19
20
|
from typing import Self
|
|
20
21
|
from urllib.parse import SplitResult
|
|
21
22
|
from urllib.parse import urljoin
|
|
@@ -30,7 +31,9 @@ from without import Endo
|
|
|
30
31
|
from without import Stream
|
|
31
32
|
from without import cancel_futures
|
|
32
33
|
from without import stack
|
|
34
|
+
from without_asgi import Content
|
|
33
35
|
from without_asgi import RawHeaders
|
|
36
|
+
from without_asgi.headers import merge
|
|
34
37
|
|
|
35
38
|
from without_http.h2_wire import request_headers
|
|
36
39
|
from without_http.h2_wire import response_status_and_headers
|
|
@@ -86,18 +89,26 @@ class Origin:
|
|
|
86
89
|
@dataclass(frozen=True, slots=True)
|
|
87
90
|
class ClientRequest:
|
|
88
91
|
"""
|
|
89
|
-
A client request as a value: the head
|
|
92
|
+
A client request as a value: the head, a streaming body, and its deadline.
|
|
90
93
|
|
|
91
94
|
The body is a `Stream[bytes]` (an async iterable of chunks), so a request can be
|
|
92
95
|
buffered (one chunk) or streamed (many), the upload half of the buffered/streaming
|
|
93
|
-
matrix. Because the whole request is the value a `
|
|
94
|
-
|
|
96
|
+
matrix. Because the whole request is the value a `Client` transforms, middleware can
|
|
97
|
+
rewrite it: add headers, change the URL, wrap the body, extend the deadline.
|
|
98
|
+
|
|
99
|
+
`timeout` bounds each phase of *this* request (see `Timeout`), and defaults to no
|
|
100
|
+
bounds at all. It rides on the request rather than on the transport because a
|
|
101
|
+
deadline is the caller's policy, not the connection's: it is the caller's time
|
|
102
|
+
budget that decides when slow progress is worse than failure. Carrying it here is
|
|
103
|
+
what lets a `Client` be a plain one-argument function, and what lets middleware
|
|
104
|
+
(`deadline`, or a retry that shortens each attempt) set it like any other field.
|
|
95
105
|
"""
|
|
96
106
|
|
|
97
107
|
method: str
|
|
98
108
|
url: str
|
|
99
109
|
headers: RawHeaders = ()
|
|
100
110
|
body: Stream[bytes] = field(default_factory=_empty_body)
|
|
111
|
+
timeout: Timeout = _NO_TIMEOUT
|
|
101
112
|
|
|
102
113
|
|
|
103
114
|
@dataclass(frozen=True, slots=True)
|
|
@@ -185,16 +196,16 @@ class ClientResponse(NamedTuple):
|
|
|
185
196
|
A client response as a value: the head paired with the body.
|
|
186
197
|
|
|
187
198
|
`head` is the parsed `ResponseHead` (status + headers), available the instant
|
|
188
|
-
`await
|
|
199
|
+
`await client(request)` returns. `body` is a `ResponseBody`, a once-consumable
|
|
189
200
|
stream that releases its connection when it ends or is closed.
|
|
190
201
|
|
|
191
202
|
A `NamedTuple` so a caller can take it whole (`response.head`, `response.body`) or
|
|
192
203
|
unpack it (`head, body = response`) with each field keeping its precise type, which a
|
|
193
204
|
`__iter__` on a dataclass could not give. The two halves are independent, the
|
|
194
205
|
consumer split that mirrors how a server consumes a request (a `scope` value plus a
|
|
195
|
-
body stream): branch on `head` without touching `body`. `
|
|
196
|
-
|
|
197
|
-
|
|
206
|
+
body stream): branch on `head` without touching `body`. `request` yields this value
|
|
207
|
+
and closes `body` on exit; it is also what a `ClientMiddleware` rewrites (by
|
|
208
|
+
constructing a new one, since a `NamedTuple` has no `dataclasses.replace`).
|
|
198
209
|
"""
|
|
199
210
|
|
|
200
211
|
head: ResponseHead
|
|
@@ -242,17 +253,21 @@ async def _releasing(
|
|
|
242
253
|
return armed
|
|
243
254
|
|
|
244
255
|
|
|
245
|
-
# A client
|
|
246
|
-
# a response over streams,
|
|
247
|
-
# `ClientResponse`.
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
#
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
# `
|
|
254
|
-
|
|
255
|
-
|
|
256
|
+
# A client *is* a function from a request to a response, the dual of a server handler:
|
|
257
|
+
# where a handler maps a request to a response over streams, a client maps a whole
|
|
258
|
+
# `ClientRequest` to a `ClientResponse`. Everything that answers a request is one, and
|
|
259
|
+
# they are interchangeable by construction: a `ConnectionPool` over the network, a
|
|
260
|
+
# canned response table in a test, an ASGI app driven in memory.
|
|
261
|
+
#
|
|
262
|
+
# A `ClientMiddleware` wraps a client into a client (`Endo`): it can rewrite the request
|
|
263
|
+
# before, or the response after, the inner client runs. This is the zero-context case of
|
|
264
|
+
# the shared `stack` vocabulary: a server middleware is `(handler, state, scope)`, a
|
|
265
|
+
# client one needs no context (the request is the value it transforms, not a fixed
|
|
266
|
+
# scope), so it is simply `(client) -> client`, and the same `stack` composes them.
|
|
267
|
+
# State a middleware must keep lives in a closure (see `cookies`), as it does
|
|
268
|
+
# server-side.
|
|
269
|
+
type Client = Callable[[ClientRequest], Awaitable[ClientResponse]]
|
|
270
|
+
type ClientMiddleware = Endo[Client]
|
|
256
271
|
|
|
257
272
|
_PASSTHROUGH: ClientMiddleware = stack()
|
|
258
273
|
|
|
@@ -275,23 +290,34 @@ def _target(parts: SplitResult) -> str:
|
|
|
275
290
|
return target
|
|
276
291
|
|
|
277
292
|
|
|
278
|
-
def _build_request(
|
|
293
|
+
def _build_request(
|
|
294
|
+
method: str, url: str, headers: RawHeaders, content: bytes | Stream[bytes] | Content, timeout: Timeout
|
|
295
|
+
) -> ClientRequest:
|
|
279
296
|
"""
|
|
280
297
|
Assemble a `ClientRequest`, picking the body framing from `content`.
|
|
281
298
|
|
|
282
299
|
Buffered `bytes` get a `content-length`; a streaming body whose length is unknown
|
|
283
300
|
gets `transfer-encoding: chunked` (HTTP/1.1 frames it as chunks; over HTTP/2 the
|
|
284
301
|
framing headers are dropped and the body rides DATA frames either way).
|
|
302
|
+
|
|
303
|
+
A `Content` is bytes that already know what they are, so its headers go *under* the
|
|
304
|
+
caller's: an explicit `content-type` on the request wins over the one the encoding
|
|
305
|
+
supplied, and everything after this point sees plain bytes.
|
|
285
306
|
"""
|
|
307
|
+
if isinstance(content, Content):
|
|
308
|
+
headers = merge(content.headers, headers)
|
|
309
|
+
content = content.body
|
|
286
310
|
if isinstance(content, bytes):
|
|
287
311
|
if not content:
|
|
288
|
-
return ClientRequest(
|
|
312
|
+
return ClientRequest(
|
|
313
|
+
method, url, headers, _empty_body(), timeout
|
|
314
|
+
) # pragma: no mutate - equals _empty_body()
|
|
289
315
|
if not _has(headers, b"content-length"):
|
|
290
316
|
headers = (*headers, (b"content-length", str(len(content)).encode(_ASCII)))
|
|
291
|
-
return ClientRequest(method, url, headers, _single(content))
|
|
317
|
+
return ClientRequest(method, url, headers, _single(content), timeout)
|
|
292
318
|
if not _has(headers, b"content-length") and not _has(headers, b"transfer-encoding"):
|
|
293
319
|
headers = (*headers, (b"transfer-encoding", b"chunked"))
|
|
294
|
-
return ClientRequest(method, url, headers, content)
|
|
320
|
+
return ClientRequest(method, url, headers, content, timeout)
|
|
295
321
|
|
|
296
322
|
|
|
297
323
|
_ALPN_H2 = ("h2", "http/1.1")
|
|
@@ -329,6 +355,28 @@ async def _open(
|
|
|
329
355
|
return reader, writer, "h2" if negotiated == "h2" else "http/1.1"
|
|
330
356
|
|
|
331
357
|
|
|
358
|
+
class Connect(Protocol):
|
|
359
|
+
"""
|
|
360
|
+
How a pool reaches an origin: the one step that touches the network.
|
|
361
|
+
|
|
362
|
+
Injected into `ConnectionPool` so the rest of it (reuse, bounds, protocol selection)
|
|
363
|
+
stays independent of how a connection is made. `_open` is the default and speaks
|
|
364
|
+
TCP; a test dials an in-memory pipe, and a unix-socket or proxy connector would slot
|
|
365
|
+
in the same way. The negotiated wire protocol comes back alongside the streams
|
|
366
|
+
because only the connector can know it: ALPN is read off the finished handshake.
|
|
367
|
+
"""
|
|
368
|
+
|
|
369
|
+
async def __call__(
|
|
370
|
+
self,
|
|
371
|
+
host: str,
|
|
372
|
+
port: int,
|
|
373
|
+
*,
|
|
374
|
+
ssl_context: ssl.SSLContext | None,
|
|
375
|
+
timeout: Timeout = ...,
|
|
376
|
+
socket_options: SocketOptions = (),
|
|
377
|
+
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter, str]: ...
|
|
378
|
+
|
|
379
|
+
|
|
332
380
|
@dataclass(slots=True, eq=False)
|
|
333
381
|
class _Http11Connection:
|
|
334
382
|
"""
|
|
@@ -933,12 +981,16 @@ class _HostPool:
|
|
|
933
981
|
@dataclass(slots=True)
|
|
934
982
|
class ConnectionPool:
|
|
935
983
|
"""
|
|
936
|
-
Connections keyed by origin
|
|
984
|
+
Connections keyed by origin: the `Client` that answers a request over the network.
|
|
985
|
+
|
|
986
|
+
Calling it *is* the request (`await pool(request)`), so a pool is interchangeable
|
|
987
|
+
with any other `Client` and composes with `ClientMiddleware` through `stack`. Most
|
|
988
|
+
callers go through the `request` context manager rather than calling it directly,
|
|
989
|
+
since that builds the `ClientRequest` and closes the response body for them.
|
|
937
990
|
|
|
938
991
|
Open it as an async context manager (`async with ConnectionPool(...) as pool`) so
|
|
939
992
|
its connections are closed on exit; a directly-constructed pool works for
|
|
940
993
|
short-lived use but does not manage the long-lived connections keep-alive retains.
|
|
941
|
-
Make requests through `async with pool.request(...) as response`.
|
|
942
994
|
|
|
943
995
|
HTTP/2 connections are kept and reused: many concurrent requests to one origin
|
|
944
996
|
multiplex over a single connection, which is the point of h2. HTTP/1.1
|
|
@@ -957,13 +1009,14 @@ class ConnectionPool:
|
|
|
957
1009
|
live context, precisely because ALPN can only be set context-wide: a shared context
|
|
958
1010
|
would be mutated out from under other pools or libraries using it.
|
|
959
1011
|
|
|
960
|
-
`
|
|
961
|
-
`
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1012
|
+
`connect` is how the pool reaches an origin, defaulting to a TCP connect (see
|
|
1013
|
+
`Connect`). It is the only part of the pool that touches the network, so replacing
|
|
1014
|
+
it points the same pooling, protocol selection, and keep-alive at somewhere else.
|
|
1015
|
+
|
|
1016
|
+
Decoration (default headers, redirect following, cookies, a deadline) is *not* a
|
|
1017
|
+
pool concern: compose it around the pool with `stack`, which yields another `Client`.
|
|
1018
|
+
That keeps connection reuse (a transport concern) and application identity
|
|
1019
|
+
independent, rather than both hiding in the pool.
|
|
967
1020
|
|
|
968
1021
|
`max_connections_per_host` bounds the number of concurrent HTTP/1.1 connections to
|
|
969
1022
|
one origin: at the bound, a checkout *waits* for one to be returned (the wait a
|
|
@@ -980,9 +1033,9 @@ class ConnectionPool:
|
|
|
980
1033
|
connections cannot outnumber concurrent checkouts. Both knobs, when set, MUST be `>=
|
|
981
1034
|
1`.
|
|
982
1035
|
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1036
|
+
Deadlines ride on the request (`ClientRequest.timeout`), not on the pool: the same
|
|
1037
|
+
pool serves callers with different time budgets, and a bound stored here would be a
|
|
1038
|
+
property of the connection rather than of the caller that wanted it.
|
|
986
1039
|
|
|
987
1040
|
`socket_options` is applied to every socket the pool opens, as `(level, option, value)`
|
|
988
1041
|
triples. Build it by concatenating the pure producers in `without_http.socket_options`
|
|
@@ -996,10 +1049,9 @@ class ConnectionPool:
|
|
|
996
1049
|
allow_http2: bool = True
|
|
997
1050
|
force_http2_cleartext: bool = False
|
|
998
1051
|
ssl_context_factory: Callable[[], ssl.SSLContext] = ssl.create_default_context
|
|
999
|
-
|
|
1052
|
+
connect: Connect = _open
|
|
1000
1053
|
max_connections_per_host: int | None = None
|
|
1001
1054
|
max_keepalive_per_host: int | None = None
|
|
1002
|
-
timeout: Timeout = _NO_TIMEOUT
|
|
1003
1055
|
socket_options: SocketOptions = _DEFAULT_SOCKET_OPTIONS
|
|
1004
1056
|
_h2: dict[Origin, _Http2Connection] = field(default_factory=dict)
|
|
1005
1057
|
_h11: dict[Origin, _HostPool] = field(default_factory=dict)
|
|
@@ -1021,61 +1073,16 @@ class ConnectionPool:
|
|
|
1021
1073
|
async def __aexit__(self, *exc: object) -> None:
|
|
1022
1074
|
await self.aclose()
|
|
1023
1075
|
|
|
1024
|
-
|
|
1025
|
-
async def request(
|
|
1026
|
-
self,
|
|
1027
|
-
method: str,
|
|
1028
|
-
url: str,
|
|
1029
|
-
*,
|
|
1030
|
-
headers: RawHeaders = (),
|
|
1031
|
-
body: bytes | Stream[bytes] = b"",
|
|
1032
|
-
middleware: ClientMiddleware = _PASSTHROUGH,
|
|
1033
|
-
timeout: Timeout | None = None,
|
|
1034
|
-
) -> AsyncIterator[ClientResponse]:
|
|
1035
|
-
"""
|
|
1036
|
-
Send a request and yield its `ClientResponse` for the block, then release the connection.
|
|
1037
|
-
|
|
1038
|
-
`body` is the request body: `bytes` to buffer it, or a `Stream[bytes]` to stream
|
|
1039
|
-
it. The yielded `ClientResponse` can be taken whole (`response.head`,
|
|
1040
|
-
`response.body`) or unpacked (`head, body = ...`); read the response body with
|
|
1041
|
-
`async for chunk in body` / `await body.read()`, or `body.read_with_trailers()`
|
|
1042
|
-
when the endpoint carries trailers. On exit any unread body is drained or aborted
|
|
1043
|
-
so the connection is never stranded.
|
|
1044
|
-
|
|
1045
|
-
`middleware` is composed inside the pool's own `middleware`, so a single request
|
|
1046
|
-
can add decoration (a `CookieJar` via `cookies`, an extra header) on top of the
|
|
1047
|
-
pool-wide stack for this call alone.
|
|
1048
|
-
|
|
1049
|
-
`timeout` replaces the pool's own `timeout` for this call (`None` inherits it).
|
|
1050
|
-
It is captured as a value in the transport exchange, so it does not compose through
|
|
1051
|
-
`middleware` the way decoration does; a deadline overrides, it does not layer.
|
|
1076
|
+
async def __call__(self, request: ClientRequest) -> ClientResponse:
|
|
1052
1077
|
"""
|
|
1053
|
-
|
|
1054
|
-
effective = self.timeout if timeout is None else timeout
|
|
1055
|
-
|
|
1056
|
-
async def bound(outgoing: ClientRequest) -> ClientResponse:
|
|
1057
|
-
return await self._exchange(outgoing, effective)
|
|
1078
|
+
Answer one request over the network, which is what makes a pool a `Client`.
|
|
1058
1079
|
|
|
1059
|
-
|
|
1060
|
-
response
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
# but suppress any close error (e.g. a surfaced request-body send failure) so it
|
|
1066
|
-
# cannot mask the original exception the caller is trying to debug.
|
|
1067
|
-
with suppress(Exception):
|
|
1068
|
-
await response.body.aclose()
|
|
1069
|
-
raise
|
|
1070
|
-
else:
|
|
1071
|
-
await response.body.aclose()
|
|
1072
|
-
|
|
1073
|
-
async def _exchange(self, request: ClientRequest, timeout: Timeout) -> ClientResponse:
|
|
1074
|
-
# The bare transport exchange: the inner `ClientExchange` that `request` wraps
|
|
1075
|
-
# with `stack(self.middleware, ...)`. Private so callers go through `request`
|
|
1076
|
-
# and never accidentally bypass the pool's configured middleware. The effective
|
|
1077
|
-
# `timeout` is threaded in as a value (captured by `bound` above), never stored on
|
|
1078
|
-
# the shared connection objects, so concurrent requests keep their own deadlines.
|
|
1080
|
+
Picks the origin's wire protocol, checks out (or opens) a connection, and returns
|
|
1081
|
+
the response as soon as its head arrives, with the body still streaming. The
|
|
1082
|
+
request's own `timeout` is threaded through as a value, never stored on the
|
|
1083
|
+
shared connection objects, so concurrent requests keep their own deadlines.
|
|
1084
|
+
"""
|
|
1085
|
+
timeout = request.timeout
|
|
1079
1086
|
parts = urlsplit(request.url)
|
|
1080
1087
|
origin = _origin(parts)
|
|
1081
1088
|
if origin.secure and self.allow_http2:
|
|
@@ -1110,7 +1117,7 @@ class ConnectionPool:
|
|
|
1110
1117
|
async with self._lock_for(origin):
|
|
1111
1118
|
connection = self._reusable_h2(origin)
|
|
1112
1119
|
if connection is None and origin not in self._h11_only:
|
|
1113
|
-
reader, writer, protocol = await
|
|
1120
|
+
reader, writer, protocol = await self.connect(
|
|
1114
1121
|
origin.host,
|
|
1115
1122
|
origin.port,
|
|
1116
1123
|
ssl_context=self._context_for_connection(http2=True),
|
|
@@ -1140,7 +1147,7 @@ class ConnectionPool:
|
|
|
1140
1147
|
async with self._lock_for(origin):
|
|
1141
1148
|
connection = self._reusable_h2(origin)
|
|
1142
1149
|
if connection is None:
|
|
1143
|
-
reader, writer, _ = await
|
|
1150
|
+
reader, writer, _ = await self.connect(
|
|
1144
1151
|
origin.host,
|
|
1145
1152
|
origin.port,
|
|
1146
1153
|
ssl_context=None,
|
|
@@ -1233,7 +1240,7 @@ class ConnectionPool:
|
|
|
1233
1240
|
|
|
1234
1241
|
async def _open_h11(self, origin: Origin, timeout: Timeout) -> _Http11Connection:
|
|
1235
1242
|
ssl_context = self._context_for_connection(http2=False) if origin.secure else None
|
|
1236
|
-
reader, writer, _ = await
|
|
1243
|
+
reader, writer, _ = await self.connect(
|
|
1237
1244
|
origin.host, origin.port, ssl_context=ssl_context, timeout=timeout, socket_options=self.socket_options
|
|
1238
1245
|
)
|
|
1239
1246
|
return _Http11Connection.new(reader, writer)
|
|
@@ -1263,6 +1270,53 @@ class ConnectionPool:
|
|
|
1263
1270
|
await host_pool.aclose()
|
|
1264
1271
|
|
|
1265
1272
|
|
|
1273
|
+
@asynccontextmanager
|
|
1274
|
+
async def request(
|
|
1275
|
+
client: Client,
|
|
1276
|
+
method: str,
|
|
1277
|
+
url: str,
|
|
1278
|
+
*,
|
|
1279
|
+
headers: RawHeaders = (),
|
|
1280
|
+
body: bytes | Stream[bytes] | Content = b"",
|
|
1281
|
+
timeout: Timeout = _NO_TIMEOUT,
|
|
1282
|
+
) -> AsyncIterator[ClientResponse]:
|
|
1283
|
+
"""
|
|
1284
|
+
Send a request through `client` and yield its `ClientResponse` for the block.
|
|
1285
|
+
|
|
1286
|
+
The one request surface, over *any* `Client`: a `ConnectionPool`, a pool wrapped in
|
|
1287
|
+
middleware (`stack(add_headers(...), cookies(jar))(pool)`), or an in-memory one from
|
|
1288
|
+
`without_http.testing`. It owns the two things a caller would otherwise repeat: the
|
|
1289
|
+
body framing (`bytes` gets a `content-length`, a `Stream[bytes]` gets
|
|
1290
|
+
`transfer-encoding: chunked`) and closing the response body on the way out, so a
|
|
1291
|
+
connection is never stranded by a body nobody read.
|
|
1292
|
+
|
|
1293
|
+
`body` takes bytes, a `Stream[bytes]` to stream them, or a `Content` when the caller
|
|
1294
|
+
holds a *value* rather than bytes: `body=json_content(order)` sends the encoding and
|
|
1295
|
+
the `content-type` describing it together, since neither is any use without the other.
|
|
1296
|
+
|
|
1297
|
+
The yielded `ClientResponse` can be taken whole (`response.head`, `response.body`) or
|
|
1298
|
+
unpacked (`head, body = ...`); read the response body with `async for chunk in body`
|
|
1299
|
+
/ `await body.read()`, or `body.read_with_trailers()` when the endpoint carries
|
|
1300
|
+
trailers. On exit any unread body is drained or aborted.
|
|
1301
|
+
|
|
1302
|
+
`timeout` bounds this request's phases (see `Timeout`), defaulting to no bounds. It
|
|
1303
|
+
lands on the `ClientRequest`, so middleware sees and can rewrite it like any other
|
|
1304
|
+
field; `deadline` sets the same field for every request through a client.
|
|
1305
|
+
"""
|
|
1306
|
+
response = await client(_build_request(method, url, headers, body, timeout))
|
|
1307
|
+
try:
|
|
1308
|
+
yield response
|
|
1309
|
+
except BaseException:
|
|
1310
|
+
# An error is already in flight; still close the body to release the connection,
|
|
1311
|
+
# but suppress any close error (e.g. a surfaced request-body send failure) so it
|
|
1312
|
+
# cannot mask the original exception the caller is trying to debug.
|
|
1313
|
+
with suppress(Exception):
|
|
1314
|
+
await response.body.aclose()
|
|
1315
|
+
raise
|
|
1316
|
+
else:
|
|
1317
|
+
await response.body.aclose()
|
|
1318
|
+
|
|
1319
|
+
|
|
1266
1320
|
def wrap(
|
|
1267
1321
|
*,
|
|
1268
1322
|
request: Endo[ClientRequest] | None = None,
|
|
@@ -1280,10 +1334,10 @@ def wrap(
|
|
|
1280
1334
|
This is the easy path for the *independent* before/after case (the dual of why
|
|
1281
1335
|
`add_headers`, below, is a one-liner over it). A middleware whose two sides share
|
|
1282
1336
|
state, like `cookies` needing the request URL when it stores the response, or that
|
|
1283
|
-
loops, like `follow_redirects`, is written directly as a `
|
|
1337
|
+
loops, like `follow_redirects`, is written directly as a `Client` wrapper.
|
|
1284
1338
|
"""
|
|
1285
1339
|
|
|
1286
|
-
def middleware(inner:
|
|
1340
|
+
def middleware(inner: Client) -> Client:
|
|
1287
1341
|
async def exchange(outgoing: ClientRequest) -> ClientResponse:
|
|
1288
1342
|
if request is not None:
|
|
1289
1343
|
outgoing = request(outgoing)
|
|
@@ -1302,14 +1356,35 @@ def add_headers(*headers: tuple[bytes, bytes]) -> ClientMiddleware:
|
|
|
1302
1356
|
Client middleware that adds headers to every request.
|
|
1303
1357
|
|
|
1304
1358
|
The mirror of a server's request-decorating middleware: it sits in the same
|
|
1305
|
-
`stack` and rewrites the request before the inner
|
|
1306
|
-
|
|
1359
|
+
`stack` and rewrites the request before the inner client runs. This is how a
|
|
1360
|
+
caller sends default headers (auth tokens, a user agent) on every request, or a
|
|
1307
1361
|
single request adds its own.
|
|
1308
1362
|
"""
|
|
1309
1363
|
extra: RawHeaders = tuple(headers)
|
|
1310
1364
|
return wrap(request=lambda request: replace(request, headers=extra + request.headers))
|
|
1311
1365
|
|
|
1312
1366
|
|
|
1367
|
+
def deadline(timeout: Timeout) -> ClientMiddleware:
|
|
1368
|
+
"""
|
|
1369
|
+
Client middleware that applies `timeout` to every request that bounds nothing itself.
|
|
1370
|
+
|
|
1371
|
+
A default time budget for everything sent through the composed client, in the same
|
|
1372
|
+
`stack` as any other decoration. A request that bounds any phase of its own keeps its
|
|
1373
|
+
own `timeout` whole, so a caller with a tighter budget for one call is not overridden
|
|
1374
|
+
by the default; that is the difference between a default and a policy imposed from
|
|
1375
|
+
above. A request bounding *nothing* (the default `Timeout()`) reads as "no budget
|
|
1376
|
+
stated" rather than "no budget wanted", so it takes the default: a caller who wants
|
|
1377
|
+
one request exempt composes it against a client without this middleware.
|
|
1378
|
+
"""
|
|
1379
|
+
|
|
1380
|
+
def apply(request: ClientRequest) -> ClientRequest:
|
|
1381
|
+
if request.timeout != _NO_TIMEOUT:
|
|
1382
|
+
return request
|
|
1383
|
+
return replace(request, timeout=timeout)
|
|
1384
|
+
|
|
1385
|
+
return wrap(request=apply)
|
|
1386
|
+
|
|
1387
|
+
|
|
1313
1388
|
_SENSITIVE_REDIRECT_HEADERS = frozenset({b"authorization", b"cookie", b"proxy-authorization"})
|
|
1314
1389
|
_BODY_FRAMING_HEADERS = frozenset({b"content-length", b"content-type", b"transfer-encoding"})
|
|
1315
1390
|
|
|
@@ -1334,7 +1409,7 @@ def follow_redirects(max_hops: int = 5) -> ClientMiddleware:
|
|
|
1334
1409
|
unfollowed) so nothing is replayed over cleartext.
|
|
1335
1410
|
"""
|
|
1336
1411
|
|
|
1337
|
-
def middleware(inner:
|
|
1412
|
+
def middleware(inner: Client) -> Client:
|
|
1338
1413
|
async def exchange(request: ClientRequest) -> ClientResponse:
|
|
1339
1414
|
response = await inner(request)
|
|
1340
1415
|
for _ in range(max_hops):
|
|
@@ -1622,7 +1697,7 @@ def cookies(jar: CookieJar) -> ClientMiddleware:
|
|
|
1622
1697
|
the hop sets.
|
|
1623
1698
|
"""
|
|
1624
1699
|
|
|
1625
|
-
def middleware(inner:
|
|
1700
|
+
def middleware(inner: Client) -> Client:
|
|
1626
1701
|
async def exchange(request: ClientRequest) -> ClientResponse:
|
|
1627
1702
|
header = jar.header_for(request.url)
|
|
1628
1703
|
if header is not None:
|
|
@@ -86,13 +86,18 @@ class _Limits:
|
|
|
86
86
|
Bundled into one value so `serving` threads a single argument down through the
|
|
87
87
|
protocol handlers, and a new bound snaps in as a field rather than another
|
|
88
88
|
parameter on every signature. `serving` exposes each field as an explicit keyword
|
|
89
|
-
argument, keeping the public surface flat while the plumbing stays terse.
|
|
89
|
+
argument, keeping the public surface flat while the plumbing stays terse. The
|
|
90
|
+
defaults live here, and the exposing signatures (`serving`, `loopback_client`,
|
|
91
|
+
`served_pipe`) read them off `_DEFAULT_LIMITS`, so the three cannot disagree.
|
|
90
92
|
"""
|
|
91
93
|
|
|
92
|
-
max_concurrent_streams: int
|
|
93
|
-
max_stream_resets: int
|
|
94
|
-
idle_timeout: timedelta | None
|
|
95
|
-
max_websocket_message_bytes: int | None
|
|
94
|
+
max_concurrent_streams: int = 100
|
|
95
|
+
max_stream_resets: int = 200
|
|
96
|
+
idle_timeout: timedelta | None = None
|
|
97
|
+
max_websocket_message_bytes: int | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_DEFAULT_LIMITS = _Limits()
|
|
96
101
|
|
|
97
102
|
|
|
98
103
|
def _address(info: object) -> tuple[str, int] | None:
|
|
@@ -845,10 +850,10 @@ async def serving(
|
|
|
845
850
|
host: str = "127.0.0.1",
|
|
846
851
|
port: int = 0,
|
|
847
852
|
max_pending_connections: int = 100,
|
|
848
|
-
max_concurrent_streams: int =
|
|
849
|
-
max_stream_resets: int =
|
|
850
|
-
idle_timeout: timedelta | None =
|
|
851
|
-
max_websocket_message_bytes: int | None =
|
|
853
|
+
max_concurrent_streams: int = _DEFAULT_LIMITS.max_concurrent_streams,
|
|
854
|
+
max_stream_resets: int = _DEFAULT_LIMITS.max_stream_resets,
|
|
855
|
+
idle_timeout: timedelta | None = _DEFAULT_LIMITS.idle_timeout,
|
|
856
|
+
max_websocket_message_bytes: int | None = _DEFAULT_LIMITS.max_websocket_message_bytes,
|
|
852
857
|
ssl_context: ssl.SSLContext | None = None,
|
|
853
858
|
ssl_handshake_timeout: float | None = None,
|
|
854
859
|
ssl_shutdown_timeout: float | None = None,
|
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import ssl
|
|
5
|
+
from collections.abc import AsyncGenerator
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from collections.abc import Awaitable
|
|
8
|
+
from collections.abc import Buffer
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from dataclasses import replace
|
|
13
|
+
from datetime import timedelta
|
|
14
|
+
from types import MappingProxyType
|
|
15
|
+
from typing import assert_never
|
|
16
|
+
from urllib.parse import unquote
|
|
17
|
+
from urllib.parse import urlsplit
|
|
18
|
+
|
|
19
|
+
from without import cancel_futures
|
|
20
|
+
from without_asgi import Asgi
|
|
21
|
+
from without_asgi import ASGIApp
|
|
22
|
+
from without_asgi import Disconnect
|
|
23
|
+
from without_asgi import EarlyHint
|
|
24
|
+
from without_asgi import HttpScope
|
|
25
|
+
from without_asgi import PathSend
|
|
26
|
+
from without_asgi import RawHeaders
|
|
27
|
+
from without_asgi import RawMessage
|
|
28
|
+
from without_asgi import RequestBody
|
|
29
|
+
from without_asgi import ResponseDebug
|
|
30
|
+
from without_asgi import ResponseStart
|
|
31
|
+
from without_asgi import ServerPush
|
|
32
|
+
from without_asgi import ZeroCopySend
|
|
33
|
+
from without_asgi import encode_http_scope
|
|
34
|
+
from without_asgi import encode_inbound
|
|
35
|
+
from without_asgi import parse_outbound
|
|
36
|
+
from without_asgi.outbound import ResponseBody as OutboundBody
|
|
37
|
+
from without_asgi.outbound import ResponseTrailers as OutboundTrailers
|
|
38
|
+
|
|
39
|
+
from without_http.client import _NO_TIMEOUT
|
|
40
|
+
from without_http.client import Client
|
|
41
|
+
from without_http.client import ClientMiddleware
|
|
42
|
+
from without_http.client import ClientRequest
|
|
43
|
+
from without_http.client import ClientResponse
|
|
44
|
+
from without_http.client import ConnectionPool
|
|
45
|
+
from without_http.client import ResponseBody
|
|
46
|
+
from without_http.client import ResponseHead
|
|
47
|
+
from without_http.client import ResponseTrailers
|
|
48
|
+
from without_http.client import _releasing
|
|
49
|
+
from without_http.client import wrap
|
|
50
|
+
from without_http.lifespan import _wait_for
|
|
51
|
+
from without_http.lifespan import run_lifespan
|
|
52
|
+
from without_http.server import _DEFAULT_LIMITS
|
|
53
|
+
from without_http.server import _Limits
|
|
54
|
+
from without_http.server import _serve_connection
|
|
55
|
+
from without_http.socket_options import SocketOptions
|
|
56
|
+
from without_http.timeouts import Timeout
|
|
57
|
+
|
|
58
|
+
# What an in-memory client tells an app about itself. It presents as HTTP/1.1, so an app
|
|
59
|
+
# that runs against it faces the same surface it would over a socket.
|
|
60
|
+
_ASGI = Asgi(version="3.0", spec_version="2.4")
|
|
61
|
+
|
|
62
|
+
# The one extension this transport can honestly offer: a `ClientResponse` carries trailer
|
|
63
|
+
# blocks through to `read_with_trailers`, so an app's trailers reach the caller here
|
|
64
|
+
# rather than being dropped. The server-offload extensions (server push, zero-copy and
|
|
65
|
+
# path send) have a kernel or a proxy to offload to and nothing in memory does, so they
|
|
66
|
+
# stay unadvertised, as they are over HTTP/1.1.
|
|
67
|
+
_EXTENSIONS: Mapping[str, Mapping[str, object]] = MappingProxyType({"http.response.trailers": {}})
|
|
68
|
+
_ASCII = "ascii"
|
|
69
|
+
_CLIENT_ADDRESS = ("127.0.0.1", 51234)
|
|
70
|
+
_BUFFER = 65536
|
|
71
|
+
|
|
72
|
+
# The address an in-memory server presents as, which nothing resolves: a `pipe` has no
|
|
73
|
+
# port to report, so the server reads this back from `sockname` and a client names it in
|
|
74
|
+
# the URL (or, over HTTP/2, in `:authority`). Public because a test that writes frames by
|
|
75
|
+
# hand has to spell the authority itself, which is what `AUTHORITY` spells for it.
|
|
76
|
+
SERVER_ADDRESS = ("testserver", 80)
|
|
77
|
+
AUTHORITY = f"{SERVER_ADDRESS[0]}:{SERVER_ADDRESS[1]}".encode()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def mock_client(handler: Callable[[ClientRequest], ClientResponse | Awaitable[ClientResponse]]) -> Client:
|
|
81
|
+
"""
|
|
82
|
+
A `Client` that answers every request from `handler`, reaching nothing at all.
|
|
83
|
+
|
|
84
|
+
The whole of mocking, because a client is already a function: `handler` takes the
|
|
85
|
+
`ClientRequest` a caller built and returns the `ClientResponse` it should see, so no
|
|
86
|
+
pool, socket, or app exists underneath. Use it to test code that *sends* requests.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
def answer(request: ClientRequest) -> ClientResponse:
|
|
90
|
+
if request.url == "https://api.test/items":
|
|
91
|
+
return respond(200, body=b'[]')
|
|
92
|
+
raise AssertionError(f"unexpected request to {request.url}")
|
|
93
|
+
|
|
94
|
+
stats = await summarize(mock_client(answer))
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`handler` may be sync or async, and is called once per request, which is what keeps
|
|
98
|
+
a canned body usable more than once: a `ClientResponse` body is a *stream*, consumed
|
|
99
|
+
exactly once, so build it inside `handler` (as above) rather than holding one
|
|
100
|
+
response value and returning it twice.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
async def client(request: ClientRequest) -> ClientResponse:
|
|
104
|
+
answered = handler(request)
|
|
105
|
+
if isinstance(answered, ClientResponse):
|
|
106
|
+
return answered
|
|
107
|
+
return await answered
|
|
108
|
+
|
|
109
|
+
return client
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def respond(
|
|
113
|
+
status: int = 200,
|
|
114
|
+
*,
|
|
115
|
+
headers: RawHeaders = (),
|
|
116
|
+
body: bytes = b"",
|
|
117
|
+
trailers: RawHeaders | None = None,
|
|
118
|
+
) -> ClientResponse:
|
|
119
|
+
"""
|
|
120
|
+
Build a canned `ClientResponse` for a `mock_client` handler to return.
|
|
121
|
+
|
|
122
|
+
The body is a one-shot stream over `body`, so call this per request rather than
|
|
123
|
+
reusing one value (see `mock_client`). `trailers`, when given, is a single trailing
|
|
124
|
+
header block a `read_with_trailers` caller will see after the body.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
async def events() -> AsyncGenerator[bytes | ResponseTrailers]:
|
|
128
|
+
if body:
|
|
129
|
+
yield body
|
|
130
|
+
if trailers is not None:
|
|
131
|
+
yield ResponseTrailers(trailers)
|
|
132
|
+
|
|
133
|
+
return ClientResponse(ResponseHead(status, headers), ResponseBody(events()))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def base_url(base: str) -> ClientMiddleware:
|
|
137
|
+
"""
|
|
138
|
+
Client middleware that resolves each request's URL against `base`.
|
|
139
|
+
|
|
140
|
+
A test client needs absolute URLs for the same reason the network one does (the URL
|
|
141
|
+
names the origin), so this is how `"/items"` becomes `"http://testserver/items"`
|
|
142
|
+
without every call site repeating the host. An already-absolute URL is left alone.
|
|
143
|
+
"""
|
|
144
|
+
prefix = base.rstrip("/")
|
|
145
|
+
|
|
146
|
+
def resolve(request: ClientRequest) -> ClientRequest:
|
|
147
|
+
if urlsplit(request.url).scheme:
|
|
148
|
+
return request
|
|
149
|
+
return replace(request, url=prefix + request.url)
|
|
150
|
+
|
|
151
|
+
return wrap(request=resolve)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def scope_from_client_request(request: ClientRequest, *, root_path: str = "") -> HttpScope:
|
|
155
|
+
"""
|
|
156
|
+
Build the `HttpScope` an ASGI app expects directly from a `ClientRequest`.
|
|
157
|
+
|
|
158
|
+
The in-memory counterpart of `scope_from_request`, which does the same job from an
|
|
159
|
+
`h11.Request`: pure, and reading only the request itself. The URL supplies what the
|
|
160
|
+
wire would have (`scheme`, `server`, the raw path and query string), and a `host`
|
|
161
|
+
header is synthesized when the caller did not set one, matching what the HTTP/1.1
|
|
162
|
+
transport puts on the wire.
|
|
163
|
+
|
|
164
|
+
The scope advertises `http.response.trailers`, since trailers do reach the caller in
|
|
165
|
+
memory, and nothing else: an app that negotiates the extension takes its trailer path
|
|
166
|
+
here.
|
|
167
|
+
"""
|
|
168
|
+
parts = urlsplit(request.url)
|
|
169
|
+
if parts.hostname is None:
|
|
170
|
+
raise ValueError(f"client request URL must be absolute, got {request.url!r}")
|
|
171
|
+
raw_path = (parts.path or "/").encode(_ASCII)
|
|
172
|
+
headers = request.headers
|
|
173
|
+
if not any(name.lower() == b"host" for name, _ in headers):
|
|
174
|
+
headers = ((b"host", parts.netloc.encode(_ASCII)), *headers)
|
|
175
|
+
return HttpScope(
|
|
176
|
+
asgi=_ASGI,
|
|
177
|
+
http_version="1.1",
|
|
178
|
+
method=request.method,
|
|
179
|
+
scheme=parts.scheme,
|
|
180
|
+
path=unquote(raw_path.decode(_ASCII)),
|
|
181
|
+
raw_path=raw_path,
|
|
182
|
+
query_string=parts.query.encode(_ASCII),
|
|
183
|
+
root_path=root_path,
|
|
184
|
+
headers=headers,
|
|
185
|
+
client=_CLIENT_ADDRESS,
|
|
186
|
+
server=(parts.hostname, parts.port or (443 if parts.scheme == "https" else 80)),
|
|
187
|
+
extensions=_EXTENSIONS,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@asynccontextmanager
|
|
192
|
+
async def asgi_client(app: ASGIApp, *, root_path: str = "") -> AsyncIterator[Client]:
|
|
193
|
+
"""
|
|
194
|
+
A `Client` that drives `app` in memory, with no wire and no server underneath.
|
|
195
|
+
|
|
196
|
+
The app's lifespan runs for the block (through the same `run_lifespan` a real server
|
|
197
|
+
uses), so startup state is in place before the first request and torn down after the
|
|
198
|
+
last, and each request calls `app(scope, receive, send)` directly on a task of its
|
|
199
|
+
own. Nothing is encoded, no socket is opened, and the whole exchange is one process.
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
async with asgi_client(app) as client:
|
|
203
|
+
async with request(client, "GET", "http://testserver/items") as (head, body):
|
|
204
|
+
assert head.status == 200
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
It speaks only ASGI, so it drives *any* ASGI app, not just a `without` one. The
|
|
208
|
+
response streams: the head is returned the instant the app sends
|
|
209
|
+
`http.response.start`, and each body chunk crosses a one-slot queue, so an app that
|
|
210
|
+
reads the request body while writing its response behaves as it would on the wire. An
|
|
211
|
+
exception from the app surfaces to the caller rather than becoming a `500`, since
|
|
212
|
+
there is no server here to convert it; reach for `loopback_client` to exercise the
|
|
213
|
+
server's own error path.
|
|
214
|
+
|
|
215
|
+
URLs are absolute, as they are for a `ConnectionPool`, so the same test body runs
|
|
216
|
+
against a real server by swapping the client. Compose `base_url` for relative ones.
|
|
217
|
+
"""
|
|
218
|
+
async with run_lifespan(app):
|
|
219
|
+
|
|
220
|
+
async def client(request: ClientRequest) -> ClientResponse:
|
|
221
|
+
return await _drive(app, request, root_path=root_path)
|
|
222
|
+
|
|
223
|
+
yield client
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def _drive(app: ASGIApp, request: ClientRequest, *, root_path: str) -> ClientResponse:
|
|
227
|
+
"""
|
|
228
|
+
Run one request through `app`, returning as soon as its head is sent.
|
|
229
|
+
|
|
230
|
+
The in-memory sibling of the server's `_run_request`: it closes a `receive` and a
|
|
231
|
+
`send` over local state and hands them to the app. Where that one encodes through
|
|
232
|
+
`h11`, this one resolves a future for the head and pushes body chunks onto a
|
|
233
|
+
one-slot queue, which is the in-memory stand-in for a socket buffer: an app that
|
|
234
|
+
runs ahead of a slow reader blocks in `send`, exactly as it would on the wire.
|
|
235
|
+
"""
|
|
236
|
+
scope = encode_http_scope(scope_from_client_request(request, root_path=root_path))
|
|
237
|
+
started = asyncio.Event()
|
|
238
|
+
head: list[ResponseHead] = []
|
|
239
|
+
chunks: asyncio.Queue[bytes | ResponseTrailers] = asyncio.Queue(maxsize=1)
|
|
240
|
+
body = aiter(request.body)
|
|
241
|
+
request_done = False # pragma: no mutate - initial sentinel, read only as a bool
|
|
242
|
+
response_done = False # pragma: no mutate - initial sentinel, read only as a bool
|
|
243
|
+
sends_trailers = False # pragma: no mutate - `http.response.start` always assigns it before a body event
|
|
244
|
+
|
|
245
|
+
async def receive() -> RawMessage:
|
|
246
|
+
nonlocal request_done
|
|
247
|
+
if request_done:
|
|
248
|
+
return encode_inbound(Disconnect())
|
|
249
|
+
try:
|
|
250
|
+
chunk = await anext(body)
|
|
251
|
+
except StopAsyncIteration:
|
|
252
|
+
request_done = True
|
|
253
|
+
return encode_inbound(RequestBody(body=b"", more_body=False))
|
|
254
|
+
return encode_inbound(RequestBody(body=chunk, more_body=True))
|
|
255
|
+
|
|
256
|
+
def end_response() -> None:
|
|
257
|
+
nonlocal response_done
|
|
258
|
+
response_done = True
|
|
259
|
+
chunks.shutdown() # drains what is queued, then ends the body stream
|
|
260
|
+
|
|
261
|
+
async def send(message: RawMessage) -> None:
|
|
262
|
+
nonlocal sends_trailers
|
|
263
|
+
match parse_outbound(message):
|
|
264
|
+
case ResponseStart(status, headers, trailers):
|
|
265
|
+
sends_trailers = trailers
|
|
266
|
+
head.append(ResponseHead(status, headers))
|
|
267
|
+
started.set()
|
|
268
|
+
case OutboundBody(chunk, more_body):
|
|
269
|
+
if chunk and request.method != "HEAD":
|
|
270
|
+
await chunks.put(chunk)
|
|
271
|
+
if not more_body and not sends_trailers:
|
|
272
|
+
end_response()
|
|
273
|
+
case OutboundTrailers(headers, more_trailers):
|
|
274
|
+
# The extension puts the trailing blocks *after* the final body message,
|
|
275
|
+
# so for an app that declared them at `http.response.start` it is the
|
|
276
|
+
# last block, not the last body chunk, that ends the response.
|
|
277
|
+
await chunks.put(ResponseTrailers(headers))
|
|
278
|
+
if not more_trailers:
|
|
279
|
+
end_response()
|
|
280
|
+
case EarlyHint():
|
|
281
|
+
pass # a client discards informational responses, as the h11 one does
|
|
282
|
+
case ServerPush() | ZeroCopySend() | PathSend() | ResponseDebug() as unsupported:
|
|
283
|
+
# Trailers are the only extension this transport advertises, so an app
|
|
284
|
+
# sending one of these events is misusing the scope it was handed.
|
|
285
|
+
raise NotImplementedError(f"{type(unsupported).__name__} is not supported in memory")
|
|
286
|
+
case _ as unreachable:
|
|
287
|
+
assert_never(unreachable)
|
|
288
|
+
|
|
289
|
+
async def drive() -> None:
|
|
290
|
+
try:
|
|
291
|
+
await app(scope, receive, send)
|
|
292
|
+
finally:
|
|
293
|
+
# However the app ends, the body stream ends with it: shutting the queue down
|
|
294
|
+
# is synchronous and non-blocking, so this is safe even under cancellation,
|
|
295
|
+
# and it drains what is already queued before ending the stream.
|
|
296
|
+
chunks.shutdown()
|
|
297
|
+
|
|
298
|
+
task = asyncio.create_task(drive())
|
|
299
|
+
if not await _wait_for(started, task):
|
|
300
|
+
await task # re-raises whatever the app failed with, if anything
|
|
301
|
+
raise RuntimeError("the application returned without starting a response")
|
|
302
|
+
|
|
303
|
+
async def events() -> AsyncGenerator[bytes | ResponseTrailers]:
|
|
304
|
+
while True:
|
|
305
|
+
try:
|
|
306
|
+
item = await chunks.get()
|
|
307
|
+
except asyncio.QueueShutDown:
|
|
308
|
+
break
|
|
309
|
+
yield item
|
|
310
|
+
if response_done:
|
|
311
|
+
return
|
|
312
|
+
# The app ended mid-response: short of the final body chunk, or short of the
|
|
313
|
+
# trailing block it declared. Surface why, rather than handing back a truncated
|
|
314
|
+
# response as though it were the whole thing.
|
|
315
|
+
await task # re-raises the app's failure, when it had one
|
|
316
|
+
raise RuntimeError("the application ended before finishing its response")
|
|
317
|
+
|
|
318
|
+
async def release(fully_read: bool) -> None:
|
|
319
|
+
# Shutting the queue down is what unblocks a `send` parked on it, immediate or not;
|
|
320
|
+
# `immediate` additionally drops what is still queued, which nothing will read now
|
|
321
|
+
# that the body generator is closed.
|
|
322
|
+
chunks.shutdown(immediate=True) # pragma: no mutate - nothing reads the queue after this
|
|
323
|
+
await cancel_futures([task])
|
|
324
|
+
|
|
325
|
+
return ClientResponse(head[0], ResponseBody(await _releasing(events(), release)))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class _PipeTransport(asyncio.Transport):
|
|
329
|
+
"""
|
|
330
|
+
One direction of an in-memory connection: writes land in the peer's `StreamReader`.
|
|
331
|
+
|
|
332
|
+
The stand-in for the socket transport `asyncio.open_connection` would hand back, so
|
|
333
|
+
the reader/writer pair above it is the ordinary `asyncio` one and every consumer
|
|
334
|
+
(`h11`, `h2`, the server's connection loop) is unchanged. Three behaviours make it a
|
|
335
|
+
connection rather than a buffer:
|
|
336
|
+
|
|
337
|
+
- **EOF and close.** `write_eof` and `close` feed the peer EOF, which is the half-
|
|
338
|
+
close the wire protocols read as "the peer is done sending"; `close` also completes
|
|
339
|
+
this side's `wait_closed` by delivering `connection_lost` to its own protocol. Once
|
|
340
|
+
either end has closed, a `write` is dropped rather than delivered, since a socket
|
|
341
|
+
also accepts it and reports the failure on a later read.
|
|
342
|
+
- **Backpressure.** When a reader's buffer fills, `asyncio` pauses *its* transport;
|
|
343
|
+
here that is translated into pausing the peer's writing, so the peer's `drain()`
|
|
344
|
+
blocks until the reader catches up. Without that wiring an in-memory writer would
|
|
345
|
+
run arbitrarily far ahead of its reader, which no socket does.
|
|
346
|
+
- **Connection facts.** `get_extra_info` answers `sockname`/`peername` from the
|
|
347
|
+
addresses it was built with and `None` for `socket`/`ssl_object`, so the server
|
|
348
|
+
reads a cleartext connection with a peer address, as it would off a real socket.
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
def __init__(self, extra: dict[str, object]) -> None:
|
|
352
|
+
super().__init__(extra)
|
|
353
|
+
# Three placeholders until `link` runs, which it always does before any use.
|
|
354
|
+
self._peer_reader: asyncio.StreamReader | None = None # pragma: no mutate
|
|
355
|
+
self._peer: _PipeTransport | None = None # pragma: no mutate
|
|
356
|
+
self._protocol: asyncio.StreamReaderProtocol | None = None # pragma: no mutate
|
|
357
|
+
self._closing = False
|
|
358
|
+
self._paused = False
|
|
359
|
+
self._eof_sent = False
|
|
360
|
+
|
|
361
|
+
def link(
|
|
362
|
+
self, peer_reader: asyncio.StreamReader, peer: _PipeTransport, protocol: asyncio.StreamReaderProtocol
|
|
363
|
+
) -> None:
|
|
364
|
+
self._peer_reader = peer_reader
|
|
365
|
+
self._peer = peer
|
|
366
|
+
self._protocol = protocol
|
|
367
|
+
|
|
368
|
+
def write(self, data: Buffer) -> None:
|
|
369
|
+
# A write lands in the peer's `StreamReader`, which refuses data once it has been
|
|
370
|
+
# fed EOF: by this side's own `write_eof`, or by the peer's `connection_lost` when
|
|
371
|
+
# the peer closed. A socket takes such a write and surfaces the failure on a later
|
|
372
|
+
# read, so the bytes are dropped here rather than raising out of the reader.
|
|
373
|
+
if self._eof_sent or (self._peer is not None and self._peer.is_closing()):
|
|
374
|
+
return
|
|
375
|
+
if self._peer_reader is not None: # pragma: no branch - always linked before use
|
|
376
|
+
self._peer_reader.feed_data(bytes(data))
|
|
377
|
+
|
|
378
|
+
def can_write_eof(self) -> bool:
|
|
379
|
+
return True
|
|
380
|
+
|
|
381
|
+
def write_eof(self) -> None:
|
|
382
|
+
self._eof_sent = True
|
|
383
|
+
if self._peer_reader is not None: # pragma: no branch - always linked before use
|
|
384
|
+
self._peer_reader.feed_eof()
|
|
385
|
+
|
|
386
|
+
def is_closing(self) -> bool:
|
|
387
|
+
return self._closing
|
|
388
|
+
|
|
389
|
+
def close(self) -> None:
|
|
390
|
+
if self._closing:
|
|
391
|
+
return
|
|
392
|
+
self._closing = True
|
|
393
|
+
self.write_eof()
|
|
394
|
+
if self._protocol is not None: # pragma: no branch - always linked before use
|
|
395
|
+
asyncio.get_running_loop().call_soon(self._protocol.connection_lost, None)
|
|
396
|
+
|
|
397
|
+
def abort(self) -> None:
|
|
398
|
+
self.close()
|
|
399
|
+
|
|
400
|
+
def pause_reading(self) -> None:
|
|
401
|
+
# asyncio pauses the transport a full reader belongs to; here that means telling
|
|
402
|
+
# the *peer* to stop writing, which is what a socket's receive window would do.
|
|
403
|
+
if self._peer is not None: # pragma: no branch - always linked before use
|
|
404
|
+
self._peer.stall_writes()
|
|
405
|
+
|
|
406
|
+
def resume_reading(self) -> None:
|
|
407
|
+
if self._peer is not None: # pragma: no branch - always linked before use
|
|
408
|
+
self._peer.resume_writes()
|
|
409
|
+
|
|
410
|
+
def stall_writes(self) -> None:
|
|
411
|
+
"""Park this side's `drain()` until the peer's reader has caught up."""
|
|
412
|
+
if not self._paused and self._protocol is not None: # pragma: no branch
|
|
413
|
+
self._paused = True
|
|
414
|
+
self._protocol.pause_writing()
|
|
415
|
+
|
|
416
|
+
def resume_writes(self) -> None:
|
|
417
|
+
"""Let this side's `drain()` proceed again."""
|
|
418
|
+
if self._paused and self._protocol is not None: # pragma: no branch
|
|
419
|
+
self._paused = False
|
|
420
|
+
self._protocol.resume_writing()
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
type Endpoint = tuple[asyncio.StreamReader, asyncio.StreamWriter]
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def pipe(
|
|
427
|
+
*,
|
|
428
|
+
server: tuple[str, int] = SERVER_ADDRESS,
|
|
429
|
+
client: tuple[str, int] = _CLIENT_ADDRESS,
|
|
430
|
+
limit: int = _BUFFER,
|
|
431
|
+
) -> tuple[Endpoint, Endpoint]:
|
|
432
|
+
"""
|
|
433
|
+
Two connected `(reader, writer)` endpoints, wired to each other and to nothing else.
|
|
434
|
+
|
|
435
|
+
The in-memory equivalent of a connected socket pair, `(client_side, server_side)`,
|
|
436
|
+
with no file descriptor, no port, and no kernel involved. `limit` is the reader
|
|
437
|
+
buffer at which backpressure kicks in, the analogue of a socket receive buffer.
|
|
438
|
+
|
|
439
|
+
What it cannot reproduce is what only a kernel provides: TLS, and the difference
|
|
440
|
+
between an orderly `FIN` and an abortive `RST`. Tests that turn on those stay on a
|
|
441
|
+
real socket.
|
|
442
|
+
"""
|
|
443
|
+
loop = asyncio.get_running_loop()
|
|
444
|
+
|
|
445
|
+
def endpoint(
|
|
446
|
+
sockname: tuple[str, int], peername: tuple[str, int]
|
|
447
|
+
) -> tuple[Endpoint, _PipeTransport, asyncio.StreamReaderProtocol]:
|
|
448
|
+
reader = asyncio.StreamReader(limit=limit, loop=loop)
|
|
449
|
+
protocol = asyncio.StreamReaderProtocol(reader, loop=loop)
|
|
450
|
+
transport = _PipeTransport({"sockname": sockname, "peername": peername})
|
|
451
|
+
writer = asyncio.StreamWriter(transport, protocol, reader, loop)
|
|
452
|
+
protocol.connection_made(transport) # sets the reader's transport, for flow control
|
|
453
|
+
return (reader, writer), transport, protocol
|
|
454
|
+
|
|
455
|
+
near, near_transport, near_protocol = endpoint(client, server)
|
|
456
|
+
far, far_transport, far_protocol = endpoint(server, client)
|
|
457
|
+
near_transport.link(far[0], far_transport, near_protocol)
|
|
458
|
+
far_transport.link(near[0], near_transport, far_protocol)
|
|
459
|
+
return near, far
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@asynccontextmanager
|
|
463
|
+
async def served_pipe(
|
|
464
|
+
app: ASGIApp,
|
|
465
|
+
*,
|
|
466
|
+
max_concurrent_streams: int = _DEFAULT_LIMITS.max_concurrent_streams,
|
|
467
|
+
max_stream_resets: int = _DEFAULT_LIMITS.max_stream_resets,
|
|
468
|
+
idle_timeout: timedelta | None = _DEFAULT_LIMITS.idle_timeout,
|
|
469
|
+
max_websocket_message_bytes: int | None = _DEFAULT_LIMITS.max_websocket_message_bytes,
|
|
470
|
+
) -> AsyncIterator[Endpoint]:
|
|
471
|
+
"""
|
|
472
|
+
The client end of a `pipe` with `app` served on the other, for a test that writes bytes.
|
|
473
|
+
|
|
474
|
+
`serving` minus `asyncio.start_server`, and minus a client: where `loopback_client`
|
|
475
|
+
puts a `ConnectionPool` on this end, this hands the raw `(reader, writer)` over, so a
|
|
476
|
+
test can drive the exact frames and (half-)close timing a protocol conformance test
|
|
477
|
+
needs. The server reads `SERVER_ADDRESS` back as its `sockname`, which is the
|
|
478
|
+
authority such a test names.
|
|
479
|
+
|
|
480
|
+
```python
|
|
481
|
+
async with served_pipe(app, max_stream_resets=2) as (reader, writer):
|
|
482
|
+
writer.write(connection.data_to_send())
|
|
483
|
+
await writer.drain()
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
The app's lifespan runs for the block and the connection is cancelled on exit, both
|
|
487
|
+
as `serving` does, so a test can assert what leaving the block does to work still in
|
|
488
|
+
flight. Both ends are closed on the way out, and a test may `write_eof()` its own
|
|
489
|
+
end early to send the half-close a protocol reads as "done sending" while still
|
|
490
|
+
reading the response. `close()` is full teardown, not a half-close: it also ends
|
|
491
|
+
this end's own reader and drops the server's subsequent writes, though closing
|
|
492
|
+
early is safe since closing twice is a no-op. The keyword arguments are `serving`'s
|
|
493
|
+
per-connection bounds, with the same defaults.
|
|
494
|
+
"""
|
|
495
|
+
limits = _Limits(
|
|
496
|
+
max_concurrent_streams=max_concurrent_streams,
|
|
497
|
+
max_stream_resets=max_stream_resets,
|
|
498
|
+
idle_timeout=idle_timeout,
|
|
499
|
+
max_websocket_message_bytes=max_websocket_message_bytes,
|
|
500
|
+
)
|
|
501
|
+
async with run_lifespan(app):
|
|
502
|
+
near, far = pipe()
|
|
503
|
+
connection = asyncio.create_task(_serve_connection(app, *far, limits))
|
|
504
|
+
try:
|
|
505
|
+
yield near
|
|
506
|
+
finally:
|
|
507
|
+
# Cancelling first is what makes the shutdown, rather than the client's EOF,
|
|
508
|
+
# the thing that ends work still in flight. The server's end is closed here
|
|
509
|
+
# rather than left to `_serve_connection`, since a block that exits without
|
|
510
|
+
# ever awaiting never lets the connection task run at all. `close()` alone,
|
|
511
|
+
# not `wait_closed()`: a pipe has nothing to flush, and the close waiter is
|
|
512
|
+
# shared with whoever closed the endpoint first, so it may already have been
|
|
513
|
+
# cancelled with them. The closes run even when the connection task crashed
|
|
514
|
+
# (`cancel_futures` re-raises such a failure), so the endpoints never leak.
|
|
515
|
+
try:
|
|
516
|
+
await cancel_futures([connection])
|
|
517
|
+
finally:
|
|
518
|
+
for _reader, writer in (near, far):
|
|
519
|
+
writer.close()
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@asynccontextmanager
|
|
523
|
+
async def loopback_client(
|
|
524
|
+
app: ASGIApp,
|
|
525
|
+
*,
|
|
526
|
+
http2: bool = False,
|
|
527
|
+
max_concurrent_streams: int = _DEFAULT_LIMITS.max_concurrent_streams,
|
|
528
|
+
max_stream_resets: int = _DEFAULT_LIMITS.max_stream_resets,
|
|
529
|
+
idle_timeout: timedelta | None = _DEFAULT_LIMITS.idle_timeout,
|
|
530
|
+
max_websocket_message_bytes: int | None = _DEFAULT_LIMITS.max_websocket_message_bytes,
|
|
531
|
+
) -> AsyncIterator[Client]:
|
|
532
|
+
"""
|
|
533
|
+
A `Client` that reaches `app` through the real wire protocols, over no socket at all.
|
|
534
|
+
|
|
535
|
+
This is `serving` minus `asyncio.start_server`: the same `ConnectionPool` encodes the
|
|
536
|
+
request, the same server code decodes and drives the app, and the bytes cross a
|
|
537
|
+
`pipe` instead of the kernel. So it exercises what `asgi_client` skips (framing,
|
|
538
|
+
chunking, keep-alive and connection reuse, the server turning a crashing handler into
|
|
539
|
+
a `500`) while still opening no port and holding no file descriptor.
|
|
540
|
+
|
|
541
|
+
```python
|
|
542
|
+
async with loopback_client(app) as client:
|
|
543
|
+
async with request(client, "GET", "http://testserver/items") as (head, body):
|
|
544
|
+
assert head.status == 200
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
`http2` sends the h2 connection preface instead, which the server recognizes by prior
|
|
548
|
+
knowledge, so one flag runs the same test over HTTP/2. The remaining arguments are
|
|
549
|
+
`serving`'s per-connection bounds, with the same defaults.
|
|
550
|
+
|
|
551
|
+
URLs must be `http`, since a pipe has no TLS to negotiate: an `https` URL is a loud
|
|
552
|
+
failure rather than a silent downgrade. Nor can it reproduce an abortive close, so
|
|
553
|
+
tests that turn on `RST` versus `FIN` semantics belong on `serving` and a real socket.
|
|
554
|
+
"""
|
|
555
|
+
limits = _Limits(
|
|
556
|
+
max_concurrent_streams=max_concurrent_streams,
|
|
557
|
+
max_stream_resets=max_stream_resets,
|
|
558
|
+
idle_timeout=idle_timeout,
|
|
559
|
+
max_websocket_message_bytes=max_websocket_message_bytes,
|
|
560
|
+
)
|
|
561
|
+
connections: set[asyncio.Task[None]] = set()
|
|
562
|
+
|
|
563
|
+
async def connect(
|
|
564
|
+
host: str,
|
|
565
|
+
port: int,
|
|
566
|
+
*,
|
|
567
|
+
ssl_context: ssl.SSLContext | None,
|
|
568
|
+
timeout: Timeout = _NO_TIMEOUT,
|
|
569
|
+
socket_options: SocketOptions = (),
|
|
570
|
+
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter, str]:
|
|
571
|
+
if ssl_context is not None:
|
|
572
|
+
raise ValueError("loopback_client has no TLS; request an http:// URL")
|
|
573
|
+
near, far = pipe(server=(host, port))
|
|
574
|
+
task = asyncio.create_task(_serve_connection(app, *far, limits))
|
|
575
|
+
connections.add(task)
|
|
576
|
+
task.add_done_callback(connections.discard)
|
|
577
|
+
return (*near, "http/1.1")
|
|
578
|
+
|
|
579
|
+
async with run_lifespan(app), ConnectionPool(connect=connect, force_http2_cleartext=http2) as pool:
|
|
580
|
+
try:
|
|
581
|
+
yield pool
|
|
582
|
+
finally:
|
|
583
|
+
# Close the pooled connections first, so each server task sees the EOF and
|
|
584
|
+
# ends on its own; whatever is still in flight after that is cancelled, which
|
|
585
|
+
# is what `serving` does at shutdown too.
|
|
586
|
+
await pool.aclose()
|
|
587
|
+
await cancel_futures(connections)
|
|
@@ -12,7 +12,7 @@ from without import timeout
|
|
|
12
12
|
@dataclass(frozen=True, slots=True)
|
|
13
13
|
class Timeout:
|
|
14
14
|
"""
|
|
15
|
-
Per-phase inactivity bounds for
|
|
15
|
+
Per-phase inactivity bounds for one client request, each disabled (`None`) by default.
|
|
16
16
|
|
|
17
17
|
Four axes, following httpx, each bounding one phase of a request that fails for its
|
|
18
18
|
own reason (see the `without-http` guide's request-lifecycle table):
|
|
@@ -31,8 +31,10 @@ class Timeout:
|
|
|
31
31
|
budget ("fail rather than make slow progress so my upstream can react"), which the
|
|
32
32
|
transport cannot know, so a caller opts in per axis (`Timeout(connect=timedelta(
|
|
33
33
|
seconds=10), read=timedelta(seconds=30))`). There is deliberately no shared-default
|
|
34
|
-
scalar: one duration across four unrelated phases carries no meaning.
|
|
35
|
-
|
|
34
|
+
scalar: one duration across four unrelated phases carries no meaning. It rides on the
|
|
35
|
+
`ClientRequest` it bounds (`deadline` fills it in for a whole client), so it is the
|
|
36
|
+
caller's value rather than the connection's. For an overall wall-clock cap, compose
|
|
37
|
+
`async with asyncio.timeout(t): request(...)`.
|
|
36
38
|
|
|
37
39
|
Each axis is applied through its own bound: `connecting()`, `reading()`, `writing()`,
|
|
38
40
|
and `pooling()` each return a context manager that bounds the wrapped await(s) by that
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|