Skip to content

Commit b29b655

Browse files
Add native asyncio backend
1 parent 393035a commit b29b655

File tree

4 files changed

+262
-5
lines changed

4 files changed

+262
-5
lines changed

httpcore/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
AsyncHTTPProxy,
99
AsyncSOCKSProxy,
1010
)
11+
from ._backends.asyncio import AsyncioBackend
1112
from ._backends.base import (
1213
SOCKET_OPTION,
1314
AsyncNetworkBackend,
@@ -97,6 +98,7 @@ def __init__(self, *args, **kwargs): # type: ignore
9798
"SOCKSProxy",
9899
# network backends, implementations
99100
"SyncBackend",
101+
"AsyncioBackend",
100102
"AnyIOBackend",
101103
"TrioBackend",
102104
# network backends, mock implementations

httpcore/_backends/asyncio.py

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import asyncio
2+
import socket
3+
import ssl
4+
from functools import cached_property
5+
from typing import Any, Dict, Iterable, Optional, Type
6+
7+
from .._exceptions import (
8+
ConnectError,
9+
ConnectTimeout,
10+
ReadError,
11+
ReadTimeout,
12+
WriteError,
13+
WriteTimeout,
14+
map_exceptions,
15+
)
16+
from .._utils import is_socket_readable
17+
from .base import SOCKET_OPTION, AsyncNetworkBackend, AsyncNetworkStream
18+
19+
SSL_MONKEY_PATCH_APPLIED = False
20+
21+
22+
def ssl_monkey_patch() -> None:
23+
"""
24+
Monkey-patch for https://bugs.python.org/issue36709
25+
This prevents console errors when outstanding HTTPS connections
26+
still exist at the point of exiting.
27+
Clients which have been opened using a `with` block, or which have
28+
had `close()` closed, will not exhibit this issue in the first place.
29+
"""
30+
MonkeyPatch = asyncio.selector_events._SelectorSocketTransport # type: ignore
31+
32+
_write = MonkeyPatch.write
33+
34+
def _fixed_write(self, data: bytes) -> None: # type: ignore
35+
if self._loop and not self._loop.is_closed():
36+
_write(self, data)
37+
38+
MonkeyPatch.write = _fixed_write
39+
40+
41+
class AsyncIOStream(AsyncNetworkStream):
42+
def __init__(
43+
self, stream_reader: asyncio.StreamReader, stream_writer: asyncio.StreamWriter
44+
):
45+
self._stream_reader = stream_reader
46+
self._stream_writer = stream_writer
47+
self._read_lock = asyncio.Lock()
48+
self._write_lock = asyncio.Lock()
49+
self._inner: Optional[AsyncIOStream] = None
50+
51+
async def start_tls(
52+
self,
53+
ssl_context: ssl.SSLContext,
54+
server_hostname: Optional[str] = None,
55+
timeout: Optional[float] = None,
56+
) -> AsyncNetworkStream:
57+
loop = asyncio.get_event_loop()
58+
59+
stream_reader = asyncio.StreamReader()
60+
protocol = asyncio.StreamReaderProtocol(stream_reader)
61+
62+
exc_map: Dict[Type[Exception], Type[Exception]] = {
63+
asyncio.TimeoutError: ConnectTimeout,
64+
OSError: ConnectError,
65+
}
66+
with map_exceptions(exc_map):
67+
transport_ssl = await asyncio.wait_for(
68+
loop.start_tls(
69+
self._stream_writer.transport,
70+
protocol,
71+
ssl_context,
72+
server_hostname=server_hostname,
73+
),
74+
timeout,
75+
)
76+
if transport_ssl is None:
77+
# https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.start_tls
78+
raise ConnectError("Transport closed while starting TLS")
79+
80+
# Initialize the protocol, so it is made aware of being tied to
81+
# a TLS connection.
82+
# See: https://github.com/encode/httpx/issues/859
83+
protocol.connection_made(transport_ssl)
84+
85+
stream_writer = asyncio.StreamWriter(
86+
transport=transport_ssl, protocol=protocol, reader=stream_reader, loop=loop
87+
)
88+
89+
ssl_stream = AsyncIOStream(stream_reader, stream_writer)
90+
# When we return a new SocketStream with new StreamReader/StreamWriter instances
91+
# we need to keep references to the old StreamReader/StreamWriter so that they
92+
# are not garbage collected and closed while we're still using them.
93+
ssl_stream._inner = self
94+
return ssl_stream
95+
96+
async def read(self, max_bytes: int, timeout: Optional[float] = None) -> bytes:
97+
exc_map: Dict[Type[Exception], Type[Exception]] = {
98+
asyncio.TimeoutError: ReadTimeout,
99+
OSError: ReadError,
100+
}
101+
async with self._read_lock:
102+
with map_exceptions(exc_map):
103+
try:
104+
return await asyncio.wait_for(
105+
self._stream_reader.read(max_bytes), timeout
106+
)
107+
except AttributeError as exc: # pragma: nocover
108+
if "resume_reading" in str(exc):
109+
# Python's asyncio has a bug that can occur when a
110+
# connection has been closed, while it is paused.
111+
# See: https://github.com/encode/httpx/issues/1213
112+
#
113+
# Returning an empty byte-string to indicate connection
114+
# close will eventually raise an httpcore.RemoteProtocolError
115+
# to the user when this goes through our HTTP parsing layer.
116+
return b""
117+
raise
118+
119+
async def write(self, data: bytes, timeout: Optional[float] = None) -> None:
120+
if not data:
121+
return
122+
123+
exc_map: Dict[Type[Exception], Type[Exception]] = {
124+
asyncio.TimeoutError: WriteTimeout,
125+
OSError: WriteError,
126+
}
127+
async with self._write_lock:
128+
with map_exceptions(exc_map):
129+
self._stream_writer.write(data)
130+
return await asyncio.wait_for(self._stream_writer.drain(), timeout)
131+
132+
async def aclose(self) -> None:
133+
# SSL connections should issue the close and then abort, rather than
134+
# waiting for the remote end of the connection to signal the EOF.
135+
#
136+
# See:
137+
#
138+
# * https://bugs.python.org/issue39758
139+
# * https://github.com/python-trio/trio/blob/
140+
# 31e2ae866ad549f1927d45ce073d4f0ea9f12419/trio/_ssl.py#L779-L829
141+
#
142+
# And related issues caused if we simply omit the 'wait_closed' call,
143+
# without first using `.abort()`
144+
#
145+
# * https://github.com/encode/httpx/issues/825
146+
# * https://github.com/encode/httpx/issues/914
147+
is_ssl = self._sslobj is not None
148+
149+
async with self._write_lock:
150+
try:
151+
self._stream_writer.close()
152+
if is_ssl:
153+
# Give the connection a chance to write any data in the buffer,
154+
# and then forcibly tear down the SSL connection.
155+
await asyncio.sleep(0)
156+
self._stream_writer.transport.abort()
157+
await self._stream_writer.wait_closed()
158+
except OSError:
159+
pass
160+
161+
def get_extra_info(self, info: str) -> Any:
162+
if info == "ssl_object":
163+
return self._sslobj
164+
if info == "client_addr":
165+
return self._raw_socket.getsockname()
166+
if info == "server_addr":
167+
return self._raw_socket.getpeername()
168+
if info == "socket":
169+
return self._raw_socket
170+
if info == "is_readable":
171+
return is_socket_readable(self._raw_socket)
172+
return None
173+
174+
@cached_property
175+
def _raw_socket(self) -> socket.socket:
176+
transport = self._stream_writer.transport
177+
sock: socket.socket = transport.get_extra_info("socket")
178+
return sock
179+
180+
@cached_property
181+
def _sslobj(self) -> Optional[ssl.SSLObject]:
182+
transport = self._stream_writer.transport
183+
sslobj: Optional[ssl.SSLObject] = transport.get_extra_info("ssl_object")
184+
return sslobj
185+
186+
187+
class AsyncioBackend(AsyncNetworkBackend):
188+
def __init__(self) -> None:
189+
global SSL_MONKEY_PATCH_APPLIED
190+
191+
if not SSL_MONKEY_PATCH_APPLIED:
192+
ssl_monkey_patch()
193+
SSL_MONKEY_PATCH_APPLIED = True
194+
195+
async def connect_tcp(
196+
self,
197+
host: str,
198+
port: int,
199+
timeout: Optional[float] = None,
200+
local_address: Optional[str] = None,
201+
socket_options: Optional[Iterable[SOCKET_OPTION]] = None,
202+
) -> AsyncNetworkStream:
203+
local_addr = None if local_address is None else (local_address, 0)
204+
205+
exc_map: Dict[Type[Exception], Type[Exception]] = {
206+
asyncio.TimeoutError: ConnectTimeout,
207+
OSError: ConnectError,
208+
}
209+
with map_exceptions(exc_map):
210+
stream_reader, stream_writer = await asyncio.wait_for(
211+
asyncio.open_connection(host, port, local_addr=local_addr),
212+
timeout,
213+
)
214+
self._set_socket_options(stream_writer, socket_options)
215+
return AsyncIOStream(
216+
stream_reader=stream_reader, stream_writer=stream_writer
217+
)
218+
219+
async def connect_unix_socket(
220+
self,
221+
path: str,
222+
timeout: Optional[float] = None,
223+
socket_options: Optional[Iterable[SOCKET_OPTION]] = None,
224+
) -> AsyncNetworkStream:
225+
exc_map: Dict[Type[Exception], Type[Exception]] = {
226+
asyncio.TimeoutError: ConnectTimeout,
227+
OSError: ConnectError,
228+
}
229+
with map_exceptions(exc_map):
230+
stream_reader, stream_writer = await asyncio.wait_for(
231+
asyncio.open_unix_connection(path), timeout
232+
)
233+
self._set_socket_options(stream_writer, socket_options)
234+
return AsyncIOStream(
235+
stream_reader=stream_reader, stream_writer=stream_writer
236+
)
237+
238+
async def sleep(self, seconds: float) -> None:
239+
await asyncio.sleep(seconds) # pragma: nocover
240+
241+
def _set_socket_options(
242+
self,
243+
stream: asyncio.StreamWriter,
244+
socket_options: Optional[Iterable[SOCKET_OPTION]] = None,
245+
) -> None:
246+
if not socket_options:
247+
return
248+
sock = stream.get_extra_info("socket")
249+
for option in socket_options:
250+
sock.setsockopt(*option)

httpcore/_backends/auto.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,20 @@
88
class AutoBackend(AsyncNetworkBackend):
99
async def _init_backend(self) -> None:
1010
if not (hasattr(self, "_backend")):
11-
backend = current_async_library()
12-
if backend == "trio":
11+
async_lib = current_async_library()
12+
if async_lib == "trio":
1313
from .trio import TrioBackend
1414

1515
self._backend: AsyncNetworkBackend = TrioBackend()
16-
else:
16+
# Note: AsyncioBackend has better performance characteristics than AnyioBackend
17+
elif async_lib == "anyio":
1718
from .anyio import AnyIOBackend
1819

1920
self._backend = AnyIOBackend()
21+
else:
22+
from .asyncio import AsyncioBackend
23+
24+
self._backend = AsyncioBackend()
2025

2126
async def connect_tcp(
2227
self,

tests/test_synchronization.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import pytest
66

7-
from httpcore import AnyIOBackend, TrioBackend
7+
from httpcore import AnyIOBackend, AsyncioBackend, TrioBackend
88
from httpcore._backends.auto import AutoBackend
99
from httpcore._synchronization import AsyncLibrary, current_async_library
1010

@@ -39,4 +39,4 @@ async def test_current_async_library(anyio_backend, check_tested_async_libraries
3939
else:
4040
assert os.environ["HTTPCORE_PREFER_ANYIO"] == "0"
4141
assert current == "asyncio"
42-
assert isinstance(auto_backend._backend, AnyIOBackend)
42+
assert isinstance(auto_backend._backend, AsyncioBackend)

0 commit comments

Comments
 (0)