Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 34 additions & 32 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,40 +14,41 @@ httpx2 = { workspace = true }

[dependency-groups]
dev = [
"httpx2[brotli,cli,http2,socks,zstd]",
"httpcore2[asyncio,trio,http2,socks]",
# Tests
"chardet==5.2.0",
"coverage[toml]==7.10.6",
"cryptography==46.0.7",
"pytest>=9.0.3",
"pytest-httpbin==2.0.0",
"pytest-trio==0.8.0",
"trio==0.31.0",
"trio-typing==0.10.0",
"trustme==1.2.1",
"uvicorn>=0.35",
"werkzeug>=3.1.6",
# Linting
"mypy==1.17.1",
"ruff==0.12.11",
# Packaging
"build==1.3.0",
"twine==6.1.0",
]
docs = [
"zensical>=0.0.41",
"mkdocstrings[python]>=0.27",
"httpx2[brotli,cli,http2,socks,zstd]",
"httpcore2[asyncio,trio,http2,socks]",
# Tests
"chardet==5.2.0",
"coverage[toml]==7.10.6",
"cryptography==46.0.7",
"pytest>=9.0.3",
"pytest-httpbin==2.0.0",
"pytest-trio==0.8.0",
"trio==0.31.0",
"trio-typing==0.10.0",
"trustme==1.2.1",
"uvicorn>=0.35",
"werkzeug>=3.1.6",
# Linting
"mypy==1.17.1",
"ruff==0.12.11",
# Packaging
"build==1.3.0",
"twine==6.1.0",
]
docs = ["zensical>=0.0.41", "mkdocstrings[python]>=0.27"]
bench = [
"aiohttp>=3.10.2",
"matplotlib>=3.9",
"pyinstrument>=4.6.2",
"urllib3>=2.2.2",
"aiohttp>=3.10.2",
"matplotlib>=3.9",
"pyinstrument>=4.6.2",
"urllib3>=2.2.2",
]

[tool.ruff]
line-length = 120
exclude = ["src/httpcore2/httpcore2/_sync", "tests/httpcore2/_sync"]

[tool.ruff.lint]
select = ["E", "F", "I", "B", "PIE"]
select = ["E", "F", "W", "I", "B", "PIE"]
ignore = ["B904", "B028"]

[tool.ruff.lint.isort]
Expand All @@ -72,12 +73,13 @@ filterwarnings = [
"error",
"ignore: You seem to already have a custom sys.excepthook handler installed. I'll skip installing Trio's custom handler, but this means MultiErrors will not show full tracebacks.:RuntimeWarning",
# See: https://github.com/agronholm/anyio/issues/508
"ignore: trio.MultiError is deprecated since Trio 0.22.0:trio.TrioDeprecationWarning"
"ignore: trio.MultiError is deprecated since Trio 0.22.0:trio.TrioDeprecationWarning",
]
markers = [
"copied_from(source, changes=None): mark test as copied from somewhere else, along with a description of changes made to accodomate e.g. our test setup",
"network: marks tests which require network connection. Used in 3rd-party build environments that have network disabled."
"network: marks tests which require network connection. Used in 3rd-party build environments that have network disabled.",
]

[tool.coverage.run]
source_pkgs = ["httpx2", "tests"]
source_pkgs = ["httpx2", "httpcore2", "tests"]
omit = ["src/httpcore2/httpcore2/_sync/*"]
2 changes: 1 addition & 1 deletion scripts/check
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/bin/sh -e

export SOURCE_FILES="src/httpx2 tests/httpx2"
export SOURCE_FILES="src/httpx2 tests/httpx2 src/httpcore2 tests/httpcore2"

set -x

Expand Down
2 changes: 1 addition & 1 deletion scripts/lint
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/bin/sh -e

export SOURCE_FILES="src/httpx2 tests/httpx2"
export SOURCE_FILES="src/httpx2 tests/httpx2 src/httpcore2 tests/httpcore2"

set -x

Expand Down
4 changes: 1 addition & 3 deletions src/httpcore2/httpcore2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,7 @@

class AnyIOBackend: # type: ignore
def __init__(self, *args, **kwargs): # type: ignore
msg = (
"Attempted to use 'httpcore2.AnyIOBackend' but 'anyio' is not installed."
)
msg = "Attempted to use 'httpcore2.AnyIOBackend' but 'anyio' is not installed."
raise RuntimeError(msg)


Expand Down
36 changes: 8 additions & 28 deletions src/httpcore2/httpcore2/_async/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,30 +58,23 @@ def __init__(
self._local_address = local_address
self._uds = uds

self._network_backend: AsyncNetworkBackend = (
AutoBackend() if network_backend is None else network_backend
)
self._network_backend: AsyncNetworkBackend = AutoBackend() if network_backend is None else network_backend
self._connection: AsyncConnectionInterface | None = None
self._connect_failed: bool = False
self._request_lock = AsyncLock()
self._socket_options = socket_options

async def handle_async_request(self, request: Request) -> Response:
if not self.can_handle_request(request.url.origin):
raise RuntimeError(
f"Attempted to send request to {request.url.origin} on connection to {self._origin}"
)
raise RuntimeError(f"Attempted to send request to {request.url.origin} on connection to {self._origin}")

try:
async with self._request_lock:
if self._connection is None:
stream = await self._connect(request)

ssl_object = stream.get_extra_info("ssl_object")
http2_negotiated = (
ssl_object is not None
and ssl_object.selected_alpn_protocol() == "h2"
)
http2_negotiated = ssl_object is not None and ssl_object.selected_alpn_protocol() == "h2"
if http2_negotiated or (self._http2 and not self._http1):
from .http2 import AsyncHTTP2Connection

Expand Down Expand Up @@ -129,27 +122,18 @@ async def _connect(self, request: Request) -> AsyncNetworkStream:
"timeout": timeout,
"socket_options": self._socket_options,
}
async with Trace(
"connect_unix_socket", logger, request, kwargs
) as trace:
stream = await self._network_backend.connect_unix_socket(
**kwargs
)
async with Trace("connect_unix_socket", logger, request, kwargs) as trace:
stream = await self._network_backend.connect_unix_socket(**kwargs)
trace.return_value = stream

if self._origin.scheme in (b"https", b"wss"):
ssl_context = (
default_ssl_context()
if self._ssl_context is None
else self._ssl_context
)
ssl_context = default_ssl_context() if self._ssl_context is None else self._ssl_context
alpn_protocols = ["http/1.1", "h2"] if self._http2 else ["http/1.1"]
ssl_context.set_alpn_protocols(alpn_protocols)

kwargs = {
"ssl_context": ssl_context,
"server_hostname": sni_hostname
or self._origin.host.decode("ascii"),
"server_hostname": sni_hostname or self._origin.host.decode("ascii"),
"timeout": timeout,
}
async with Trace("start_tls", logger, request, kwargs) as trace:
Expand Down Expand Up @@ -177,11 +161,7 @@ def is_available(self) -> bool:
# If HTTP/2 support is enabled, and the resulting connection could
# end up as HTTP/2 then we should indicate the connection as being
# available to service multiple requests.
return (
self._http2
and (self._origin.scheme == b"https" or not self._http1)
and not self._connect_failed
)
return self._http2 and (self._origin.scheme == b"https" or not self._http1) and not self._connect_failed
return self._connection.is_available()

def has_expired(self) -> bool:
Expand Down
55 changes: 14 additions & 41 deletions src/httpcore2/httpcore2/_async/connection_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,7 @@ def clear_connection(self) -> None:
self.connection = None
self._connection_acquired = AsyncEvent()

async def wait_for_connection(
self, timeout: float | None = None
) -> AsyncConnectionInterface:
async def wait_for_connection(self, timeout: float | None = None) -> AsyncConnectionInterface:
if self.connection is None:
await self._connection_acquired.wait(timeout=timeout)
assert self.connection is not None
Expand Down Expand Up @@ -93,17 +91,11 @@ def __init__(
"""
self._ssl_context = ssl_context
self._proxy = proxy
self._max_connections = (
sys.maxsize if max_connections is None else max_connections
)
self._max_connections = sys.maxsize if max_connections is None else max_connections
self._max_keepalive_connections = (
sys.maxsize
if max_keepalive_connections is None
else max_keepalive_connections
)
self._max_keepalive_connections = min(
self._max_connections, self._max_keepalive_connections
sys.maxsize if max_keepalive_connections is None else max_keepalive_connections
)
self._max_keepalive_connections = min(self._max_connections, self._max_keepalive_connections)

self._keepalive_expiry = keepalive_expiry
self._http1 = http1
Expand All @@ -112,9 +104,7 @@ def __init__(
self._local_address = local_address
self._uds = uds

self._network_backend = (
AutoBackend() if network_backend is None else network_backend
)
self._network_backend = AutoBackend() if network_backend is None else network_backend
self._socket_options = socket_options

# The mutable state on a connection pool is the queue of incoming requests,
Expand Down Expand Up @@ -206,13 +196,9 @@ async def handle_async_request(self, request: Request) -> Response:
"""
scheme = request.url.scheme.decode()
if scheme == "":
raise UnsupportedProtocol(
"Request URL is missing an 'http://' or 'https://' protocol."
)
raise UnsupportedProtocol("Request URL is missing an 'http://' or 'https://' protocol.")
if scheme not in ("http", "https", "ws", "wss"):
raise UnsupportedProtocol(
f"Request URL has an unsupported protocol '{scheme}://'."
)
raise UnsupportedProtocol(f"Request URL has an unsupported protocol '{scheme}://'.")

timeouts = request.extensions.get("timeout", {})
timeout = timeouts.get("pool", None)
Expand All @@ -235,9 +221,7 @@ async def handle_async_request(self, request: Request) -> Response:

try:
# Send the request on the assigned connection.
response = await connection.handle_async_request(
pool_request.request
)
response = await connection.handle_async_request(pool_request.request)
except ConnectionNotAvailable:
# In some cases a connection may initially be available to
# handle a request, but then become unavailable.
Expand All @@ -263,9 +247,7 @@ async def handle_async_request(self, request: Request) -> Response:
return Response(
status=response.status,
headers=response.headers,
content=PoolByteStream(
stream=response.stream, pool_request=pool_request, pool=self
),
content=PoolByteStream(stream=response.stream, pool_request=pool_request, pool=self),
extensions=response.extensions,
)

Expand Down Expand Up @@ -293,8 +275,7 @@ def _assign_requests_to_connections(self) -> list[AsyncConnectionInterface]:
closing_connections.append(connection)
elif (
connection.is_idle()
and sum(connection.is_idle() for connection in self._connections)
> self._max_keepalive_connections
and sum(connection.is_idle() for connection in self._connections) > self._max_keepalive_connections
):
# log: "closing idle connection"
self._connections.remove(connection)
Expand All @@ -309,9 +290,7 @@ def _assign_requests_to_connections(self) -> list[AsyncConnectionInterface]:
for connection in self._connections
if connection.can_handle_request(origin) and connection.is_available()
]
idle_connections = [
connection for connection in self._connections if connection.is_idle()
]
idle_connections = [connection for connection in self._connections if connection.is_idle()]

# There are three cases for how we may be able to handle the request:
#
Expand Down Expand Up @@ -369,21 +348,15 @@ def __repr__(self) -> str:
class_name = self.__class__.__name__
with self._optional_thread_lock:
request_is_queued = [request.is_queued() for request in self._requests]
connection_is_idle = [
connection.is_idle() for connection in self._connections
]
connection_is_idle = [connection.is_idle() for connection in self._connections]

num_active_requests = request_is_queued.count(False)
num_queued_requests = request_is_queued.count(True)
num_active_connections = connection_is_idle.count(False)
num_idle_connections = connection_is_idle.count(True)

requests_info = (
f"Requests: {num_active_requests} active, {num_queued_requests} queued"
)
connection_info = (
f"Connections: {num_active_connections} active, {num_idle_connections} idle"
)
requests_info = f"Requests: {num_active_requests} active, {num_queued_requests} queued"
connection_info = f"Connections: {num_active_connections} active, {num_idle_connections} idle"

return f"<{class_name} [{requests_info} | {connection_info}]>"

Expand Down
Loading