Repository: encode/httpx
Pull request: Transport API
Closes https://github.com/encode/httpx/issues/768

~I did some `dispatch > transport` renaming but did not rename the `_dispatch` package and the `*Dispatch` classes in it. I considered renaming the package to `_transports` and all classes to `*Transport` with `*Dispatch` aliases but thought it might be better to check first.~

Edit: Pipelines failed for some reason so I went for it while I gave GH actions some time to sort themselves out.

Problem statement:
Transport API
**What is the dispatcher API, and why is it useful?...**

The dispatcher is the part of the system that's responsible for actually sending the request and returning a response to the client. Having an API to override the dispatcher used by the client allows you to take complete control over that process, and switch out the default behaviour with something else.

For instance the "plug in to a WSGI app" and "plug in to an ASGI app" cases that we support are implemented as dispatcher classes, that send the request to a Python web application, rather than sending a network request.

**Currently the dispatchers are considered private API, but really we'd like a public API here at some point. This issue is for discussing what we think that API should be.**

Right now the API is...

```python
 def send(request: Request, timeout: Timeout = None) -> Response:
    ...

def close():
    ...
```

With`async` equivelents for the async case.

That's actually pretty okay, but if we're looking at a public API, we ought to figure out if it's actually what we want to expose. There's a case to be made for making the dispatcher API just take plain primitive datastructures, rather than dealing with `Request`/`Response` models. That'd make for a more portable interface, that could be used in other packages without importing `httpx`.

That might look *something* like this...

```python
def send(self, method: bytes, url: Typle[bytes, bytes, int, bytes], headers: List[Tuple[bytes, bytes]], body: ClosableIterator[bytes] = None, timeout: Timeout = None):
    ...
    return (status_code: int, http_version: bytes, reason_phrase: bytes, headers: List[Tuple[bytes, bytes]], body: ClosableIterator[bytes])

def close():
    ...
```

Both the request and response bodies would be byte-iterable, closable interfaces (or byte async-iterable, closable interfaces). Everything else would be plain ol' Python datastructures.

Thoughts:

* It's feasible that `url` *could* be defined as a tuple or url components instead, so that we can keep pass a *parsed* url objects from the client to a dispatcher interface?  **Update: I've tweaked the interface to describe it that way around, as "scheme", "host", "port", "full_path".
* We *do* need to have the timeout be part of the dispatch interface, since we want to be able to support configured-per-request timeout values - I'd guess we'd need to define that as an optional four-tuple of connect/read/write/pool float timeout values.
* We might want to think about defining expected exception types.
* There's no support for HTTP trailing headers in this interface. Perhaps we don't care about that, or could the `ContentStream.close()` perhaps optionally return a list of headers, maybe?

Something that'd potentially be interesting about defining this interface in a really strict, primitive datastructures way, would be that we *could* pull out our dispatcher implementations into a really tightly scoped package `httpcore`, that'd just provide the plain network dispatchers, without any client smarts around them. (Interesting amongst other things because it'd provide a nice just-the-network-backend for other client libs to potentially use too / switch too.)

**I think a useful first take onto all of this might? be an exploratory pull request that switches us over to using a plainer dispatch API, just to get a feel for how that'd look**. A very first initial take on that could still use the `URL`, `ContentStream`, and `Timeout` primitives.

(This also potentially ties in with whatever the `hip` team's plans are. For instance, right now we're using our own dispatcher implementation - currently in the async case only, but planned for both cases - but we *would* have the option of using the `hip` backend, once it's available. It's not obvious to me if we'd want to do that, or if we'd prefer to continue with our existing backend, which is looking pretty nice now, but it *could* be a point of collaboration, or something to discuss.)

*Alternately, we might decide that this falls into architecture astronauting territory, and that the existing API is "just fine thankyouverymuch".*

Hints from issue discussion:
I think it's probably a good time for us to start pushing ahead with this, and ensuring that we've got a nice clean interface split between everything in the dispatcher implementation vs. everything at the client level.

Here's a plan towards how we could approach that...

* Drop `cert`, `verify`, `trust_env` from `ConnectionPool`, and configure using a plain `ssl_context: SSLContext = None` at that level. (The client would pass `ssl_config.ssl_context` when instantiating the `ConnectionPool`)
* Drop `pool_limits` from ConnectionPool, and instead provide `max_keepalive` and `max_connections` configuration at that level. (The client would pass `pool_limits.soft_limit`/`pool_limits.hard_limit` when instantiating the `ConnectionPool`) - Related to that we might want to rename soft_limit/hard_limit?
* Switch the dispatcher API to a plain no-models style, as described here.

At that point we've got both the Dispatcher API, and the connection pool init nicely decoupled from any model/httpx-config-classes info, and it'd probably be a good point to tackle our `unasync`'ed sync connection pool implementation, that'd allow us to drop `urllib3` as the sync default.

I think that a neat tack at that point might be to spin up a completely seperate `httpcore` repo, containing *just* the dispatch and backends, and the tests for those. We wouldn't include the ASGI, WSGI, and urllib3 dispatch classes in there either since we won't be running `unasync` on those. Then...

* Get the tests & CI all passing cleanly on the `httpcore` repo.
* Add `unasync` into the mix, to provide a `SyncConnectionPool` implementation.
* Document that package up as a low-level no-models, no-redirects, no-cookie-handling, no-client-smarts HTTP implementation.
* Switch our async dispatcher implementations over to use `httpcore.AsyncConnectionPool`, and `httpcore.AsyncHTTPProxy`.
* Switch our sync dispatcher implementation over to use `httpcore.SyncConnectionPool` and `httpcore.SyncHTTPProxy`. (With our `URLLib3Dispatcher()` class remaining as an optional alternative)

One possible outcome that designing out a really strict interface could *potentially* have here would be to put us in a better position to be able to collaborate with the `hip` team, since we've got a nice clear options of "switch to a hip-based backend" or "switch to a hip-based backend for HTTP/1.1 only, or httpcore if HTTP/2 is enabled". Not clear if that's feasible, but at least it gives us options.

I'm not *absolutely* sold on the neccessity of pulling the dispatch out into a seperate package, but...

* I think the initial steps of switching to a no-models init and dispatch API are worth us doing anyways.
* It'd be quite nice to have all the unasync and "actually make a network request" stuff partitioned into one place, with all the client smarts and model APIs on top of that be completely independant.
* We could potentially treat the `httpcore` work as exploratory and take a view on "just include this all in the same package" vs. "enforced split" once we've done the unasync work.
There's other potentially productive points of collaboration here too, since `httpcore` would be a nice minimal dependency for other client libs to use for their core networking too.

One good example here would be adding a `requests` adapter class using `httpcore`. https://github.com/psf/requests/blob/master/requests/adapters.py#L55

> a completely seperate `httpcore` repo

I can't express how excited I am about this.

We've been talking about trying to draw the line between "low-level/network/just-make-an-HTTP-request stuff"  vs "client-with-smarts stuff" for some time, including reaching out for comments in https://github.com/python-http/discussions/issues/9.

So the idea of "let's just try to make it happen" by experimenting from own our code is exciting because it gives a concrete view on how things could be organized.
> I can't express how excited I am about this.

Ace! 🌟😃🌟

I've created https://github.com/encode/httpcore - for my part I'll probably start by drawing up the documentation for it. We essentially know what the interface will look like.

(Also, we'll repurpose the `httpcore` PyPI project which `httpx` was under while it was still in its earliest days of devlopement.)
Tangentially related to prior renames/rebrands, the https://pypi.org/project/requests-async/ and https://pypi.org/project/http3/ projects should have their documentation updated to point to https://pypi.org/project/httpx/ / https://www.python-httpx.org/
Here we go... 👉 https://www.encode.io/httpcore/
@StephenBrown2 Yup, good point.
Things I think we need to do for 1.0...

* Move to `transport=...`, with `dispatch=...` placed on the deprecation path, right?
* Include a "Custom Transport classes" section in the advanced docs, which:
  * Outlines the transport API.
  * Provides an example of instantiating a transport directly, eg. `transport = httpcore.SyncConnectionPool(...)`. to help users see how that fits together.
  * Provide an example with `transport = httpx.URLLib3Transport(...)`.

We might also need to think about a "mount" API, and/or how to document proxy transport classes alongside this.

Commit message: _dispatch > _transports

Also rename *Dispatch classes to *Transport and added aliases

Patch to review:
diff --git a/docs/advanced.md b/docs/advanced.md
index 5203258bca..1e879d6607 100644
--- a/docs/advanced.md
+++ b/docs/advanced.md
@@ -619,3 +619,28 @@ If you do need to make HTTPS connections to a local server, for example to test
 >>> r
 Response <200 OK>
 ```
+
+## Custom Transports
+
+HTTPX's `Client` also accepts a `transport` argument. This argument allows you
+to inject a custom Transport objects that will be used to perform the actual
+sending of the requests.
+
+These Transport objects must implement some methods from
+[`httpcore`'s API](https://www.encode.io/httpcore/api/), either
+`httpcore.AsyncHTTPTransport` if you're using the asynchronous `Client`, or
+`httpcore.SyncHTTPTransport` if you're using a synchronous one.
+
+Specifically you *MUST* implement `request`, and `close` or `aclose` depending
+on the type of client you're using.
+
+For example, HTTPX ships with a transport that uses the excellent
+[`urllib3` library](https://urllib3.readthedocs.io/en/latest/):
+
+```python
+>>> import httpx
+>>> from httpx._dispatch.urllib3 import URLLib3Dispatcher
+>>> client = httpx.Client(transport=URLLib3Dispatcher())
+>>> client.get("https://example.org")
+<Response [200 OK]>
+```
diff --git a/httpx/__init__.py b/httpx/__init__.py
index fa02ca8fa4..f35c6fc545 100644
--- a/httpx/__init__.py
+++ b/httpx/__init__.py
@@ -3,8 +3,8 @@
 from ._auth import Auth, BasicAuth, DigestAuth
 from ._client import AsyncClient, Client
 from ._config import PoolLimits, Proxy, Timeout
-from ._dispatch.asgi import ASGIDispatch
-from ._dispatch.wsgi import WSGIDispatch
+from ._transports.asgi import ASGIDispatch, ASGITransport
+from ._transports.wsgi import WSGIDispatch, WSGITransport
 from ._exceptions import (
     ConnectTimeout,
     CookieConflict,
@@ -44,6 +44,7 @@
     "stream",
     "codes",
     "ASGIDispatch",
+    "ASGITransport",
     "AsyncClient",
     "Auth",
     "BasicAuth",
@@ -79,4 +80,5 @@
     "Response",
     "DigestAuth",
     "WSGIDispatch",
+    "WSGITransport",
 ]
diff --git a/httpx/_client.py b/httpx/_client.py
index a9fee25397..fdc02d34b9 100644
--- a/httpx/_client.py
+++ b/httpx/_client.py
@@ -1,5 +1,6 @@
 import functools
 import typing
+import warnings
 from types import TracebackType
 
 import hstspreload
@@ -91,7 +92,7 @@ def get_proxy_map(
             return {"all": proxy}
         elif isinstance(proxies, httpcore.AsyncHTTPTransport):  # pragma: nocover
             raise RuntimeError(
-                "Passing a dispatcher instance to 'proxies=' is no longer "
+                "Passing a transport instance to 'proxies=' is no longer "
                 "supported. Use `httpx.Proxy() instead.`"
             )
         else:
@@ -102,7 +103,7 @@ def get_proxy_map(
                     new_proxies[str(key)] = proxy
                 elif isinstance(value, httpcore.AsyncHTTPTransport):  # pragma: nocover
                     raise RuntimeError(
-                        "Passing a dispatcher instance to 'proxies=' is "
+                        "Passing a transport instance to 'proxies=' is "
                         "no longer supported. Use `httpx.Proxy() instead.`"
                     )
             return new_proxies
@@ -417,7 +418,10 @@ class Client(BaseClient):
     that should be followed.
     * **base_url** - *(optional)* A URL to use as the base when building
     request URLs.
-    * **dispatch** - *(optional)* A dispatch class to use for sending requests
+    * **transport** - *(optional)* A transport class to use for sending requests
+    over the network.
+    * **dispatch** - *(optional)* A deprecated alias for transport.
+    * **transport** - *(optional)* A transport class to use for sending requests
     over the network.
     * **app** - *(optional)* An WSGI application to send requests to,
     rather than sending actual network requests.
@@ -439,6 +443,7 @@ def __init__(
         pool_limits: PoolLimits = DEFAULT_POOL_LIMITS,
         max_redirects: int = DEFAULT_MAX_REDIRECTS,
         base_url: URLTypes = None,
+        transport: httpcore.SyncHTTPTransport = None,
         dispatch: httpcore.SyncHTTPTransport = None,
         app: typing.Callable = None,
         trust_env: bool = True,
@@ -456,16 +461,24 @@ def __init__(
 
         proxy_map = self.get_proxy_map(proxies, trust_env)
 
-        self.dispatch = self.init_dispatch(
+        if dispatch is not None:
+            warnings.warn(
+                "The dispatch argument is deprecated since v0.13 and will be "
+                "removed in a future release, please use 'transport'"
+            )
+            if transport is None:
+                transport = dispatch
+
+        self.transport = self.init_transport(
             verify=verify,
             cert=cert,
             pool_limits=pool_limits,
-            dispatch=dispatch,
+            transport=transport,
             app=app,
             trust_env=trust_env,
         )
         self.proxies: typing.Dict[str, httpcore.SyncHTTPTransport] = {
-            key: self.init_proxy_dispatch(
+            key: self.init_proxy_transport(
                 proxy,
                 verify=verify,
                 cert=cert,
@@ -475,17 +488,17 @@ def __init__(
             for key, proxy in proxy_map.items()
         }
 
-    def init_dispatch(
+    def init_transport(
         self,
         verify: VerifyTypes = True,
         cert: CertTypes = None,
         pool_limits: PoolLimits = DEFAULT_POOL_LIMITS,
-        dispatch: httpcore.SyncHTTPTransport = None,
+        transport: httpcore.SyncHTTPTransport = None,
         app: typing.Callable = None,
         trust_env: bool = True,
     ) -> httpcore.SyncHTTPTransport:
-        if dispatch is not None:
-            return dispatch
+        if transport is not None:
+            return transport
 
         if app is not None:
             return WSGIDispatch(app=app)
@@ -502,7 +515,7 @@ def init_dispatch(
             max_connections=max_connections,
         )
 
-    def init_proxy_dispatch(
+    def init_proxy_transport(
         self,
         proxy: Proxy,
         verify: VerifyTypes = True,
@@ -525,7 +538,7 @@ def init_proxy_dispatch(
             max_connections=max_connections,
         )
 
-    def dispatcher_for_url(self, url: URL) -> httpcore.SyncHTTPTransport:
+    def transport_for_url(self, url: URL) -> httpcore.SyncHTTPTransport:
         """
         Returns the transport instance that should be used for a given URL.
         This will either be the standard connection pool, or a proxy.
@@ -545,10 +558,10 @@ def dispatcher_for_url(self, url: URL) -> httpcore.SyncHTTPTransport:
             )
             for proxy_key in proxy_keys:
                 if proxy_key and proxy_key in self.proxies:
-                    dispatcher = self.proxies[proxy_key]
-                    return dispatcher
+                    transport = self.proxies[proxy_key]
+                    return transport
 
-        return self.dispatch
+        return self.transport
 
     def request(
         self,
@@ -680,7 +693,7 @@ def send_single_request(self, request: Request, timeout: Timeout) -> Response:
         Sends a single request, without handling any redirections.
         """
 
-        dispatcher = self.dispatcher_for_url(request.url)
+        transport = self.transport_for_url(request.url)
 
         try:
             (
@@ -689,7 +702,7 @@ def send_single_request(self, request: Request, timeout: Timeout) -> Response:
                 reason_phrase,
                 headers,
                 stream,
-            ) = dispatcher.request(
+            ) = transport.request(
                 request.method.encode(),
                 request.url.raw,
                 headers=request.headers.raw,
@@ -892,7 +905,7 @@ def delete(
         )
 
     def close(self) -> None:
-        self.dispatch.close()
+        self.transport.close()
         for proxy in self.proxies.values():
             proxy.close()
 
@@ -951,6 +964,9 @@ class AsyncClient(BaseClient):
     request URLs.
     * **dispatch** - *(optional)* A dispatch class to use for sending requests
     over the network.
+    * **transport** - *(optional)* A transport class to use for sending requests
+    over the network.
+    * **dispatch** - *(optional)* A deprecated alias for transport.
     * **app** - *(optional)* An ASGI application to send requests to,
     rather than sending actual network requests.
     * **trust_env** - *(optional)* Enables or disables usage of environment
@@ -973,6 +989,7 @@ def __init__(
         max_redirects: int = DEFAULT_MAX_REDIRECTS,
         base_url: URLTypes = None,
         dispatch: httpcore.AsyncHTTPTransport = None,
+        transport: httpcore.AsyncHTTPTransport = None,
         app: typing.Callable = None,
         trust_env: bool = True,
     ):
@@ -987,19 +1004,27 @@ def __init__(
             trust_env=trust_env,
         )
 
+        if dispatch is not None:
+            warnings.warn(
+                "The dispatch argument is deprecated since v0.13 and will be "
+                "removed in a future release, please use 'transport'"
+            )
+            if transport is None:
+                transport = dispatch
+
         proxy_map = self.get_proxy_map(proxies, trust_env)
 
-        self.dispatch = self.init_dispatch(
+        self.transport = self.init_transport(
             verify=verify,
             cert=cert,
             http2=http2,
             pool_limits=pool_limits,
-            dispatch=dispatch,
+            transport=transport,
             app=app,
             trust_env=trust_env,
         )
         self.proxies: typing.Dict[str, httpcore.AsyncHTTPTransport] = {
-            key: self.init_proxy_dispatch(
+            key: self.init_proxy_transport(
                 proxy,
                 verify=verify,
                 cert=cert,
@@ -1010,18 +1035,18 @@ def __init__(
             for key, proxy in proxy_map.items()
         }
 
-    def init_dispatch(
+    def init_transport(
         self,
         verify: VerifyTypes = True,
         cert: CertTypes = None,
         http2: bool = False,
         pool_limits: PoolLimits = DEFAULT_POOL_LIMITS,
-        dispatch: httpcore.AsyncHTTPTransport = None,
+        transport: httpcore.AsyncHTTPTransport = None,
         app: typing.Callable = None,
         trust_env: bool = True,
     ) -> httpcore.AsyncHTTPTransport:
-        if dispatch is not None:
-            return dispatch
+        if transport is not None:
+            return transport
 
         if app is not None:
             return ASGIDispatch(app=app)
@@ -1039,7 +1064,7 @@ def init_dispatch(
             http2=http2,
         )
 
-    def init_proxy_dispatch(
+    def init_proxy_transport(
         self,
         proxy: Proxy,
         verify: VerifyTypes = True,
@@ -1064,7 +1089,7 @@ def init_proxy_dispatch(
             http2=http2,
         )
 
-    def dispatcher_for_url(self, url: URL) -> httpcore.AsyncHTTPTransport:
+    def transport_for_url(self, url: URL) -> httpcore.AsyncHTTPTransport:
         """
         Returns the transport instance that should be used for a given URL.
         This will either be the standard connection pool, or a proxy.
@@ -1084,10 +1109,10 @@ def dispatcher_for_url(self, url: URL) -> httpcore.AsyncHTTPTransport:
             )
             for proxy_key in proxy_keys:
                 if proxy_key and proxy_key in self.proxies:
-                    dispatcher = self.proxies[proxy_key]
-                    return dispatcher
+                    transport = self.proxies[proxy_key]
+                    return transport
 
-        return self.dispatch
+        return self.transport
 
     async def request(
         self,
@@ -1222,7 +1247,7 @@ async def send_single_request(
         Sends a single request, without handling any redirections.
         """
 
-        dispatcher = self.dispatcher_for_url(request.url)
+        transport = self.transport_for_url(request.url)
 
         try:
             (
@@ -1231,7 +1256,7 @@ async def send_single_request(
                 reason_phrase,
                 headers,
                 stream,
-            ) = await dispatcher.request(
+            ) = await transport.request(
                 request.method.encode(),
                 request.url.raw,
                 headers=request.headers.raw,
@@ -1434,7 +1459,7 @@ async def delete(
         )
 
     async def aclose(self) -> None:
-        await self.dispatch.aclose()
+        await self.transport.aclose()
         for proxy in self.proxies.values():
             await proxy.aclose()
 
diff --git a/httpx/_dispatch/__init__.py b/httpx/_transports/__init__.py
similarity index 100%
rename from httpx/_dispatch/__init__.py
rename to httpx/_transports/__init__.py
diff --git a/httpx/_dispatch/asgi.py b/httpx/_transports/asgi.py
similarity index 87%
rename from httpx/_dispatch/asgi.py
rename to httpx/_transports/asgi.py
index a86969bcca..8a843f33a3 100644
--- a/httpx/_dispatch/asgi.py
+++ b/httpx/_transports/asgi.py
@@ -1,5 +1,6 @@
 import typing
 from typing import Callable, Dict, List, Optional, Tuple
+import warnings
 
 import httpcore
 import sniffio
@@ -24,9 +25,9 @@ def create_event() -> "Event":
         return asyncio.Event()
 
 
-class ASGIDispatch(httpcore.AsyncHTTPTransport):
+class ASGITransport(httpcore.AsyncHTTPTransport):
     """
-    A custom AsyncDispatcher that handles sending requests directly to an ASGI app.
+    A custom AsyncTransport that handles sending requests directly to an ASGI app.
     The simplest way to use this functionality is to use the `app` argument.
 
     ```
@@ -35,10 +36,10 @@ class ASGIDispatch(httpcore.AsyncHTTPTransport):
 
     Alternatively, you can setup the dispatch instance explicitly.
     This allows you to include any additional configuration arguments specific
-    to the ASGIDispatch class:
+    to the ASGITransport class:
 
     ```
-    dispatch = httpx.ASGIDispatch(
+    dispatch = httpx.ASGITransport(
         app=app,
         root_path="/submount",
         client=("1.2.3.4", 123)
@@ -153,3 +154,20 @@ async def send(message: dict) -> None:
         stream = ByteStream(b"".join(body_parts))
 
         return (b"HTTP/1.1", status_code, b"", response_headers, stream)
+
+
+class ASGIDispatch(ASGITransport):
+    def __init__(
+        self,
+        app: Callable,
+        raise_app_exceptions: bool = True,
+        root_path: str = "",
+        client: Tuple[str, int] = ("127.0.0.1", 123),
+    ) -> None:
+        warnings.warn("ASGIDispatch is deprecated, please use ASGITransport")
+        super().__init__(
+            app=app,
+            raise_app_exceptions=raise_app_exceptions,
+            root_path=root_path,
+            client=client,
+        )
diff --git a/httpx/_dispatch/urllib3.py b/httpx/_transports/urllib3.py
similarity index 89%
rename from httpx/_dispatch/urllib3.py
rename to httpx/_transports/urllib3.py
index 57c257dfa6..5148249da2 100644
--- a/httpx/_dispatch/urllib3.py
+++ b/httpx/_transports/urllib3.py
@@ -2,6 +2,7 @@
 import socket
 import ssl
 from typing import Dict, Iterator, List, Optional, Tuple, Union
+import warnings
 
 import httpcore
 import urllib3
@@ -13,7 +14,7 @@
 from .._utils import as_network_error
 
 
-class URLLib3Dispatcher(httpcore.SyncHTTPTransport):
+class URLLib3Transport(httpcore.SyncHTTPTransport):
     def __init__(
         self,
         *,
@@ -153,3 +154,23 @@ def response_bytes() -> Iterator[bytes]:
 
     def close(self) -> None:
         self.pool.clear()
+
+
+class URLLib3Dispatch(URLLib3Transport):
+    def __init__(
+        self,
+        *,
+        proxy: Proxy = None,
+        verify: VerifyTypes = True,
+        cert: CertTypes = None,
+        trust_env: bool = None,
+        pool_limits: PoolLimits = DEFAULT_POOL_LIMITS,
+    ):
+        warnings.warn("URLLib3Dispatch is deprecated, please use URLLib3Transport")
+        super().__init__(
+            proxy=proxy,
+            verify=verify,
+            cert=cert,
+            trust_env=trust_env,
+            pool_limits=pool_limits,
+        )
diff --git a/httpx/_dispatch/wsgi.py b/httpx/_transports/wsgi.py
similarity index 87%
rename from httpx/_dispatch/wsgi.py
rename to httpx/_transports/wsgi.py
index 9d83183fce..9805c6ebf6 100644
--- a/httpx/_dispatch/wsgi.py
+++ b/httpx/_transports/wsgi.py
@@ -1,6 +1,7 @@
 import io
 import itertools
 import typing
+import warnings
 
 import httpcore
 
@@ -15,7 +16,7 @@ def _skip_leading_empty_chunks(body: typing.Iterable) -> typing.Iterable:
     return []
 
 
-class WSGIDispatch(httpcore.SyncHTTPTransport):
+class WSGITransport(httpcore.SyncHTTPTransport):
     """
     A custom transport that handles sending requests directly to an WSGI app.
     The simplest way to use this functionality is to use the `app` argument.
@@ -26,10 +27,10 @@ class WSGIDispatch(httpcore.SyncHTTPTransport):
 
     Alternatively, you can setup the dispatch instance explicitly.
     This allows you to include any additional configuration arguments specific
-    to the WSGIDispatch class:
+    to the WSGITransport class:
 
     ```
-    dispatch = httpx.WSGIDispatch(
+    dispatch = httpx.WSGITransport(
         app=app,
         script_name="/submount",
         remote_addr="1.2.3.4"
@@ -131,3 +132,20 @@ def start_response(
         stream = IteratorStream(chunk for chunk in result)
 
         return (b"HTTP/1.1", status_code, b"", headers, stream)
+
+
+class WSGIDispatch(WSGITransport):
+    def __init__(
+        self,
+        app: typing.Callable,
+        raise_app_exceptions: bool = True,
+        script_name: str = "",
+        remote_addr: str = "127.0.0.1",
+    ) -> None:
+        warnings.warn("WSGIDispatch is deprecated, please use WSGITransport")
+        super().__init__(
+            app=app,
+            raise_app_exceptions=raise_app_exceptions,
+            script_name=script_name,
+            remote_addr=remote_addr,
+        )
diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py
index 31c665fdb6..979dd3c4a1 100644
--- a/tests/client/test_auth.py
+++ b/tests/client/test_auth.py
@@ -484,7 +484,7 @@ async def test_digest_auth_unavailable_streaming_body():
     client = AsyncClient(dispatch=MockDispatch())
 
     async def streaming_body():
-        yield b"Example request body"
+        yield b"Example request body"  # pragma: nocover
 
     with pytest.raises(RequestBodyUnavailable):
         await client.post(url, data=streaming_body(), auth=auth)
diff --git a/tests/client/test_proxies.py b/tests/client/test_proxies.py
index 802e2440d9..ce8f403b5e 100644
--- a/tests/client/test_proxies.py
+++ b/tests/client/test_proxies.py
@@ -74,14 +74,14 @@ def test_proxies_parameter(proxies, expected_proxies):
         ),
     ],
 )
-def test_dispatcher_for_request(url, proxies, expected):
+def test_transport_for_request(url, proxies, expected):
     client = httpx.AsyncClient(proxies=proxies)
-    dispatcher = client.dispatcher_for_url(httpx.URL(url))
+    transport = client.transport_for_url(httpx.URL(url))
 
     if expected is None:
-        assert dispatcher is client.dispatch
+        assert transport is client.transport
     else:
-        assert dispatcher.proxy_origin == httpx.URL(expected).raw[:3]
+        assert transport.proxy_origin == httpx.URL(expected).raw[:3]
 
 
 def test_unsupported_proxy_scheme():
@@ -110,9 +110,9 @@ def test_proxies_environ(monkeypatch, url, env, expected):
         monkeypatch.setenv(name, value)
 
     client = httpx.AsyncClient()
-    dispatcher = client.dispatcher_for_url(httpx.URL(url))
+    transport = client.transport_for_url(httpx.URL(url))
 
     if expected is None:
-        assert dispatcher == client.dispatch
+        assert transport == client.transport
     else:
-        assert dispatcher.proxy_origin == httpx.URL(expected).raw[:3]
+        assert transport.proxy_origin == httpx.URL(expected).raw[:3]


Code review comments:
diff_hunk: @@ -3,8 +3,8 @@
 from ._auth import Auth, BasicAuth, DigestAuth
 from ._client import AsyncClient, Client
 from ._config import PoolLimits, Proxy, Timeout
-from ._dispatch.asgi import ASGIDispatch
-from ._dispatch.wsgi import WSGIDispatch
+from ._transports.asgi import ASGIDispatch, ASGITransport
+from ._transports.wsgi import WSGIDispatch, WSGITransport
path: httpx/__init__.py
text: @user1:
Fantastic yes. We'll want `URLLib3Transport` here, too.

diff_hunk: @@ -417,7 +418,10 @@ class Client(BaseClient):
     that should be followed.
     * **base_url** - *(optional)* A URL to use as the base when building
     request URLs.
-    * **dispatch** - *(optional)* A dispatch class to use for sending requests
+    * **transport** - *(optional)* A transport class to use for sending requests
+    over the network.
+    * **dispatch** - *(optional)* A deprecated alias for transport.
+    * **transport** - *(optional)* A transport class to use for sending requests
path: httpx/_client.py
text: @user1:
`transport` is duplicated here. :)

diff_hunk: @@ -619,3 +619,28 @@ If you do need to make HTTPS connections to a local server, for example to test
 >>> r
 Response <200 OK>
 ```
+
+## Custom Transports
+
+HTTPX's `Client` also accepts a `transport` argument. This argument allows you
+to inject a custom Transport objects that will be used to perform the actual
+sending of the requests.
+
+These Transport objects must implement some methods from
+[`httpcore`'s API](https://www.encode.io/httpcore/api/), either
+`httpcore.AsyncHTTPTransport` if you're using the asynchronous `Client`, or
+`httpcore.SyncHTTPTransport` if you're using a synchronous one.
+
+Specifically you *MUST* implement `request`, and `close` or `aclose` depending
+on the type of client you're using.
+
+For example, HTTPX ships with a transport that uses the excellent
+[`urllib3` library](https://urllib3.readthedocs.io/en/latest/):
+
+```python
+>>> import httpx
+>>> from httpx._dispatch.urllib3 import URLLib3Dispatcher
path: docs/advanced.md
text: @user1:
We'll want to rename this to `URLLib3Transport`, and promote it to public API, so `httpx.URLLib3Transport(...)`.

@user1:
I guess we also ought to note that `urllib3` needs to be installed to use this transport.

We *might* need to look at if we need an `assert` check in the codebase too, since `urllib3` is no longer going to be a core dependancy of `httpx`, it'll be an optional from 13.0 onwards.

@author:
Yep, that makes sense 👍