Repository: encode/httpx

Issue:
Add `httpx.HTTPTransport()` and `httpx.AsyncHTTPTransport()`
Raising this separately out of https://github.com/encode/httpx/issues/1274 since I think it's worth surfacing in it's own right.


Discussion:

Resolving patch:
diff --git a/httpx/__init__.py b/httpx/__init__.py
--- a/httpx/__init__.py
+++ b/httpx/__init__.py
@@ -36,6 +36,7 @@
 from ._models import URL, Cookies, Headers, QueryParams, Request, Response
 from ._status_codes import StatusCode, codes
 from ._transports.asgi import ASGITransport
+from ._transports.default import AsyncHTTPTransport, HTTPTransport
 from ._transports.mock import MockTransport
 from ._transports.wsgi import WSGITransport
 
@@ -45,6 +46,7 @@
     "__version__",
     "ASGITransport",
     "AsyncClient",
+    "AsyncHTTPTransport",
     "Auth",
     "BasicAuth",
     "Client",
@@ -63,6 +65,7 @@
     "Headers",
     "HTTPError",
     "HTTPStatusError",
+    "HTTPTransport",
     "InvalidURL",
     "Limits",
     "LocalProtocolError",
diff --git a/httpx/_client.py b/httpx/_client.py
--- a/httpx/_client.py
+++ b/httpx/_client.py
@@ -17,7 +17,6 @@
     Proxy,
     Timeout,
     UnsetType,
-    create_ssl_context,
 )
 from ._decoders import SUPPORTED_DECODERS
 from ._exceptions import (
@@ -30,6 +29,7 @@
 from ._models import URL, Cookies, Headers, QueryParams, Request, Response
 from ._status_codes import codes
 from ._transports.asgi import ASGITransport
+from ._transports.default import AsyncHTTPTransport, HTTPTransport
 from ._transports.wsgi import WSGITransport
 from ._types import (
     AuthTypes,
@@ -649,14 +649,8 @@ def _init_transport(
         if app is not None:
             return WSGITransport(app=app)
 
-        ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env)
-
-        return httpcore.SyncConnectionPool(
-            ssl_context=ssl_context,
-            max_connections=limits.max_connections,
-            max_keepalive_connections=limits.max_keepalive_connections,
-            keepalive_expiry=limits.keepalive_expiry,
-            http2=http2,
+        return HTTPTransport(
+            verify=verify, cert=cert, http2=http2, limits=limits, trust_env=trust_env
         )
 
     def _init_proxy_transport(
@@ -668,17 +662,13 @@ def _init_proxy_transport(
         limits: Limits = DEFAULT_LIMITS,
         trust_env: bool = True,
     ) -> httpcore.SyncHTTPTransport:
-        ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env)
-
-        return httpcore.SyncHTTPProxy(
-            proxy_url=proxy.url.raw,
-            proxy_headers=proxy.headers.raw,
-            proxy_mode=proxy.mode,
-            ssl_context=ssl_context,
-            max_connections=limits.max_connections,
-            max_keepalive_connections=limits.max_keepalive_connections,
-            keepalive_expiry=limits.keepalive_expiry,
+        return HTTPTransport(
+            verify=verify,
+            cert=cert,
             http2=http2,
+            limits=limits,
+            trust_env=trust_env,
+            proxy=proxy,
         )
 
     def _transport_for_url(self, url: URL) -> httpcore.SyncHTTPTransport:
@@ -1292,14 +1282,8 @@ def _init_transport(
         if app is not None:
             return ASGITransport(app=app)
 
-        ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env)
-
-        return httpcore.AsyncConnectionPool(
-            ssl_context=ssl_context,
-            max_connections=limits.max_connections,
-            max_keepalive_connections=limits.max_keepalive_connections,
-            keepalive_expiry=limits.keepalive_expiry,
-            http2=http2,
+        return AsyncHTTPTransport(
+            verify=verify, cert=cert, http2=http2, limits=limits, trust_env=trust_env
         )
 
     def _init_proxy_transport(
@@ -1311,17 +1295,13 @@ def _init_proxy_transport(
         limits: Limits = DEFAULT_LIMITS,
         trust_env: bool = True,
     ) -> httpcore.AsyncHTTPTransport:
-        ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env)
-
-        return httpcore.AsyncHTTPProxy(
-            proxy_url=proxy.url.raw,
-            proxy_headers=proxy.headers.raw,
-            proxy_mode=proxy.mode,
-            ssl_context=ssl_context,
-            max_connections=limits.max_connections,
-            max_keepalive_connections=limits.max_keepalive_connections,
-            keepalive_expiry=limits.keepalive_expiry,
+        return AsyncHTTPTransport(
+            verify=verify,
+            cert=cert,
             http2=http2,
+            limits=limits,
+            trust_env=trust_env,
+            proxy=proxy,
         )
 
     def _transport_for_url(self, url: URL) -> httpcore.AsyncHTTPTransport:
diff --git a/httpx/_transports/default.py b/httpx/_transports/default.py
new file mode 100644
--- /dev/null
+++ b/httpx/_transports/default.py
@@ -0,0 +1,174 @@
+"""
+Custom transports, with nicely configured defaults.
+
+The following additional keyword arguments are currently supported by httpcore...
+
+* uds: str
+* local_address: str
+* retries: int
+* backend: str ("auto", "asyncio", "trio", "curio", "anyio", "sync")
+
+Example usages...
+
+# Disable HTTP/2 on a single specfic domain.
+mounts = {
+    "all://": httpx.HTTPTransport(http2=True),
+    "all://*example.org": httpx.HTTPTransport()
+}
+
+# Using advanced httpcore configuration, with connection retries.
+transport = httpx.HTTPTransport(retries=1)
+client = httpx.Client(transport=transport)
+
+# Using advanced httpcore configuration, with unix domain sockets.
+transport = httpx.HTTPTransport(uds="socket.uds")
+client = httpx.Client(transport=transport)
+"""
+import typing
+from types import TracebackType
+
+import httpcore
+
+from .._config import DEFAULT_LIMITS, Limits, Proxy, create_ssl_context
+from .._types import CertTypes, VerifyTypes
+
+T = typing.TypeVar("T", bound="HTTPTransport")
+A = typing.TypeVar("A", bound="AsyncHTTPTransport")
+Headers = typing.List[typing.Tuple[bytes, bytes]]
+URL = typing.Tuple[bytes, bytes, typing.Optional[int], bytes]
+
+
+class HTTPTransport(httpcore.SyncHTTPTransport):
+    def __init__(
+        self,
+        verify: VerifyTypes = True,
+        cert: CertTypes = None,
+        http2: bool = False,
+        limits: Limits = DEFAULT_LIMITS,
+        trust_env: bool = True,
+        proxy: Proxy = None,
+        uds: str = None,
+        local_address: str = None,
+        retries: int = 0,
+        backend: str = "sync",
+    ) -> None:
+        ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env)
+
+        if proxy is None:
+            self._pool = httpcore.SyncConnectionPool(
+                ssl_context=ssl_context,
+                max_connections=limits.max_connections,
+                max_keepalive_connections=limits.max_keepalive_connections,
+                keepalive_expiry=limits.keepalive_expiry,
+                http2=http2,
+                uds=uds,
+                local_address=local_address,
+                retries=retries,
+                backend=backend,
+            )
+        else:
+            self._pool = httpcore.SyncHTTPProxy(
+                proxy_url=proxy.url.raw,
+                proxy_headers=proxy.headers.raw,
+                proxy_mode=proxy.mode,
+                ssl_context=ssl_context,
+                max_connections=limits.max_connections,
+                max_keepalive_connections=limits.max_keepalive_connections,
+                keepalive_expiry=limits.keepalive_expiry,
+                http2=http2,
+                backend=backend,
+            )
+
+    def __enter__(self: T) -> T:  # Use generics for subclass support.
+        self._pool.__enter__()
+        return self
+
+    def __exit__(
+        self,
+        exc_type: typing.Type[BaseException] = None,
+        exc_value: BaseException = None,
+        traceback: TracebackType = None,
+    ) -> None:
+        self._pool.__exit__(exc_type, exc_value, traceback)
+
+    def request(
+        self,
+        method: bytes,
+        url: URL,
+        headers: Headers = None,
+        stream: httpcore.SyncByteStream = None,
+        ext: dict = None,
+    ) -> typing.Tuple[int, Headers, httpcore.SyncByteStream, dict]:
+        return self._pool.request(method, url, headers=headers, stream=stream, ext=ext)
+
+    def close(self) -> None:
+        self._pool.close()
+
+
+class AsyncHTTPTransport(httpcore.AsyncHTTPTransport):
+    def __init__(
+        self,
+        verify: VerifyTypes = True,
+        cert: CertTypes = None,
+        http2: bool = False,
+        limits: Limits = DEFAULT_LIMITS,
+        trust_env: bool = True,
+        proxy: Proxy = None,
+        uds: str = None,
+        local_address: str = None,
+        retries: int = 0,
+        backend: str = "auto",
+    ) -> None:
+        ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env)
+
+        if proxy is None:
+            self._pool = httpcore.AsyncConnectionPool(
+                ssl_context=ssl_context,
+                max_connections=limits.max_connections,
+                max_keepalive_connections=limits.max_keepalive_connections,
+                keepalive_expiry=limits.keepalive_expiry,
+                http2=http2,
+                uds=uds,
+                local_address=local_address,
+                retries=retries,
+                backend=backend,
+            )
+        else:
+            self._pool = httpcore.AsyncHTTPProxy(
+                proxy_url=proxy.url.raw,
+                proxy_headers=proxy.headers.raw,
+                proxy_mode=proxy.mode,
+                ssl_context=ssl_context,
+                max_connections=limits.max_connections,
+                max_keepalive_connections=limits.max_keepalive_connections,
+                keepalive_expiry=limits.keepalive_expiry,
+                http2=http2,
+                backend=backend,
+            )
+
+    async def __aenter__(self: A) -> A:  # Use generics for subclass support.
+        await self._pool.__aenter__()
+        return self
+
+    async def __aexit__(
+        self,
+        exc_type: typing.Type[BaseException] = None,
+        exc_value: BaseException = None,
+        traceback: TracebackType = None,
+    ) -> None:
+        await self._pool.__aexit__(exc_type, exc_value, traceback)
+
+    async def arequest(
+        self,
+        method: bytes,
+        url: URL,
+        headers: Headers = None,
+        stream: httpcore.AsyncByteStream = None,
+        ext: dict = None,
+    ) -> typing.Tuple[int, Headers, httpcore.AsyncByteStream, dict]:
+        return await self._pool.arequest(
+            method, url, headers=headers, stream=stream, ext=ext
+        )
+
+    async def aclose(self) -> None:
+        await self._pool.aclose()


Test patch:
diff --git a/tests/client/test_proxies.py b/tests/client/test_proxies.py
--- a/tests/client/test_proxies.py
+++ b/tests/client/test_proxies.py
@@ -43,8 +43,9 @@ def test_proxies_parameter(proxies, expected_proxies):
         pattern = URLPattern(proxy_key)
         assert pattern in client._mounts
         proxy = client._mounts[pattern]
-        assert isinstance(proxy, httpcore.SyncHTTPProxy)
-        assert proxy.proxy_origin == url_to_origin(url)
+        assert isinstance(proxy, httpx.HTTPTransport)
+        assert isinstance(proxy._pool, httpcore.SyncHTTPProxy)
+        assert proxy._pool.proxy_origin == url_to_origin(url)
 
     assert len(expected_proxies) == len(client._mounts)
 
@@ -116,8 +117,9 @@ def test_transport_for_request(url, proxies, expected):
     if expected is None:
         assert transport is client._transport
     else:
-        assert isinstance(transport, httpcore.SyncHTTPProxy)
-        assert transport.proxy_origin == url_to_origin(expected)
+        assert isinstance(transport, httpx.HTTPTransport)
+        assert isinstance(transport._pool, httpcore.SyncHTTPProxy)
+        assert transport._pool.proxy_origin == url_to_origin(expected)
 
 
 @pytest.mark.asyncio
@@ -250,7 +252,7 @@ def test_proxies_environ(monkeypatch, client_class, url, env, expected):
     if expected is None:
         assert transport == client._transport
     else:
-        assert transport.proxy_origin == url_to_origin(expected)
+        assert transport._pool.proxy_origin == url_to_origin(expected)
 
 
 @pytest.mark.parametrize(