httpdex-core 0.1.0__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.
- httpdex_core-0.1.0/.gitignore +6 -0
- httpdex_core-0.1.0/PKG-INFO +38 -0
- httpdex_core-0.1.0/README.md +24 -0
- httpdex_core-0.1.0/httpdex_core/__init__.py +47 -0
- httpdex_core-0.1.0/httpdex_core/_backends.py +221 -0
- httpdex_core-0.1.0/httpdex_core/_connection.py +259 -0
- httpdex_core-0.1.0/httpdex_core/_connection_test.py +528 -0
- httpdex_core-0.1.0/httpdex_core/_exceptions.py +37 -0
- httpdex_core-0.1.0/httpdex_core/_exceptions_test.py +41 -0
- httpdex_core-0.1.0/httpdex_core/_h2_connection.py +412 -0
- httpdex_core-0.1.0/httpdex_core/_h2_connection_test.py +1131 -0
- httpdex_core-0.1.0/httpdex_core/_h3_connection.py +469 -0
- httpdex_core-0.1.0/httpdex_core/_h3_connection_test.py +186 -0
- httpdex_core-0.1.0/httpdex_core/_integration_test.py +214 -0
- httpdex_core-0.1.0/httpdex_core/_mock.py +86 -0
- httpdex_core-0.1.0/httpdex_core/_models.py +142 -0
- httpdex_core-0.1.0/httpdex_core/_models_test.py +39 -0
- httpdex_core-0.1.0/httpdex_core/_pool.py +331 -0
- httpdex_core-0.1.0/httpdex_core/_pool_test.py +864 -0
- httpdex_core-0.1.0/httpdex_core/py.typed +0 -0
- httpdex_core-0.1.0/pyproject.toml +78 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: httpdex-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async/sync HTTP connection pool. Replaces httpcore.
|
|
5
|
+
Author: Marcelo Trylesinski
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: anyio>=4.0
|
|
9
|
+
Requires-Dist: certifi
|
|
10
|
+
Requires-Dist: httpdex-h2>=0.1.0
|
|
11
|
+
Requires-Dist: httpdex-h3>=0.1.0
|
|
12
|
+
Requires-Dist: httpdex-parse>=0.1.0
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# httpdex-core
|
|
16
|
+
|
|
17
|
+
Core transport primitives for `httpdex`.
|
|
18
|
+
|
|
19
|
+
`httpdex-core` provides the async and sync connection pools, per-protocol connection implementations, request/response models, and backend abstractions used by the top-level client.
|
|
20
|
+
|
|
21
|
+
## Highlights
|
|
22
|
+
|
|
23
|
+
- Async and sync connection pools
|
|
24
|
+
- HTTP/1.1, HTTP/2, and HTTP/3 connection implementations
|
|
25
|
+
- Shared request and response models
|
|
26
|
+
- Small public surface for building custom transports or experiments
|
|
27
|
+
|
|
28
|
+
## Public API
|
|
29
|
+
|
|
30
|
+
- `AsyncConnectionPool`
|
|
31
|
+
- `SyncConnectionPool`
|
|
32
|
+
- `AsyncHTTP11Connection`, `AsyncHTTP2Connection`, `AsyncHTTP3Connection`
|
|
33
|
+
- `SyncHTTP11Connection`, `SyncHTTP2Connection`, `SyncHTTP3Connection`
|
|
34
|
+
- `Request`, `Response`, `URL`, `Origin`
|
|
35
|
+
|
|
36
|
+
## Role In The Workspace
|
|
37
|
+
|
|
38
|
+
This package is the transport layer that sits below `httpdex` and above the protocol-specific parser and framing packages.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# httpdex-core
|
|
2
|
+
|
|
3
|
+
Core transport primitives for `httpdex`.
|
|
4
|
+
|
|
5
|
+
`httpdex-core` provides the async and sync connection pools, per-protocol connection implementations, request/response models, and backend abstractions used by the top-level client.
|
|
6
|
+
|
|
7
|
+
## Highlights
|
|
8
|
+
|
|
9
|
+
- Async and sync connection pools
|
|
10
|
+
- HTTP/1.1, HTTP/2, and HTTP/3 connection implementations
|
|
11
|
+
- Shared request and response models
|
|
12
|
+
- Small public surface for building custom transports or experiments
|
|
13
|
+
|
|
14
|
+
## Public API
|
|
15
|
+
|
|
16
|
+
- `AsyncConnectionPool`
|
|
17
|
+
- `SyncConnectionPool`
|
|
18
|
+
- `AsyncHTTP11Connection`, `AsyncHTTP2Connection`, `AsyncHTTP3Connection`
|
|
19
|
+
- `SyncHTTP11Connection`, `SyncHTTP2Connection`, `SyncHTTP3Connection`
|
|
20
|
+
- `Request`, `Response`, `URL`, `Origin`
|
|
21
|
+
|
|
22
|
+
## Role In The Workspace
|
|
23
|
+
|
|
24
|
+
This package is the transport layer that sits below `httpdex` and above the protocol-specific parser and framing packages.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from httpdex_core._connection import AsyncHTTP11Connection, SyncHTTP11Connection
|
|
4
|
+
from httpdex_core._exceptions import (
|
|
5
|
+
ConnectError,
|
|
6
|
+
ConnectionError,
|
|
7
|
+
ConnectTimeout,
|
|
8
|
+
PoolTimeout,
|
|
9
|
+
ReadError,
|
|
10
|
+
ReadTimeout,
|
|
11
|
+
UnsupportedProtocol,
|
|
12
|
+
WriteError,
|
|
13
|
+
WriteTimeout,
|
|
14
|
+
)
|
|
15
|
+
from httpdex_core._h2_connection import AsyncHTTP2Connection, SyncHTTP2Connection
|
|
16
|
+
from httpdex_core._h3_connection import AsyncHTTP3Connection, SyncHTTP3Connection
|
|
17
|
+
from httpdex_core._mock import MockAsyncBackend, MockSyncBackend
|
|
18
|
+
from httpdex_core._models import URL, AsyncResponseStream, Origin, Request, Response, SyncResponseStream
|
|
19
|
+
from httpdex_core._pool import AsyncConnectionPool, SyncConnectionPool
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AsyncConnectionPool",
|
|
23
|
+
"AsyncHTTP11Connection",
|
|
24
|
+
"AsyncHTTP2Connection",
|
|
25
|
+
"AsyncHTTP3Connection",
|
|
26
|
+
"AsyncResponseStream",
|
|
27
|
+
"ConnectError",
|
|
28
|
+
"ConnectTimeout",
|
|
29
|
+
"ConnectionError",
|
|
30
|
+
"MockAsyncBackend",
|
|
31
|
+
"MockSyncBackend",
|
|
32
|
+
"Origin",
|
|
33
|
+
"PoolTimeout",
|
|
34
|
+
"ReadError",
|
|
35
|
+
"ReadTimeout",
|
|
36
|
+
"Request",
|
|
37
|
+
"Response",
|
|
38
|
+
"SyncConnectionPool",
|
|
39
|
+
"SyncHTTP11Connection",
|
|
40
|
+
"SyncResponseStream",
|
|
41
|
+
"SyncHTTP2Connection",
|
|
42
|
+
"SyncHTTP3Connection",
|
|
43
|
+
"URL",
|
|
44
|
+
"UnsupportedProtocol",
|
|
45
|
+
"WriteError",
|
|
46
|
+
"WriteTimeout",
|
|
47
|
+
]
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
import ssl
|
|
5
|
+
|
|
6
|
+
import anyio
|
|
7
|
+
import anyio.abc
|
|
8
|
+
from anyio.streams.tls import TLSStream
|
|
9
|
+
|
|
10
|
+
from httpdex_core._exceptions import ConnectError, ConnectTimeout, ReadError, ReadTimeout, WriteError, WriteTimeout
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AsyncBackend:
|
|
14
|
+
async def connect(
|
|
15
|
+
self,
|
|
16
|
+
host: str,
|
|
17
|
+
port: int,
|
|
18
|
+
*,
|
|
19
|
+
timeout: float | None = None,
|
|
20
|
+
ssl_context: ssl.SSLContext | None = None,
|
|
21
|
+
local_address: str | None = None,
|
|
22
|
+
) -> AsyncStream:
|
|
23
|
+
try:
|
|
24
|
+
with anyio.fail_after(timeout):
|
|
25
|
+
stream = await anyio.connect_tcp(
|
|
26
|
+
remote_host=host,
|
|
27
|
+
remote_port=port,
|
|
28
|
+
local_host=local_address,
|
|
29
|
+
)
|
|
30
|
+
if ssl_context is not None:
|
|
31
|
+
tls_hostname = host.rstrip(".")
|
|
32
|
+
tls_stream = await TLSStream.wrap(
|
|
33
|
+
stream, ssl_context=ssl_context, hostname=tls_hostname, standard_compatible=False
|
|
34
|
+
)
|
|
35
|
+
return AsyncStream(tls_stream)
|
|
36
|
+
return AsyncStream(stream)
|
|
37
|
+
except TimeoutError as exc:
|
|
38
|
+
raise ConnectTimeout(str(exc)) from None
|
|
39
|
+
except OSError as exc:
|
|
40
|
+
raise ConnectError(str(exc)) from None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AsyncStream:
|
|
44
|
+
def __init__(self, stream: anyio.abc.ByteStream) -> None:
|
|
45
|
+
self._stream = stream
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def alpn_protocol(self) -> str | None:
|
|
49
|
+
if isinstance(self._stream, TLSStream):
|
|
50
|
+
ssl_object = self._stream.extra(anyio.abc.TLSAttribute.ssl_object)
|
|
51
|
+
if ssl_object is not None:
|
|
52
|
+
return ssl_object.selected_alpn_protocol() # type: ignore[union-attr]
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
async def read(self, max_bytes: int, *, timeout: float | None = None) -> bytes:
|
|
56
|
+
try:
|
|
57
|
+
with anyio.fail_after(timeout):
|
|
58
|
+
data = await self._stream.receive(max_bytes)
|
|
59
|
+
return data
|
|
60
|
+
except anyio.EndOfStream:
|
|
61
|
+
return b""
|
|
62
|
+
except TimeoutError as exc:
|
|
63
|
+
raise ReadTimeout(str(exc)) from None
|
|
64
|
+
except anyio.ClosedResourceError as exc:
|
|
65
|
+
raise ReadError(str(exc)) from None
|
|
66
|
+
|
|
67
|
+
async def write(self, data: bytes, *, timeout: float | None = None) -> None:
|
|
68
|
+
try:
|
|
69
|
+
with anyio.fail_after(timeout):
|
|
70
|
+
view = memoryview(data)
|
|
71
|
+
await self._stream.send(bytes(view))
|
|
72
|
+
except TimeoutError as exc:
|
|
73
|
+
raise WriteTimeout(str(exc)) from None
|
|
74
|
+
except anyio.ClosedResourceError as exc:
|
|
75
|
+
raise WriteError(str(exc)) from None
|
|
76
|
+
|
|
77
|
+
async def aclose(self) -> None:
|
|
78
|
+
await self._stream.aclose()
|
|
79
|
+
|
|
80
|
+
def is_readable(self) -> bool:
|
|
81
|
+
# Intentionally no socket poll here - checked only on use.
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SyncBackend:
|
|
86
|
+
def connect(
|
|
87
|
+
self,
|
|
88
|
+
host: str,
|
|
89
|
+
port: int,
|
|
90
|
+
*,
|
|
91
|
+
timeout: float | None = None,
|
|
92
|
+
ssl_context: ssl.SSLContext | None = None,
|
|
93
|
+
local_address: str | None = None,
|
|
94
|
+
) -> SyncStream:
|
|
95
|
+
try:
|
|
96
|
+
sock = socket.create_connection(
|
|
97
|
+
(host, port),
|
|
98
|
+
timeout=timeout,
|
|
99
|
+
source_address=(local_address, 0) if local_address else None,
|
|
100
|
+
)
|
|
101
|
+
sock.settimeout(None)
|
|
102
|
+
if ssl_context is not None:
|
|
103
|
+
tls_hostname = host.rstrip(".")
|
|
104
|
+
sock = ssl_context.wrap_socket(sock, server_hostname=tls_hostname)
|
|
105
|
+
return SyncStream(sock)
|
|
106
|
+
except TimeoutError as exc:
|
|
107
|
+
raise ConnectTimeout(str(exc)) from None
|
|
108
|
+
except OSError as exc:
|
|
109
|
+
raise ConnectError(str(exc)) from None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class SyncStream:
|
|
113
|
+
def __init__(self, sock: socket.socket | ssl.SSLSocket) -> None:
|
|
114
|
+
self._sock = sock
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def alpn_protocol(self) -> str | None:
|
|
118
|
+
if isinstance(self._sock, ssl.SSLSocket):
|
|
119
|
+
return self._sock.selected_alpn_protocol()
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
def read(self, max_bytes: int, *, timeout: float | None = None) -> bytes:
|
|
123
|
+
self._sock.settimeout(timeout)
|
|
124
|
+
try:
|
|
125
|
+
data = self._sock.recv(max_bytes)
|
|
126
|
+
return data
|
|
127
|
+
except TimeoutError as exc:
|
|
128
|
+
raise ReadTimeout(str(exc)) from None
|
|
129
|
+
except OSError as exc:
|
|
130
|
+
raise ReadError(str(exc)) from None
|
|
131
|
+
|
|
132
|
+
def write(self, data: bytes, *, timeout: float | None = None) -> None:
|
|
133
|
+
self._sock.settimeout(timeout)
|
|
134
|
+
view = memoryview(data)
|
|
135
|
+
try:
|
|
136
|
+
while view:
|
|
137
|
+
sent = self._sock.send(view)
|
|
138
|
+
view = view[sent:]
|
|
139
|
+
except TimeoutError as exc:
|
|
140
|
+
raise WriteTimeout(str(exc)) from None
|
|
141
|
+
except OSError as exc:
|
|
142
|
+
raise WriteError(str(exc)) from None
|
|
143
|
+
|
|
144
|
+
def close(self) -> None:
|
|
145
|
+
self._sock.close()
|
|
146
|
+
|
|
147
|
+
def is_readable(self) -> bool:
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class AsyncUDPBackend:
|
|
152
|
+
async def connect(self, host: str, port: int, *, timeout: float | None = None) -> AsyncUDPSocket:
|
|
153
|
+
try:
|
|
154
|
+
with anyio.fail_after(timeout):
|
|
155
|
+
udp_socket = await anyio.create_connected_udp_socket(remote_host=host, remote_port=port)
|
|
156
|
+
return AsyncUDPSocket(udp_socket)
|
|
157
|
+
except TimeoutError as exc:
|
|
158
|
+
raise ConnectTimeout(str(exc)) from None
|
|
159
|
+
except OSError as exc:
|
|
160
|
+
raise ConnectError(str(exc)) from None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class AsyncUDPSocket:
|
|
164
|
+
def __init__(self, socket: anyio.abc.ConnectedUDPSocket) -> None:
|
|
165
|
+
self._socket = socket
|
|
166
|
+
|
|
167
|
+
async def send(self, data: bytes) -> None:
|
|
168
|
+
try:
|
|
169
|
+
await self._socket.send(item=data)
|
|
170
|
+
except OSError as exc:
|
|
171
|
+
raise WriteError(str(exc)) from None
|
|
172
|
+
|
|
173
|
+
async def receive(self, *, timeout: float | None = None) -> bytes:
|
|
174
|
+
try:
|
|
175
|
+
with anyio.fail_after(timeout):
|
|
176
|
+
return await self._socket.receive()
|
|
177
|
+
except TimeoutError as exc:
|
|
178
|
+
raise ReadTimeout(str(exc)) from None
|
|
179
|
+
except anyio.ClosedResourceError as exc:
|
|
180
|
+
raise ReadError(str(exc)) from None
|
|
181
|
+
|
|
182
|
+
async def aclose(self) -> None:
|
|
183
|
+
await self._socket.aclose()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class SyncUDPBackend:
|
|
187
|
+
def connect(self, host: str, port: int, *, timeout: float | None = None) -> SyncUDPSocket:
|
|
188
|
+
try:
|
|
189
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
190
|
+
sock.settimeout(timeout)
|
|
191
|
+
sock.connect((host, port))
|
|
192
|
+
sock.settimeout(None)
|
|
193
|
+
return SyncUDPSocket(sock)
|
|
194
|
+
except TimeoutError as exc:
|
|
195
|
+
raise ConnectTimeout(str(exc)) from None
|
|
196
|
+
except OSError as exc:
|
|
197
|
+
raise ConnectError(str(exc)) from None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class SyncUDPSocket:
|
|
201
|
+
def __init__(self, sock: socket.socket) -> None:
|
|
202
|
+
self._sock = sock
|
|
203
|
+
|
|
204
|
+
def send(self, data: bytes) -> None:
|
|
205
|
+
try:
|
|
206
|
+
self._sock.send(data)
|
|
207
|
+
except OSError as exc:
|
|
208
|
+
raise WriteError(str(exc)) from None
|
|
209
|
+
|
|
210
|
+
def receive(self, *, timeout: float | None = None) -> bytes:
|
|
211
|
+
self._sock.settimeout(timeout)
|
|
212
|
+
try:
|
|
213
|
+
data = self._sock.recv(65536)
|
|
214
|
+
return data
|
|
215
|
+
except TimeoutError as exc:
|
|
216
|
+
raise ReadTimeout(str(exc)) from None
|
|
217
|
+
except OSError as exc:
|
|
218
|
+
raise ReadError(str(exc)) from None
|
|
219
|
+
|
|
220
|
+
def close(self) -> None:
|
|
221
|
+
self._sock.close()
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import httpdex_parse
|
|
7
|
+
|
|
8
|
+
from httpdex_core._backends import AsyncStream, SyncStream
|
|
9
|
+
from httpdex_core._models import AsyncResponseStream, Origin, Request, Response, SyncResponseStream
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConnectionStatus(enum.Enum):
|
|
13
|
+
NEW = enum.auto()
|
|
14
|
+
ACTIVE = enum.auto()
|
|
15
|
+
IDLE = enum.auto()
|
|
16
|
+
CLOSED = enum.auto()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AsyncHTTP11Connection:
|
|
20
|
+
def __init__(self, origin: Origin, stream: AsyncStream, *, keepalive_expiry: float | None = 5.0) -> None:
|
|
21
|
+
self._origin = origin
|
|
22
|
+
self._stream = stream
|
|
23
|
+
self._h11 = httpdex_parse.Connection(httpdex_parse.CLIENT)
|
|
24
|
+
self._status = ConnectionStatus.IDLE
|
|
25
|
+
self._expire_at: float | None = None
|
|
26
|
+
self._keepalive_expiry = keepalive_expiry
|
|
27
|
+
self._timeout: float | None = None
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def origin(self) -> Origin:
|
|
31
|
+
return self._origin
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def status(self) -> ConnectionStatus:
|
|
35
|
+
return self._status
|
|
36
|
+
|
|
37
|
+
def is_idle(self) -> bool:
|
|
38
|
+
return self._status is ConnectionStatus.IDLE
|
|
39
|
+
|
|
40
|
+
def is_closed(self) -> bool:
|
|
41
|
+
return self._status is ConnectionStatus.CLOSED
|
|
42
|
+
|
|
43
|
+
def is_available(self) -> bool:
|
|
44
|
+
return self._status is ConnectionStatus.IDLE
|
|
45
|
+
|
|
46
|
+
def has_expired(self) -> bool:
|
|
47
|
+
if self._expire_at is None:
|
|
48
|
+
return False
|
|
49
|
+
now = time.monotonic()
|
|
50
|
+
return now > self._expire_at
|
|
51
|
+
|
|
52
|
+
async def handle_async_request(
|
|
53
|
+
self,
|
|
54
|
+
request: Request,
|
|
55
|
+
*,
|
|
56
|
+
timeout: float | None = None,
|
|
57
|
+
) -> tuple[Response, AsyncResponseStream]:
|
|
58
|
+
self._status = ConnectionStatus.ACTIVE
|
|
59
|
+
self._timeout = timeout
|
|
60
|
+
try:
|
|
61
|
+
return await self._send_request_and_read_response(request)
|
|
62
|
+
except BaseException:
|
|
63
|
+
self._status = ConnectionStatus.CLOSED
|
|
64
|
+
await self._stream.aclose()
|
|
65
|
+
raise
|
|
66
|
+
|
|
67
|
+
async def _send_request_and_read_response(self, request: Request) -> tuple[Response, AsyncResponseStream]:
|
|
68
|
+
# Send request.
|
|
69
|
+
h11_request = httpdex_parse.Request(
|
|
70
|
+
method=request.method,
|
|
71
|
+
target=request.url.target,
|
|
72
|
+
headers=request.headers,
|
|
73
|
+
)
|
|
74
|
+
await self._stream.write(self._h11.send(h11_request), timeout=self._timeout)
|
|
75
|
+
|
|
76
|
+
if request.stream is not None:
|
|
77
|
+
async for chunk in request.stream: # type: ignore[union-attr] # pragma: no branch
|
|
78
|
+
body_data = self._h11.send(httpdex_parse.Data(data=chunk))
|
|
79
|
+
if body_data:
|
|
80
|
+
await self._stream.write(body_data, timeout=self._timeout)
|
|
81
|
+
|
|
82
|
+
eom_data = self._h11.send(httpdex_parse.EndOfMessage())
|
|
83
|
+
if eom_data:
|
|
84
|
+
await self._stream.write(eom_data, timeout=self._timeout)
|
|
85
|
+
|
|
86
|
+
# Read response.
|
|
87
|
+
response_event = await self._read_until_event(
|
|
88
|
+
(httpdex_parse.Response, httpdex_parse.InformationalResponse),
|
|
89
|
+
)
|
|
90
|
+
while isinstance(response_event, httpdex_parse.InformationalResponse):
|
|
91
|
+
response_event = await self._read_until_event(
|
|
92
|
+
(httpdex_parse.Response, httpdex_parse.InformationalResponse),
|
|
93
|
+
)
|
|
94
|
+
assert isinstance(response_event, httpdex_parse.Response)
|
|
95
|
+
|
|
96
|
+
# Build a lazy stream that reads body chunks on demand.
|
|
97
|
+
async def read_next_chunk() -> bytes | None:
|
|
98
|
+
event = await self._read_until_event((httpdex_parse.Data, httpdex_parse.EndOfMessage))
|
|
99
|
+
if isinstance(event, httpdex_parse.Data):
|
|
100
|
+
return event.data
|
|
101
|
+
return None # EndOfMessage
|
|
102
|
+
|
|
103
|
+
body_stream = AsyncResponseStream(read_next_chunk, self._mark_idle)
|
|
104
|
+
|
|
105
|
+
response_headers: list[tuple[bytes, bytes]] = [(rn, v) for rn, _, v in response_event.headers]
|
|
106
|
+
return Response(
|
|
107
|
+
status=response_event.status_code,
|
|
108
|
+
headers=response_headers,
|
|
109
|
+
stream=None,
|
|
110
|
+
), body_stream
|
|
111
|
+
|
|
112
|
+
async def _read_until_event(
|
|
113
|
+
self,
|
|
114
|
+
event_types: tuple[type, ...],
|
|
115
|
+
) -> httpdex_parse.Request | httpdex_parse.Response | httpdex_parse.InformationalResponse | httpdex_parse.Data | httpdex_parse.EndOfMessage:
|
|
116
|
+
while True:
|
|
117
|
+
event = self._h11.next_event()
|
|
118
|
+
if isinstance(event, event_types):
|
|
119
|
+
return event
|
|
120
|
+
if event is httpdex_parse.NEED_DATA:
|
|
121
|
+
data = await self._stream.read(65536, timeout=self._timeout)
|
|
122
|
+
self._h11.receive_data(data if data else b"")
|
|
123
|
+
continue
|
|
124
|
+
if event is httpdex_parse.PAUSED: # pragma: no cover
|
|
125
|
+
break
|
|
126
|
+
raise RuntimeError("unexpected parser state") # pragma: no cover
|
|
127
|
+
|
|
128
|
+
def _mark_idle(self) -> None:
|
|
129
|
+
try:
|
|
130
|
+
self._h11.start_next_cycle()
|
|
131
|
+
self._status = ConnectionStatus.IDLE
|
|
132
|
+
if self._keepalive_expiry is not None:
|
|
133
|
+
self._expire_at = time.monotonic() + self._keepalive_expiry
|
|
134
|
+
except httpdex_parse.LocalProtocolError:
|
|
135
|
+
self._status = ConnectionStatus.CLOSED
|
|
136
|
+
|
|
137
|
+
async def aclose(self) -> None:
|
|
138
|
+
self._status = ConnectionStatus.CLOSED
|
|
139
|
+
await self._stream.aclose()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class SyncHTTP11Connection:
|
|
143
|
+
def __init__(self, origin: Origin, stream: SyncStream, *, keepalive_expiry: float | None = 5.0) -> None:
|
|
144
|
+
self._origin = origin
|
|
145
|
+
self._stream = stream
|
|
146
|
+
self._h11 = httpdex_parse.Connection(httpdex_parse.CLIENT)
|
|
147
|
+
self._status = ConnectionStatus.IDLE
|
|
148
|
+
self._expire_at: float | None = None
|
|
149
|
+
self._keepalive_expiry = keepalive_expiry
|
|
150
|
+
self._timeout: float | None = None
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def origin(self) -> Origin:
|
|
154
|
+
return self._origin
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def status(self) -> ConnectionStatus:
|
|
158
|
+
return self._status
|
|
159
|
+
|
|
160
|
+
def is_idle(self) -> bool:
|
|
161
|
+
return self._status is ConnectionStatus.IDLE
|
|
162
|
+
|
|
163
|
+
def is_closed(self) -> bool:
|
|
164
|
+
return self._status is ConnectionStatus.CLOSED
|
|
165
|
+
|
|
166
|
+
def is_available(self) -> bool:
|
|
167
|
+
return self._status is ConnectionStatus.IDLE
|
|
168
|
+
|
|
169
|
+
def has_expired(self) -> bool:
|
|
170
|
+
if self._expire_at is None:
|
|
171
|
+
return False
|
|
172
|
+
now = time.monotonic()
|
|
173
|
+
return now > self._expire_at
|
|
174
|
+
|
|
175
|
+
def handle_request(
|
|
176
|
+
self,
|
|
177
|
+
request: Request,
|
|
178
|
+
*,
|
|
179
|
+
timeout: float | None = None,
|
|
180
|
+
) -> tuple[Response, SyncResponseStream]:
|
|
181
|
+
self._status = ConnectionStatus.ACTIVE
|
|
182
|
+
self._timeout = timeout
|
|
183
|
+
try:
|
|
184
|
+
return self._send_request_and_read_response(request)
|
|
185
|
+
except BaseException:
|
|
186
|
+
self._status = ConnectionStatus.CLOSED
|
|
187
|
+
self._stream.close()
|
|
188
|
+
raise
|
|
189
|
+
|
|
190
|
+
def _send_request_and_read_response(self, request: Request) -> tuple[Response, SyncResponseStream]:
|
|
191
|
+
h11_request = httpdex_parse.Request(
|
|
192
|
+
method=request.method,
|
|
193
|
+
target=request.url.target,
|
|
194
|
+
headers=request.headers,
|
|
195
|
+
)
|
|
196
|
+
self._stream.write(self._h11.send(h11_request), timeout=self._timeout)
|
|
197
|
+
|
|
198
|
+
if request.stream is not None:
|
|
199
|
+
for chunk in request.stream: # type: ignore[union-attr]
|
|
200
|
+
body_data = self._h11.send(httpdex_parse.Data(data=chunk))
|
|
201
|
+
if body_data:
|
|
202
|
+
self._stream.write(body_data, timeout=self._timeout)
|
|
203
|
+
|
|
204
|
+
eom_data = self._h11.send(httpdex_parse.EndOfMessage())
|
|
205
|
+
if eom_data:
|
|
206
|
+
self._stream.write(eom_data, timeout=self._timeout)
|
|
207
|
+
|
|
208
|
+
response_event = self._read_until_event(
|
|
209
|
+
(httpdex_parse.Response, httpdex_parse.InformationalResponse),
|
|
210
|
+
)
|
|
211
|
+
while isinstance(response_event, httpdex_parse.InformationalResponse):
|
|
212
|
+
response_event = self._read_until_event(
|
|
213
|
+
(httpdex_parse.Response, httpdex_parse.InformationalResponse),
|
|
214
|
+
)
|
|
215
|
+
assert isinstance(response_event, httpdex_parse.Response)
|
|
216
|
+
|
|
217
|
+
# Build a lazy stream that reads body chunks on demand.
|
|
218
|
+
def read_next_chunk() -> bytes | None:
|
|
219
|
+
event = self._read_until_event((httpdex_parse.Data, httpdex_parse.EndOfMessage))
|
|
220
|
+
if isinstance(event, httpdex_parse.Data):
|
|
221
|
+
return event.data
|
|
222
|
+
return None # EndOfMessage
|
|
223
|
+
|
|
224
|
+
body_stream = SyncResponseStream(read_next_chunk, self._mark_idle)
|
|
225
|
+
|
|
226
|
+
response_headers: list[tuple[bytes, bytes]] = [(rn, v) for rn, _, v in response_event.headers]
|
|
227
|
+
return Response(
|
|
228
|
+
status=response_event.status_code,
|
|
229
|
+
headers=response_headers,
|
|
230
|
+
), body_stream
|
|
231
|
+
|
|
232
|
+
def _read_until_event(
|
|
233
|
+
self,
|
|
234
|
+
event_types: tuple[type, ...],
|
|
235
|
+
) -> httpdex_parse.Request | httpdex_parse.Response | httpdex_parse.InformationalResponse | httpdex_parse.Data | httpdex_parse.EndOfMessage:
|
|
236
|
+
while True:
|
|
237
|
+
event = self._h11.next_event()
|
|
238
|
+
if isinstance(event, event_types):
|
|
239
|
+
return event
|
|
240
|
+
if event is httpdex_parse.NEED_DATA:
|
|
241
|
+
data = self._stream.read(65536, timeout=self._timeout)
|
|
242
|
+
self._h11.receive_data(data if data else b"")
|
|
243
|
+
continue
|
|
244
|
+
if event is httpdex_parse.PAUSED: # pragma: no cover
|
|
245
|
+
break
|
|
246
|
+
raise RuntimeError("unexpected parser state") # pragma: no cover
|
|
247
|
+
|
|
248
|
+
def _mark_idle(self) -> None:
|
|
249
|
+
try:
|
|
250
|
+
self._h11.start_next_cycle()
|
|
251
|
+
self._status = ConnectionStatus.IDLE
|
|
252
|
+
if self._keepalive_expiry is not None:
|
|
253
|
+
self._expire_at = time.monotonic() + self._keepalive_expiry
|
|
254
|
+
except httpdex_parse.LocalProtocolError:
|
|
255
|
+
self._status = ConnectionStatus.CLOSED
|
|
256
|
+
|
|
257
|
+
def close(self) -> None:
|
|
258
|
+
self._status = ConnectionStatus.CLOSED
|
|
259
|
+
self._stream.close()
|