Repository: urllib3/urllib3

Issue:
DNS lookup error exception
I am using `requests` to check domain status for `k8s.io` domains (https://github.com/kubernetes/k8s.io/pull/30) and I can't find a good way to check for DNS lookup error like `ERR_NAME_NOT_RESOLVED` in Chrome. Exceptions output seems platform specific:

```
    Traceback (most recent call last):
      File "check.py", line 11, in <module>
        status = requests.head('http://' + site)
      File "C:\Python27\lib\site-packages\requests\api.py", line 93, in head
        return request('head', url, **kwargs)
      File "C:\Python27\lib\site-packages\requests\api.py", line 53, in request
        return session.request(method=method, url=url, **kwargs)
      File "C:\Python27\lib\site-packages\requests\sessions.py", line 468, in request
        resp = self.send(prep, **send_kwargs)
      File "C:\Python27\lib\site-packages\requests\sessions.py", line 576, in send
        r = adapter.send(request, **kwargs)
      File "C:\Python27\lib\site-packages\requests\adapters.py", line 437, in send
        raise ConnectionError(e, request=request)
    requests.exceptions.ConnectionError: HTTPConnectionPool(host='jenkins.k8s.io', port=80):
        Max retries exceeded with url: / (Caused by
        NewConnectionError('<requests.packages.urllib3.connection.HTTPConnection object at
        0x029E43B0>: Failed to establish a new connection: [Errno 11001] getaddrinfo failed',))
```

So I was redirected here from https://github.com/kennethreitz/requests/issues/3630#issuecomment-255031285 to see if `urllib3` could expose more fine-grained DNSLookupError exception and maybe remove MaxRetryError wrapper.
## 


Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/urllib3/urllib3/pull/2305?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3) Report
> Merging [#2305](https://codecov.io/gh/urllib3/urllib3/pull/2305?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3) (938f899) into [main](https://codecov.io/gh/urllib3/urllib3/commit/6622092ea119c6867bb9a9f9d6bb86e0f4c59526?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3) (6622092) will **decrease** coverage by `0.36%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/urllib3/urllib3/pull/2305/graphs/tree.svg?width=650&height=150&src=pr&token=UYKHB2uXDn&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3)](https://codecov.io/gh/urllib3/urllib3/pull/2305?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3)

```diff
@@             Coverage Diff             @@
##              main    #2305      +/-   ##
===========================================
- Coverage   100.00%   99.63%   -0.37%     
===========================================
  Files           25       25              
  Lines         2439     2444       +5     
===========================================
- Hits          2439     2435       -4     
- Misses           0        9       +9     
```


| [Impacted Files](https://codecov.io/gh/urllib3/urllib3/pull/2305?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3) | Coverage Δ | |
|---|---|---|
| [src/urllib3/connectionpool.py](https://codecov.io/gh/urllib3/urllib3/pull/2305/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3#diff-c3JjL3VybGxpYjMvY29ubmVjdGlvbnBvb2wucHk=) | `100.00% <ø> (ø)` | |
| [src/urllib3/connection.py](https://codecov.io/gh/urllib3/urllib3/pull/2305/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3#diff-c3JjL3VybGxpYjMvY29ubmVjdGlvbi5weQ==) | `98.82% <100.00%> (-1.18%)` | :arrow_down: |
| [src/urllib3/exceptions.py](https://codecov.io/gh/urllib3/urllib3/pull/2305/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3#diff-c3JjL3VybGxpYjMvZXhjZXB0aW9ucy5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/urllib3/util/connection.py](https://codecov.io/gh/urllib3/urllib3/pull/2305/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3#diff-c3JjL3VybGxpYjMvdXRpbC9jb25uZWN0aW9uLnB5) | `100.00% <100.00%> (ø)` | |
| [src/urllib3/util/wait.py](https://codecov.io/gh/urllib3/urllib3/pull/2305/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3#diff-c3JjL3VybGxpYjMvdXRpbC93YWl0LnB5) | `91.11% <0.00%> (-8.89%)` | :arrow_down: |
| [src/urllib3/\_collections.py](https://codecov.io/gh/urllib3/urllib3/pull/2305/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3#diff-c3JjL3VybGxpYjMvX2NvbGxlY3Rpb25zLnB5) | `99.42% <0.00%> (-0.58%)` | :arrow_down: |
| [src/urllib3/response.py](https://codecov.io/gh/urllib3/urllib3/pull/2305/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3#diff-c3JjL3VybGxpYjMvcmVzcG9uc2UucHk=) | `99.49% <0.00%> (-0.51%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/urllib3/urllib3/pull/2305?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/urllib3/urllib3/pull/2305?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3). Last update [6622092...938f899](https://codecov.io/gh/urllib3/urllib3/pull/2305?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3).

pquentin: Thank you for tackling this! Please let us know when you'd like a review
euri10: it seems raising socket.gaierror does not yield the same message on various platforms, so I had to remove the specific match in `with pytest.raises(socket.gaierror),  match=......):` in the `test.test_util.TestUtil.test_dnsresolver_error` because the CI [failed](https://github.com/urllib3/urllib3/runs/2912067602#step:8:100) on this test

windows: [Errno 11001] getaddrinfo failed in windows
linux: [Errno -2] Name or service not known

The docs say about the [error](https://docs.python.org/3/library/socket.html#socket.gaierror) that:
> The numeric error value will match one of the EAI_* constants defined in this module

so given the source [here](https://github.com/python/cpython/blob/bb3e0c240bc60fe08d332ff5955d54197f79751c/Modules/addrinfo.h#L58-L71) I'm not sure how to interpret the `11001` win error, I'm not familiar enough with how windows errors are "translated", would like some pointers here
```
#define EAI_ADDRFAMILY   1      /* address family for hostname not supported */
#define EAI_AGAIN        2      /* temporary failure in name resolution */
#define EAI_BADFLAGS     3      /* invalid value for ai_flags */
#define EAI_FAIL         4      /* non-recoverable failure in name resolution */
#define EAI_FAMILY       5      /* ai_family not supported */
#define EAI_MEMORY       6      /* memory allocation failure */
#define EAI_NODATA       7      /* no address associated with hostname */
#define EAI_NONAME       8      /* hostname nor servname provided, or not known */
#define EAI_SERVICE      9      /* servname not supported for ai_socktype */
#define EAI_SOCKTYPE    10      /* ai_socktype not supported */
#define EAI_SYSTEM      11      /* system error returned in errno */
#define EAI_BADHINTS    12
#define EAI_PROTOCOL    13
#define EAI_MAX         14
```
pquentin: I'm pretty sure `EAI_*` is Unix or Linux-only, Windows uses completely different errors, as you've seen. See https://docs.microsoft.com/en-us/windows/win32/winsock/windows-sockets-error-codes-2 which tells us that `11001` is `WSAHOST_NOT_FOUND`
euri10: I think it's OK for a review

I would have liked to add a more fine-grained test (with a match in the test.test_util.TestUtil.test_dnsresolver_error) which checks for the gaierror reason, but it's too OS specific, and I'm sure it adds lots of value.

I also tried to take into account [this comment](https://github.com/urllib3/urllib3/pull/1827#discussion_r401218612) from a previous attempt, but I found that if you do that, you'll get the NameResolutionError switched to a ProtocolError in [here](https://github.com/urllib3/urllib3/blob/3d8c93d96938afea4a7a9490b41d07d999d348c6/src/urllib3/connectionpool.py#L751-L752) because gaierror is a sublass of OSError (not sure I get this correctly though, I begin so I have the exception layout not quite well in mind !)

Patch:
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 9b435ced..6cbf5a88 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,7 +1,7 @@
 blank_issues_enabled: false
 contact_links:
   - name: 📚 Documentation
-    url: https://urllib3.readthedocs.io/en/latest/
+    url: https://urllib3.readthedocs.io
     about: Make sure you read the relevant docs
   - name: ❓ Ask on StackOverflow
     url: https://stackoverflow.com/questions/tagged/urllib3
diff --git a/setup.cfg b/setup.cfg
index e6680da4..0fe7a8ce 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -5,11 +5,11 @@ max-line-length=99
 
 [metadata]
 license_file = LICENSE.txt
-provides-extra =
+provides_extra =
     secure
     socks
     brotli
-requires-dist =
+requires_dist =
     pyOpenSSL>=0.14; extra == 'secure'
     cryptography>=1.3.4; extra == 'secure'
     idna>=2.0.0; extra == 'secure'
diff --git a/src/urllib3/connection.py b/src/urllib3/connection.py
index 59e93e28..d21b0856 100644
--- a/src/urllib3/connection.py
+++ b/src/urllib3/connection.py
@@ -44,6 +44,7 @@ from ._version import __version__
 from .exceptions import (
     ConnectTimeoutError,
     HTTPSProxyError,
+    NameResolutionError,
     NewConnectionError,
     SystemTimeWarning,
 )
@@ -205,7 +206,8 @@ class HTTPConnection(_HTTPConnection):
                 source_address=self.source_address,
                 socket_options=self.socket_options,
             )
-
+        except socket.gaierror as e:
+            raise NameResolutionError(self.host, e)
         except SocketTimeout:
             raise ConnectTimeoutError(
                 self,
diff --git a/src/urllib3/connectionpool.py b/src/urllib3/connectionpool.py
index a719c2c3..58420210 100644
--- a/src/urllib3/connectionpool.py
+++ b/src/urllib3/connectionpool.py
@@ -30,6 +30,7 @@ from .exceptions import (
     InsecureRequestWarning,
     LocationValueError,
     MaxRetryError,
+    NameResolutionError,
     NewConnectionError,
     ProtocolError,
     ProxyError,
@@ -738,12 +739,15 @@ class HTTPConnectionPool(ConnectionPool, RequestMethods):
             SSLError,
             CertificateError,
             HTTPSProxyError,
+            NameResolutionError,
         ) as e:
             # Discard the connection for these exceptions. It will be
             # replaced during the next _get_conn() call.
             clean_exit = False
             if isinstance(e, (BaseSSLError, CertificateError)):
                 e = SSLError(e)
+            elif isinstance(e, NameResolutionError):
+                pass
             elif isinstance(e, (OSError, NewConnectionError)) and self.proxy:
                 e = ProxyError("Cannot connect to proxy.", e)
             elif isinstance(e, (OSError, HTTPException)):
diff --git a/src/urllib3/exceptions.py b/src/urllib3/exceptions.py
index 01854050..c3f502f7 100644
--- a/src/urllib3/exceptions.py
+++ b/src/urllib3/exceptions.py
@@ -1,3 +1,4 @@
+import socket
 import warnings
 from http.client import IncompleteRead as httplib_IncompleteRead
 from typing import TYPE_CHECKING, Callable, List, Optional, Tuple, Union
@@ -376,3 +377,11 @@ class UnrewindableBodyError(HTTPError):
     """urllib3 encountered an error when trying to rewind a body"""
 
     pass
+
+
+class NameResolutionError(HTTPError, socket.gaierror):
+    """Raised when host name resolution fails."""
+
+    def __init__(self, host: str, reason: socket.gaierror):
+        message = f"Failed to resolve '{host}' ({reason})"
+        HTTPError.__init__(self, message)


Test patch:
diff --git a/test/__init__.py b/test/__init__.py
index 57106516..7f4a085a 100644
--- a/test/__init__.py
+++ b/test/__init__.py
@@ -41,7 +41,7 @@ else:
 VALID_SOURCE_ADDRESSES = [(("::1", 0), True), (("127.0.0.1", 0), False)]
 # RFC 5737: 192.0.2.0/24 is for testing only.
 # RFC 3849: 2001:db8::/32 is for documentation only.
-INVALID_SOURCE_ADDRESSES = [("192.0.2.255", 0), ("2001:db8::1", 0)]
+INVALID_SOURCE_ADDRESSES = [(("192.0.2.255", 0), False), (("2001:db8::1", 0), True)]
 
 # We use timeouts in three different ways in our tests
 #
diff --git a/test/test_util.py b/test/test_util.py
index 70c0f633..0a364559 100644
--- a/test/test_util.py
+++ b/test/test_util.py
@@ -762,6 +762,20 @@ class TestUtil:
         with pytest.raises(OSError, match="getaddrinfo returns an empty list"):
             create_connection(("example.com", 80))
 
+    @patch("socket.getaddrinfo")
+    def test_dnsresolver_forced_error(self, getaddrinfo):
+        getaddrinfo.side_effect = socket.gaierror()
+        with pytest.raises(socket.gaierror):
+            # dns is valid but we force the error just for the sake of the test
+            create_connection(("example.com", 80))
+
+    def test_dnsresolver_expected_error(self):
+        with pytest.raises(socket.gaierror):
+            # windows: [Errno 11001] getaddrinfo failed in windows
+            # linux: [Errno -2] Name or service not known
+            # macos: [Errno 8] nodename nor servname provided, or not known
+            create_connection(("badhost.invalid", 80))
+
     @pytest.mark.parametrize(
         "input,params,expected",
         (
diff --git a/test/with_dummyserver/test_connectionpool.py b/test/with_dummyserver/test_connectionpool.py
index 3da8d38f..e4eacc29 100644
--- a/test/with_dummyserver/test_connectionpool.py
+++ b/test/with_dummyserver/test_connectionpool.py
@@ -22,6 +22,7 @@ from urllib3.exceptions import (
     DecodeError,
     EmptyPoolError,
     MaxRetryError,
+    NameResolutionError,
     NewConnectionError,
     ReadTimeoutError,
     UnrewindableBodyError,
@@ -417,7 +418,7 @@ class TestConnectionPool(HTTPDummyServerTestCase):
         with HTTPConnectionPool("badhost.invalid", self.port) as pool:
             with pytest.raises(MaxRetryError) as e:
                 pool.request("GET", "/", retries=5)
-            assert type(e.value.reason) == NewConnectionError
+            assert type(e.value.reason) == NameResolutionError
 
     def test_keepalive(self):
         with HTTPConnectionPool(self.host, self.port, block=True, maxsize=1) as pool:
@@ -709,13 +710,19 @@ class TestConnectionPool(HTTPDummyServerTestCase):
                 r = pool.request("GET", "/source_address")
                 assert r.data == addr[0].encode()
 
-    def test_source_address_error(self):
-        for addr in INVALID_SOURCE_ADDRESSES:
-            with HTTPConnectionPool(
-                self.host, self.port, source_address=addr, retries=False
-            ) as pool:
+    @pytest.mark.parametrize(
+        "invalid_source_address, is_ipv6", INVALID_SOURCE_ADDRESSES
+    )
+    def test_source_address_error(self, invalid_source_address, is_ipv6):
+        with HTTPConnectionPool(
+            self.host, self.port, source_address=invalid_source_address, retries=False
+        ) as pool:
+            if is_ipv6:
+                with pytest.raises(NameResolutionError):
+                    pool.request("GET", f"/source_address?{invalid_source_address}")
+            else:
                 with pytest.raises(NewConnectionError):
-                    pool.request("GET", f"/source_address?{addr}")
+                    pool.request("GET", f"/source_address?{invalid_source_address}")
 
     def test_stream_keepalive(self):
         x = 2
@@ -1024,7 +1031,7 @@ class TestRetry(HTTPDummyServerTestCase):
         with HTTPConnectionPool(
             "thishostdoesnotexist.invalid", self.port, timeout=0.001
         ) as pool:
-            with pytest.raises(NewConnectionError):
+            with pytest.raises(NameResolutionError):
                 pool.request("GET", "/test", retries=False)
 
     def test_read_retries(self):
diff --git a/test/with_dummyserver/test_proxy_poolmanager.py b/test/with_dummyserver/test_proxy_poolmanager.py
index 76094daa..d46f7eb9 100644
--- a/test/with_dummyserver/test_proxy_poolmanager.py
+++ b/test/with_dummyserver/test_proxy_poolmanager.py
@@ -16,7 +16,7 @@ from urllib3.exceptions import (
     ConnectTimeoutError,
     HTTPSProxyError,
     MaxRetryError,
-    ProxyError,
+    NameResolutionError,
     ProxySchemeUnknown,
     ProxySchemeUnsupported,
     SSLError,
@@ -146,7 +146,7 @@ class TestHTTPProxyManager(HTTPDummyProxyTestCase):
 
             with pytest.raises(MaxRetryError) as e:
                 http.request("GET", f"{self.http_url}/")
-            assert type(e.value.reason) == ProxyError
+            assert type(e.value.reason) == NameResolutionError
 
     def test_https_conn_failed(self):
         """