Repository: encode/httpx
Pull request: Proxy Configurations
Here's part 2 of the HTTP proxy PRs, configuring on the client. I also worked out some issues with re-using the TCP tunnel connection.

Closes #33

Problem statement:
Environment support
Needs to handle anything that requests currently deals with when `trust_env=True` is set.
`.netrc`, `REQUEST_CA_BUNDLE` and anything else relevant.

First step to dealing with this would be to outline *exactly* what set of stuff `requests` includes. If anyone wants to dig into this and comment on this issue comprehensively with what set of behavior we need, then that'd be a great start.

We have `adapters/environment.py` currently stubbed out to deal with adding in this behavior.

Hints from issue discussion:
If this is still important, I'd be interested in doing some research here to determine what exactly requests includes, and then possibly implementing proper functionality.
This is definitely important! If you can lend a hand with discovery that'd be great.
Hi, I tried to include netrc support. #177 what do you think about this approach? 
We still need a way to grab environment variables and to document those environment variables for this issue to be closed.
I did some research in the Requests docs looking for environment variables it supports, and whether we support them already:

- [x] `NETRC`
- [ ] `REQUEST_CA_BUNDLE` or `CURL_CA_BUNDLE` (see [here](https://2.python-requests.org/en/master/user/advanced/#ssl-cert-verification) and [here](https://github.com/psf/requests/blob/master/requests/sessions.py#L712)).
- [ ] `HTTP_PROXY`, `HTTPS_PROXY` (see [here](https://2.python-requests.org/en/master/user/advanced/#proxies)), [`ALL_PROXY`](https://2.python-requests.org/en/master/community/updates/#id35), [`NO_PROXY`](https://2.python-requests.org/en/master/community/updates/#id81).

(Note that the names of the `*_PROXY` environment variables seems be a [convention](https://superuser.com/questions/944958/are-http-proxy-https-proxy-and-no-proxy-environment-variables-standard).)

To make extra sure we don't miss anything, it would probably be worth cloning the Requests repo and grepping our way using `environment variable`, `os.getenv` and `os.environ.get`.

The scope of this issue is rather broad, so if that's confirmed and as suggested by @sethmlarson in https://github.com/encode/httpx/pull/277#issuecomment-525036637, how about creating issues for each set of environment variables?
Should also support `SSL_CERT_FILE` as that's merged in Requests 3.0 branch and was intended to be supported and is an OpenSSL thing. See [PEP 476](https://www.python.org/dev/peps/pep-0476/).
After inspecting the Requests code base, I updated the list with `CURL_CA_BUNDLE` — I couldn't find any other use of environment variables in there.
Having recently spent a lot of time in wireshark, I'd quite like `SSLKEYLOGFILE` too (unless Python's `ssl` provides this free of charge?)
> Having recently spent a lot of time in wireshark, I'd quite like `SSLKEYLOGFILE` too (unless Python's `ssl` provides this free of charge?)

Seems it will be [landing in Python 3.8](https://bugs.python.org/issue34271). Meanwhile, [sslkeylog](https://github.com/segevfiner/sslkeylog)?

Commit message: Start of proxy config

Patch to review:
diff --git a/docs/advanced.md b/docs/advanced.md
index dba5128ded..97b0db0576 100644
--- a/docs/advanced.md
+++ b/docs/advanced.md
@@ -111,3 +111,48 @@ password example-password
 
 ...
 ```
+
+## HTTP Proxying
+
+HTTPX supports setting up proxies the same way that Requests does via the `proxies` parameter.
+
+```python
+>>> client = httpx.Client(proxies={
+  "http": "http://127.0.0.1:3080",
+  "https": "http://127.0.0.1:3080"
+})
+```
+
+Proxies can be configured per scheme, scheme+host, for all schemes of a host, or for all requests:
+
+```python
+>>> client = httpx.Client(proxies={
+    "http://example.com":  "...",  # Host+Scheme
+    "all://example.com":  "...",  # Host
+    "http": "...",  # Scheme
+    "all": "...",  # All
+})
+>>> client = httpx.Client(proxies="...")  # Shortcut for 'all'
+```
+
+!!! warning
+    It is recommended for privacy reasons to use HTTPS and tunnel requests if possible.
+    Proxies can read your traffic even if the `proxy_url` parameter uses HTTPS
+    unless you make requests to the target domain with HTTPS through a `CONNECT` TCP tunnel.
+
+By default `HTTPProxy` will operate as a forwarding proxy for `http://...` requests
+and will establish a `CONNECT` TCP tunnel for `https://` requests. This doesn't change
+regardless of the `proxy_url` being `http` or `https`.
+
+Proxies can be configured to have different behavior such as forwarding or tunneling all requests:
+
+```python
+proxy = httpx.HTTPProxy(
+    proxy_url="https://127.0.0.1",
+    proxy_mode=httpx.HTTPProxyMode.TUNNEL_ONLY
+)
+client = httpx.Client(proxies=proxy)
+
+# This request will be tunnelled instead of forwarded.
+client.get("http://example.com")
+```
diff --git a/docs/environment_variables.md b/docs/environment_variables.md
index d8f3960d49..c8e3ca5387 100644
--- a/docs/environment_variables.md
+++ b/docs/environment_variables.md
@@ -79,3 +79,17 @@ SERVER_TRAFFIC_SECRET_0 XXXX
 CLIENT_HANDSHAKE_TRAFFIC_SECRET XXXX
 CLIENT_TRAFFIC_SECRET_0 XXXX
 ```
+
+`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`
+----------------------------------------
+
+Valid values: A URL to a proxy
+
+Sets the proxy to be used for `http`, `https`, or all requests respectively.
+
+```console
+export HTTP_PROXY=http://127.0.0.1:3080
+
+# This request will be sent through the proxy
+python -c "import httpx; httpx.get('http://example.com')"
+```
diff --git a/httpx/client.py b/httpx/client.py
index ea6dae6e17..9285912412 100644
--- a/httpx/client.py
+++ b/httpx/client.py
@@ -20,6 +20,7 @@
 from .dispatch.asgi import ASGIDispatch
 from .dispatch.base import AsyncDispatcher, Dispatcher
 from .dispatch.connection_pool import ConnectionPool
+from .dispatch.proxy_http import HTTPProxy
 from .dispatch.threaded import ThreadedDispatcher
 from .dispatch.wsgi import WSGIDispatch
 from .exceptions import HTTPError, InvalidURL
@@ -38,6 +39,7 @@
     CookieTypes,
     Headers,
     HeaderTypes,
+    ProxiesTypes,
     QueryParamTypes,
     RequestData,
     RequestFiles,
@@ -45,18 +47,20 @@
     ResponseContent,
     URLTypes,
 )
-from .utils import get_netrc_login
+from .utils import get_environment_proxies, get_netrc_login
 
 
 class BaseClient:
     def __init__(
         self,
+        *,
         auth: AuthTypes = None,
         headers: HeaderTypes = None,
         cookies: CookieTypes = None,
         verify: VerifyTypes = True,
         cert: CertTypes = None,
         http_versions: HTTPVersionTypes = None,
+        proxies: ProxiesTypes = None,
         timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
         pool_limits: PoolLimits = DEFAULT_POOL_LIMITS,
         max_redirects: int = DEFAULT_MAX_REDIRECTS,
@@ -101,6 +105,13 @@ def __init__(
         else:
             self.base_url = URL(base_url)
 
+        if proxies is None and trust_env:
+            proxies = typing.cast(ProxiesTypes, get_environment_proxies())
+
+        self.proxies: typing.Dict[str, AsyncDispatcher] = _proxies_to_dispatchers(
+            proxies
+        )
+
         self.auth = auth
         self._headers = Headers(headers)
         self._cookies = Cookies(cookies)
@@ -162,13 +173,20 @@ async def _get_response(
         cert: CertTypes = None,
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
+        proxies: ProxiesTypes = None,
     ) -> AsyncResponse:
         if request.url.scheme not in ("http", "https"):
             raise InvalidURL('URL scheme must be "http" or "https".')
 
+        if proxies is not None:
+            dispatch_proxies = _proxies_to_dispatchers(proxies)
+        else:
+            dispatch_proxies = proxies or self.proxies
+        dispatch = self._dispatcher_for_request(request, dispatch_proxies)
+
         async def get_response(request: AsyncRequest) -> AsyncResponse:
             try:
-                response = await self.dispatch.send(
+                response = await dispatch.send(
                     request, verify=verify, cert=cert, timeout=timeout
                 )
             except HTTPError as exc:
@@ -236,6 +254,31 @@ def _get_auth_middleware(
 
         return None
 
+    def _dispatcher_for_request(
+        self, request: AsyncRequest, proxies: typing.Dict[str, AsyncDispatcher]
+    ) -> AsyncDispatcher:
+        """Gets the AsyncDispatcher instance that should be used for a given Request"""
+        if proxies:
+            url = request.url
+            is_default_port = (url.scheme == "http" and url.port == 80) or (
+                url.scheme == "https" and url.port == 443
+            )
+            hostname = f"{url.host}:{url.port}"
+            proxy_keys = (
+                f"{url.scheme}://{hostname}",
+                f"{url.scheme}://{url.host}" if is_default_port else None,
+                f"all://{hostname}",
+                f"all://{url.host}" if is_default_port else None,
+                url.scheme,
+                "all",
+            )
+            for proxy_key in proxy_keys:
+                if proxy_key and proxy_key in proxies:
+                    dispatcher = proxies[proxy_key]
+                    return dispatcher
+
+        return self.dispatch
+
     def build_request(
         self,
         method: str,
@@ -279,6 +322,7 @@ async def get(
         verify: VerifyTypes = None,
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
+        proxies: ProxiesTypes = None,
     ) -> AsyncResponse:
         return await self.request(
             "GET",
@@ -293,6 +337,7 @@ async def get(
             cert=cert,
             timeout=timeout,
             trust_env=trust_env,
+            proxies=proxies,
         )
 
     async def options(
@@ -309,6 +354,7 @@ async def options(
         verify: VerifyTypes = None,
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
+        proxies: ProxiesTypes = None,
     ) -> AsyncResponse:
         return await self.request(
             "OPTIONS",
@@ -323,6 +369,7 @@ async def options(
             cert=cert,
             timeout=timeout,
             trust_env=trust_env,
+            proxies=proxies,
         )
 
     async def head(
@@ -339,6 +386,7 @@ async def head(
         verify: VerifyTypes = None,
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
+        proxies: ProxiesTypes = None,
     ) -> AsyncResponse:
         return await self.request(
             "HEAD",
@@ -353,6 +401,7 @@ async def head(
             cert=cert,
             timeout=timeout,
             trust_env=trust_env,
+            proxies=proxies,
         )
 
     async def post(
@@ -372,6 +421,7 @@ async def post(
         verify: VerifyTypes = None,
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
+        proxies: ProxiesTypes = None,
     ) -> AsyncResponse:
         return await self.request(
             "POST",
@@ -389,6 +439,7 @@ async def post(
             cert=cert,
             timeout=timeout,
             trust_env=trust_env,
+            proxies=proxies,
         )
 
     async def put(
@@ -408,6 +459,7 @@ async def put(
         verify: VerifyTypes = None,
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
+        proxies: ProxiesTypes = None,
     ) -> AsyncResponse:
         return await self.request(
             "PUT",
@@ -425,6 +477,7 @@ async def put(
             cert=cert,
             timeout=timeout,
             trust_env=trust_env,
+            proxies=proxies,
         )
 
     async def patch(
@@ -444,6 +497,7 @@ async def patch(
         verify: VerifyTypes = None,
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
+        proxies: ProxiesTypes = None,
     ) -> AsyncResponse:
         return await self.request(
             "PATCH",
@@ -461,6 +515,7 @@ async def patch(
             cert=cert,
             timeout=timeout,
             trust_env=trust_env,
+            proxies=proxies,
         )
 
     async def delete(
@@ -480,6 +535,7 @@ async def delete(
         verify: VerifyTypes = None,
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
+        proxies: ProxiesTypes = None,
     ) -> AsyncResponse:
         return await self.request(
             "DELETE",
@@ -497,6 +553,7 @@ async def delete(
             cert=cert,
             timeout=timeout,
             trust_env=trust_env,
+            proxies=proxies,
         )
 
     async def request(
@@ -517,6 +574,7 @@ async def request(
         verify: VerifyTypes = None,
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
+        proxies: ProxiesTypes = None,
     ) -> AsyncResponse:
         request = self.build_request(
             method=method,
@@ -537,6 +595,7 @@ async def request(
             cert=cert,
             timeout=timeout,
             trust_env=trust_env,
+            proxies=proxies,
         )
         return response
 
@@ -551,6 +610,7 @@ async def send(
         cert: CertTypes = None,
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
+        proxies: ProxiesTypes = None,
     ) -> AsyncResponse:
         return await self._get_response(
             request=request,
@@ -561,6 +621,7 @@ async def send(
             cert=cert,
             timeout=timeout,
             trust_env=trust_env,
+            proxies=proxies,
         )
 
     async def close(self) -> None:
@@ -639,6 +700,7 @@ def request(
         verify: VerifyTypes = None,
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
+        proxies: ProxiesTypes = None,
     ) -> Response:
         request = self.build_request(
             method=method,
@@ -659,6 +721,7 @@ def request(
             cert=cert,
             timeout=timeout,
             trust_env=trust_env,
+            proxies=proxies,
         )
         return response
 
@@ -673,6 +736,7 @@ def send(
         cert: CertTypes = None,
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
+        proxies: ProxiesTypes = None,
     ) -> Response:
         concurrency_backend = self.concurrency_backend
 
@@ -686,6 +750,7 @@ def send(
             "cert": cert,
             "timeout": timeout,
             "trust_env": trust_env,
+            "proxies": proxies,
         }
         async_response = concurrency_backend.run(coroutine, *args, **kwargs)
 
@@ -729,6 +794,7 @@ def get(
         verify: VerifyTypes = None,
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
+        proxies: ProxiesTypes = None,
     ) -> Response:
         return self.request(
             "GET",
@@ -743,6 +809,7 @@ def get(
             cert=cert,
             timeout=timeout,
             trust_env=trust_env,
+            proxies=proxies,
         )
 
     def options(
@@ -759,6 +826,7 @@ def options(
         verify: VerifyTypes = None,
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
+        proxies: ProxiesTypes = None,
     ) -> Response:
         return self.request(
             "OPTIONS",
@@ -773,6 +841,7 @@ def options(
             cert=cert,
             timeout=timeout,
             trust_env=trust_env,
+            proxies=proxies,
         )
 
     def head(
@@ -789,6 +858,7 @@ def head(
         verify: VerifyTypes = None,
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
+        proxies: ProxiesTypes = None,
     ) -> Response:
         return self.request(
             "HEAD",
@@ -803,6 +873,7 @@ def head(
             cert=cert,
             timeout=timeout,
             trust_env=trust_env,
+            proxies=proxies,
         )
 
     def post(
@@ -822,6 +893,7 @@ def post(
         verify: VerifyTypes = None,
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
+        proxies: ProxiesTypes = None,
     ) -> Response:
         return self.request(
             "POST",
@@ -839,6 +911,7 @@ def post(
             cert=cert,
             timeout=timeout,
             trust_env=trust_env,
+            proxies=proxies,
         )
 
     def put(
@@ -858,6 +931,7 @@ def put(
         verify: VerifyTypes = None,
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
+        proxies: ProxiesTypes = None,
     ) -> Response:
         return self.request(
             "PUT",
@@ -875,6 +949,7 @@ def put(
             cert=cert,
             timeout=timeout,
             trust_env=trust_env,
+            proxies=proxies,
         )
 
     def patch(
@@ -894,6 +969,7 @@ def patch(
         verify: VerifyTypes = None,
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
+        proxies: ProxiesTypes = None,
     ) -> Response:
         return self.request(
             "PATCH",
@@ -911,6 +987,7 @@ def patch(
             cert=cert,
             timeout=timeout,
             trust_env=trust_env,
+            proxies=proxies,
         )
 
     def delete(
@@ -930,6 +1007,7 @@ def delete(
         verify: VerifyTypes = None,
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
+        proxies: ProxiesTypes = None,
     ) -> Response:
         return self.request(
             "DELETE",
@@ -947,6 +1025,7 @@ def delete(
             cert=cert,
             timeout=timeout,
             trust_env=trust_env,
+            proxies=proxies,
         )
 
     def close(self) -> None:
@@ -963,3 +1042,29 @@ def __exit__(
         traceback: TracebackType = None,
     ) -> None:
         self.close()
+
+
+def _proxy_from_url(url: URLTypes) -> AsyncDispatcher:
+    url = URL(url)
+    if url.scheme in ("http", "https"):
+        return HTTPProxy(url)
+    raise ValueError(f"Unknown proxy for {url!r}")
+
+
+def _proxies_to_dispatchers(
+    proxies: typing.Optional[ProxiesTypes]
+) -> typing.Dict[str, AsyncDispatcher]:
+    if proxies is None:
+        return {}
+    elif isinstance(proxies, (str, URL)):
+        return {"all": _proxy_from_url(proxies)}
+    elif isinstance(proxies, AsyncDispatcher):
+        return {"all": proxies}
+    else:
+        new_proxies = {}
+        for key, dispatcher_or_url in proxies.items():
+            if isinstance(dispatcher_or_url, (str, URL)):
+                new_proxies[str(key)] = _proxy_from_url(dispatcher_or_url)
+            else:
+                new_proxies[str(key)] = dispatcher_or_url
+        return new_proxies
diff --git a/httpx/dispatch/connection_pool.py b/httpx/dispatch/connection_pool.py
index 2cc11c9884..1a23e8c80e 100644
--- a/httpx/dispatch/connection_pool.py
+++ b/httpx/dispatch/connection_pool.py
@@ -128,7 +128,7 @@ async def send(
         return response
 
     async def acquire_connection(self, origin: Origin) -> HTTPConnection:
-        logger.debug("acquire_connection origin={origin!r}")
+        logger.debug(f"acquire_connection origin={origin!r}")
         connection = self.pop_connection(origin)
 
         if connection is None:
diff --git a/httpx/models.py b/httpx/models.py
index e60453bd72..15e0e924fa 100644
--- a/httpx/models.py
+++ b/httpx/models.py
@@ -41,6 +41,7 @@
 
 if typing.TYPE_CHECKING:
     from .middleware.base import BaseMiddleware  # noqa: F401
+    from .dispatch.base import AsyncDispatcher  # noqa: F401
 
 PrimitiveData = typing.Optional[typing.Union[str, int, float, bool]]
 
@@ -67,6 +68,12 @@
     "BaseMiddleware",
 ]
 
+ProxiesTypes = typing.Union[
+    URLTypes,
+    "AsyncDispatcher",
+    typing.Dict[URLTypes, typing.Union[URLTypes, "AsyncDispatcher"]],
+]
+
 AsyncRequestData = typing.Union[dict, str, bytes, typing.AsyncIterator[bytes]]
 
 RequestData = typing.Union[dict, str, bytes, typing.Iterator[bytes]]
diff --git a/httpx/utils.py b/httpx/utils.py
index f6b83c89db..bd85fc9fd4 100644
--- a/httpx/utils.py
+++ b/httpx/utils.py
@@ -6,6 +6,7 @@
 import sys
 import typing
 from pathlib import Path
+from urllib.request import getproxies
 
 
 def normalize_header_key(value: typing.AnyStr, encoding: str = None) -> bytes:
@@ -171,14 +172,44 @@ def get_logger(name: str) -> logging.Logger:
     return logging.getLogger(name)
 
 
+def get_environment_proxies() -> typing.Dict[str, str]:
+    """Gets proxy information from the environment"""
+
+    # urllib.request.getproxies() falls back on System
+    # Registry and Config for proxies on Windows and macOS.
+    # We don't want to propagate non-HTTP proxies into
+    # our configuration such as 'TRAVIS_APT_PROXY'.
+    proxies = {
+        key: val
+        for key, val in getproxies().items()
+        if ("://" in key or key in ("http", "https"))
+    }
+
+    # Favor lowercase environment variables over uppercase.
+    all_proxy = get_environ_lower_and_upper("ALL_PROXY")
+    if all_proxy is not None:
+        proxies["all"] = all_proxy
+
+    return proxies
+
+
+def get_environ_lower_and_upper(key: str) -> typing.Optional[str]:
+    """Gets a value from os.environ with both the lowercase and uppercase
+    environment variable. Prioritizes the lowercase environment variable.
+    """
+    for key in (key.lower(), key.upper()):
+        value = os.environ.get(key, None)
+        if value is not None and isinstance(value, str):
+            return value
+    return None
+
+
 def to_bytes(value: typing.Union[str, bytes], encoding: str = "utf-8") -> bytes:
     return value.encode(encoding) if isinstance(value, str) else value
 
 
-def to_str(str_or_bytes: typing.Union[str, bytes], encoding: str = "utf-8") -> str:
-    return (
-        str_or_bytes if isinstance(str_or_bytes, str) else str_or_bytes.decode(encoding)
-    )
+def to_str(value: typing.Union[str, bytes], encoding: str = "utf-8") -> str:
+    return value if isinstance(value, str) else value.decode(encoding)
 
 
 def unquote(value: str) -> str:
diff --git a/tests/client/test_proxies.py b/tests/client/test_proxies.py
new file mode 100644
index 0000000000..19521b7e43
--- /dev/null
+++ b/tests/client/test_proxies.py
@@ -0,0 +1,29 @@
+import pytest
+
+import httpx
+
+
+@pytest.mark.parametrize(
+    ["proxies", "expected_proxies"],
+    [
+        ("http://127.0.0.1", [("all", "http://127.0.0.1")]),
+        ({"all": "http://127.0.0.1"}, [("all", "http://127.0.0.1")]),
+        (
+            {"http": "http://127.0.0.1", "https": "https://127.0.0.1"},
+            [("http", "http://127.0.0.1"), ("https", "https://127.0.0.1")],
+        ),
+        (httpx.HTTPProxy("http://127.0.0.1"), [("all", "http://127.0.0.1")]),
+        (
+            {"https": httpx.HTTPProxy("https://127.0.0.1"), "all": "http://127.0.0.1"},
+            [("all", "http://127.0.0.1"), ("https", "https://127.0.0.1")],
+        ),
+    ],
+)
+def test_proxies_parameter(proxies, expected_proxies):
+    client = httpx.Client(proxies=proxies)
+
+    for proxy_key, url in expected_proxies:
+        assert proxy_key in client.proxies
+        assert client.proxies[proxy_key].proxy_url == url
+
+    assert len(expected_proxies) == len(client.proxies)
diff --git a/tests/dispatch/test_proxy_http.py b/tests/dispatch/test_proxy_http.py
index 0302f46b5f..edde6e74a9 100644
--- a/tests/dispatch/test_proxy_http.py
+++ b/tests/dispatch/test_proxy_http.py
@@ -88,13 +88,24 @@ async def test_proxy_tunnel_start_tls(backend):
     raw_io = MockRawSocketBackend(
         data_to_send=(
             [
+                # Tunnel Response
                 b"HTTP/1.1 200 OK\r\n"
                 b"Date: Sun, 10 Oct 2010 23:26:07 GMT\r\n"
                 b"Server: proxy-server\r\n"
                 b"\r\n",
+                # Response 1
                 b"HTTP/1.1 404 Not Found\r\n"
                 b"Date: Sun, 10 Oct 2010 23:26:07 GMT\r\n"
                 b"Server: origin-server\r\n"
+                b"Connection: keep-alive\r\n"
+                b"Content-Length: 0\r\n"
+                b"\r\n",
+                # Response 2
+                b"HTTP/1.1 200 OK\r\n"
+                b"Date: Sun, 10 Oct 2010 23:26:07 GMT\r\n"
+                b"Server: origin-server\r\n"
+                b"Connection: keep-alive\r\n"
+                b"Content-Length: 0\r\n"
                 b"\r\n",
             ]
         ),
@@ -105,23 +116,36 @@ async def test_proxy_tunnel_start_tls(backend):
         backend=raw_io,
         proxy_mode=httpx.HTTPProxyMode.TUNNEL_ONLY,
     ) as proxy:
-        response = await proxy.request("GET", f"https://example.com")
+        response1 = await proxy.request("GET", f"https://example.com")
 
-        assert response.status_code == 404
-        assert response.headers["Server"] == "origin-server"
+        assert response1.status_code == 404
+        assert response1.headers["Server"] == "origin-server"
 
-        assert response.request.method == "GET"
-        assert response.request.url == "https://example.com"
-        assert response.request.headers["Host"] == "example.com"
+        assert response1.request.method == "GET"
+        assert response1.request.url == "https://example.com"
+        assert response1.request.headers["Host"] == "example.com"
+        await response1.read()
+
+        # Make another request to see that the tunnel is re-used.
+        response2 = await proxy.request("GET", f"https://example.com/target")
+
+        assert response2.status_code == 200
+        assert response2.headers["Server"] == "origin-server"
+
+        assert response2.request.method == "GET"
+        assert response2.request.url == "https://example.com/target"
+        assert response2.request.headers["Host"] == "example.com"
+        await response2.read()
 
     recv = raw_io.received_data
-    assert len(recv) == 4
+    assert len(recv) == 5
     assert recv[0] == b"--- CONNECT(127.0.0.1, 8000) ---"
     assert recv[1].startswith(
         b"CONNECT example.com:443 HTTP/1.1\r\nhost: 127.0.0.1:8000\r\n"
     )
     assert recv[2] == b"--- START_TLS(example.com) ---"
     assert recv[3].startswith(b"GET / HTTP/1.1\r\nhost: example.com\r\n")
+    assert recv[4].startswith(b"GET /target HTTP/1.1\r\nhost: example.com\r\n")
 
 
 @pytest.mark.parametrize(
diff --git a/tests/dispatch/utils.py b/tests/dispatch/utils.py
index b8bbb78064..38c1b24ec9 100644
--- a/tests/dispatch/utils.py
+++ b/tests/dispatch/utils.py
@@ -216,5 +216,8 @@ async def read(self, n, timeout, flag=None) -> bytes:
             return b""
         return self.backend.data_to_send.pop(0)
 
+    def is_connection_dropped(self) -> bool:
+        return False
+
     async def close(self) -> None:
         pass
diff --git a/tests/test_utils.py b/tests/test_utils.py
index d3dad731f2..e688f272cb 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -5,7 +5,12 @@
 
 import httpx
 from httpx import utils
-from httpx.utils import get_netrc_login, guess_json_utf, parse_header_links
+from httpx.utils import (
+    get_environment_proxies,
+    get_netrc_login,
+    guess_json_utf,
+    parse_header_links,
+)
 
 
 @pytest.mark.parametrize(
@@ -111,3 +116,29 @@ async def test_httpx_debug_enabled_stderr_logging(server, capsys, httpx_debug):
 
     # Reset the logger so we don't have verbose output in all unit tests
     logging.getLogger("httpx").handlers = []
+
+
+@pytest.mark.parametrize(
+    ["environment", "proxies"],
+    [
+        ({}, {}),
+        ({"HTTP_PROXY": "http://127.0.0.1"}, {"http": "http://127.0.0.1"}),
+        (
+            {"https_proxy": "http://127.0.0.1", "HTTP_PROXY": "https://127.0.0.1"},
+            {"https": "http://127.0.0.1", "http": "https://127.0.0.1"},
+        ),
+        (
+            {"all_proxy": "http://127.0.0.1", "ALL_PROXY": "https://1.1.1.1"},
+            {"all": "http://127.0.0.1"},
+        ),
+        (
+            {"https_proxy": "http://127.0.0.1", "HTTPS_PROXY": "https://1.1.1.1"},
+            {"https": "http://127.0.0.1"},
+        ),
+        ({"TRAVIS_APT_PROXY": "http://127.0.0.1"}, {}),
+    ],
+)
+def test_get_environment_proxies(environment, proxies):
+    os.environ.update(environment)
+
+    assert get_environment_proxies() == proxies


Code review comments:
diff_hunk: @@ -162,13 +173,20 @@ def merge_headers(
         cert: CertTypes = None,
         timeout: TimeoutTypes = None,
         trust_env: bool = None,
+        proxies: ProxiesTypes = None,
     ) -> AsyncResponse:
         if request.url.scheme not in ("http", "https"):
             raise InvalidURL('URL scheme must be "http" or "https".')
 
+        if proxies is not None:
+            dispatch_proxies = _proxies_to_dispatchers(proxies)
+        else:
+            dispatch_proxies = proxies or self.proxies
path: httpx/client.py
text: @user1:
This `or self.proxies` is not necessary, as `proxies` will be `None`?

@author:
Good catch!