Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/httpx2/httpx2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"AsyncByteStream",
"AsyncClient",
"AsyncHTTPTransport",
"AsyncPyreqwestTransport",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those can't be here.

"Auth",
"BaseTransport",
"BasicAuth",
Expand Down Expand Up @@ -59,6 +60,7 @@
"ProtocolError",
"Proxy",
"ProxyError",
"PyreqwestTransport",
"put",
"query",
"QueryParams",
Expand Down
3 changes: 3 additions & 0 deletions src/httpx2/httpx2/_transports/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from .base import AsyncBaseTransport, BaseTransport
from .default import AsyncHTTPTransport, HTTPTransport
from .mock import MockTransport
from .pyreqwest import AsyncPyreqwestTransport, PyreqwestTransport
from .wsgi import WSGITransport

__all__ = [
Expand All @@ -11,5 +12,7 @@
"AsyncHTTPTransport",
"HTTPTransport",
"MockTransport",
"AsyncPyreqwestTransport",
"PyreqwestTransport",
"WSGITransport",
]
129 changes: 129 additions & 0 deletions src/httpx2/httpx2/_transports/pyreqwest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
from __future__ import annotations

import typing
from collections.abc import AsyncIterator, Iterator
from contextlib import AsyncExitStack, ExitStack

from .._models import Request, Response
from .._types import AsyncByteStream, SyncByteStream
from .base import AsyncBaseTransport, BaseTransport

if typing.TYPE_CHECKING: # pragma: no cover
from pyreqwest.client import Client as PyreqwestClient, SyncClient as PyreqwestSyncClient
from pyreqwest.response import Response as PyreqwestResponse, SyncResponse as PyreqwestSyncResponse

__all__ = ["AsyncPyreqwestTransport", "PyreqwestTransport"]


def _load_pyreqwest_sync_client_builder() -> typing.Any:
try:
from pyreqwest.client import SyncClientBuilder
except ImportError as exc: # pragma: no cover
msg = "Using 'PyreqwestTransport' requires installing the 'pyreqwest' package."
raise RuntimeError(msg) from exc
return SyncClientBuilder


def _load_pyreqwest_async_client_builder() -> typing.Any:
try:
from pyreqwest.client import ClientBuilder
except ImportError as exc: # pragma: no cover
msg = "Using 'AsyncPyreqwestTransport' requires installing the 'pyreqwest' package."
raise RuntimeError(msg) from exc
return ClientBuilder


def _http_version(version: str) -> bytes:
if version == "HTTP/2.0":
version = "HTTP/2"
return version.encode("ascii", errors="ignore")


class _PyreqwestStream(SyncByteStream):
def __init__(self, response: PyreqwestSyncResponse, exit_stack: ExitStack) -> None:
self._reader = response.body_reader
self._exit_stack = exit_stack

def __iter__(self) -> Iterator[bytes]:
while (chunk := self._reader.read()) is not None:
yield bytes(chunk)

def close(self) -> None:
self._exit_stack.close()


class _AsyncPyreqwestStream(AsyncByteStream):
def __init__(self, response: PyreqwestResponse, exit_stack: AsyncExitStack) -> None:
self._reader = response.body_reader
self._exit_stack = exit_stack

async def __aiter__(self) -> AsyncIterator[bytes]:
while (chunk := await self._reader.read()) is not None:
yield bytes(chunk)

async def aclose(self) -> None:
await self._exit_stack.aclose()


class PyreqwestTransport(BaseTransport):
"""A sync transport backed by pyreqwest/reqwest."""

def __init__(self, client: PyreqwestSyncClient | None = None, *, close_client: bool = True) -> None:
SyncClientBuilder = _load_pyreqwest_sync_client_builder()
self._client = client or SyncClientBuilder().follow_redirects(False).default_cookie_store(False).build()
self._close_client = client is None or close_client

def handle_request(self, request: Request) -> Response:
content = request.read()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Streaming uploads are fully buffered before the connection opens, so large or unbounded request iterators lose streaming/backpressure and can exhaust memory. Pass a streaming request body to pyreqwest rather than calling Request.read() here.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/httpx2/httpx2/_transports/pyreqwest.py, line 77:

<comment>Streaming uploads are fully buffered before the connection opens, so large or unbounded request iterators lose streaming/backpressure and can exhaust memory. Pass a streaming request body to pyreqwest rather than calling `Request.read()` here.</comment>

<file context>
@@ -0,0 +1,129 @@
+        self._close_client = client is None or close_client
+
+    def handle_request(self, request: Request) -> Response:
+        content = request.read()
+        builder = self._client.request(request.method, str(request.url)).headers(request.headers.multi_items())
+        if content:
</file context>

builder = self._client.request(request.method, str(request.url)).headers(request.headers.multi_items())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply each request's timeout configuration

When a caller supplies a client- or request-level timeout, build_request stores it in request.extensions["timeout"], but this transport never reads that extension when constructing the pyreqwest request. Consequently values such as timeout=0.01 or timeout=None have no effect and the request instead uses whatever timeout was configured on the underlying pyreqwest client; the async path has the same omission.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Configured Client(timeout=...) and per-request timeouts are ignored by this transport; stalled pyreqwest calls use pyreqwest's own defaults instead. Translate request.extensions["timeout"] into the pyreqwest request/client timeout configuration before starting the request.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/httpx2/httpx2/_transports/pyreqwest.py, line 78:

<comment>Configured `Client(timeout=...)` and per-request timeouts are ignored by this transport; stalled pyreqwest calls use pyreqwest's own defaults instead. Translate `request.extensions["timeout"]` into the pyreqwest request/client timeout configuration before starting the request.</comment>

<file context>
@@ -0,0 +1,129 @@
+
+    def handle_request(self, request: Request) -> Response:
+        content = request.read()
+        builder = self._client.request(request.method, str(request.url)).headers(request.headers.multi_items())
+        if content:
+            builder = builder.body_bytes(content)
</file context>

if content:
builder = builder.body_bytes(content)

exit_stack = ExitStack()
try:
response = exit_stack.enter_context(builder.build_streamed())
return Response(
response.status,
headers=list(response.headers.items()),
stream=_PyreqwestStream(response, exit_stack),
extensions={"http_version": _http_version(response.version)},
)
except BaseException:
exit_stack.close()
raise
Comment on lines +91 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Translate pyreqwest failures into HTTPX exceptions

When connection establishment or sending fails inside build_streamed(), this handler re-raises the pyreqwest exception unchanged. Callers that follow the documented HTTPX exception API and catch httpx2.RequestError or httpx2.TransportError therefore miss ordinary network failures when opting into this transport; the async implementation behaves identically, so both paths should map backend exceptions as the default HTTP transport does.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Network failures from pyreqwest are re-raised as raw exceptions rather than being mapped to httpx2.TransportError subclasses (e.g., ConnectError, ReadTimeout). Callers relying on the standard httpx2 exception hierarchy—except httpx2.TransportError—will not catch connection failures or timeouts when using this transport, which is inconsistent with how the default HTTPTransport behaves.

Consider wrapping the builder.build_streamed() call (and equivalently in the async path) with a try/except that catches pyreqwest-specific exceptions and re-raises them as the appropriate httpx2 transport error, e.g.:

except SomePyreqwestConnectionError as exc:
    raise httpx2.ConnectError(str(exc)) from exc
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/httpx2/httpx2/_transports/pyreqwest.py, line 93:

<comment>Network failures from pyreqwest are re-raised as raw exceptions rather than being mapped to `httpx2.TransportError` subclasses (e.g., `ConnectError`, `ReadTimeout`). Callers relying on the standard httpx2 exception hierarchy—`except httpx2.TransportError`—will not catch connection failures or timeouts when using this transport, which is inconsistent with how the default `HTTPTransport` behaves.

Consider wrapping the `builder.build_streamed()` call (and equivalently in the async path) with a try/except that catches pyreqwest-specific exceptions and re-raises them as the appropriate `httpx2` transport error, e.g.:
```python
except SomePyreqwestConnectionError as exc:
    raise httpx2.ConnectError(str(exc)) from exc
```</comment>

<file context>
@@ -0,0 +1,129 @@
+            )
+        except BaseException:
+            exit_stack.close()
+            raise
+
+    def close(self) -> None:
</file context>


def close(self) -> None:
if self._close_client:
self._client.close()


class AsyncPyreqwestTransport(AsyncBaseTransport):
"""An async transport backed by pyreqwest/reqwest."""

def __init__(self, client: PyreqwestClient | None = None, *, close_client: bool = True) -> None:
ClientBuilder = _load_pyreqwest_async_client_builder()
self._client = client or ClientBuilder().follow_redirects(False).default_cookie_store(False).build()
self._close_client = client is None or close_client

async def handle_async_request(self, request: Request) -> Response:
content = await request.aread()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Async streaming uploads are fully buffered before the connection opens, so large or unbounded async iterators lose streaming/backpressure and can exhaust memory. Pass a streaming request body to pyreqwest rather than calling Request.aread() here.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/httpx2/httpx2/_transports/pyreqwest.py, line 109:

<comment>Async streaming uploads are fully buffered before the connection opens, so large or unbounded async iterators lose streaming/backpressure and can exhaust memory. Pass a streaming request body to pyreqwest rather than calling `Request.aread()` here.</comment>

<file context>
@@ -0,0 +1,129 @@
+        self._close_client = client is None or close_client
+
+    async def handle_async_request(self, request: Request) -> Response:
+        content = await request.aread()
+        builder = self._client.request(request.method, str(request.url)).headers(request.headers.multi_items())
+        if content:
</file context>

builder = self._client.request(request.method, str(request.url)).headers(request.headers.multi_items())
if content:
builder = builder.body_bytes(content)

exit_stack = AsyncExitStack()
try:
response = await exit_stack.enter_async_context(builder.build_streamed())
return Response(
response.status,
headers=list(response.headers.items()),
stream=_AsyncPyreqwestStream(response, exit_stack),
extensions={"http_version": _http_version(response.version)},
)
except BaseException:
await exit_stack.aclose()
raise

async def aclose(self) -> None:
if self._close_client:
await self._client.close()
3 changes: 3 additions & 0 deletions src/httpx2/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ cli = [
http2 = [
"h2>=3,<5",
]
pyreqwest = [
"pyreqwest>=0.12; python_version >= '3.11'",
]
socks = [
"socksio==1.*",
]
Expand Down
38 changes: 38 additions & 0 deletions tests/httpx2/test_pyreqwest_transport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from __future__ import annotations

import asyncio

import pytest

import httpx2

pytest.importorskip("pyreqwest")


def test_pyreqwest_transport(server: object) -> None:
with httpx2.Client(transport=httpx2.PyreqwestTransport()) as client:
response = client.get(str(server.url)) # type: ignore[attr-defined]

assert response.status_code == 200
assert response.text == "Hello, world!"
assert response.http_version == "HTTP/1.1"


def test_async_pyreqwest_transport(server: object) -> None:
async def run() -> httpx2.Response:
async with httpx2.AsyncClient(transport=httpx2.AsyncPyreqwestTransport()) as client:
return await client.get(str(server.url)) # type: ignore[attr-defined]

response = asyncio.run(run())

assert response.status_code == 200
assert response.text == "Hello, world!"
assert response.http_version == "HTTP/1.1"


def test_pyreqwest_transport_request_body(server: object) -> None:
with httpx2.Client(transport=httpx2.PyreqwestTransport()) as client:
response = client.post(str(server.url.copy_with(path="/echo_body")), content=b"hello") # type: ignore[attr-defined]

assert response.status_code == 200
assert response.content == b"hello"
Loading
Loading