Skip to content

Commit 03b1104

Browse files
committed
Add four diagnostic connection errors
- ConnectionFailedError - ConnectionTimeoutError - SSLVerificationError - ProxyConnectionError The first one is the most generic of the bunch. It's the error that is raised when is no better (or at least defined) error to raise. This sucks, but unfortunately requests and in particular urllib3 errors are terrible. They lack so much context that it is challenging to present specific, actionable errors. For example, there's no way to deduce that a urllib3 NewConnectionError is caused by DNS unless you inspect the error chain (for socket.gaierror) or parse the raw error string (which is too hacky for my taste). The other three are a lot better, although they are admittedly still rather vague. The SSLVerificationError can't discern whether it was caused by an expired or self-signed certificate for example. To work around this, I've opted to include the original error as additional context (with some limited rewriting if possible). Anyway, the main benefit here is that despite the coarseness, we're able to provide at least some actionable advice through the diagnostic hints. In addition, these errors are way more readable than whatever requests or urllib3 spits out (often being caught, stringified and presented in single-line screaming red by pip).
1 parent 60051f1 commit 03b1104

File tree

4 files changed

+175
-16
lines changed

4 files changed

+175
-16
lines changed

src/pip/_internal/exceptions.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import pathlib
1313
import re
1414
import sys
15+
from http.client import RemoteDisconnected
1516
from itertools import chain, groupby, repeat
1617
from typing import TYPE_CHECKING, Dict, Iterator, List, Literal, Optional, Union
1718

@@ -22,6 +23,7 @@
2223
if TYPE_CHECKING:
2324
from hashlib import _Hash
2425

26+
from pip._vendor import urllib3
2527
from pip._vendor.requests.models import Request, Response
2628

2729
from pip._internal.metadata import BaseDistribution
@@ -312,6 +314,109 @@ def __str__(self) -> str:
312314
return str(self.error_msg)
313315

314316

317+
class ConnectionFailedError(DiagnosticPipError):
318+
reference = "connection-failed"
319+
320+
def __init__(self, url: str, host: str, error: Exception) -> None:
321+
from pip._vendor.urllib3.exceptions import NewConnectionError, ProtocolError
322+
323+
details = str(error)
324+
if isinstance(error, NewConnectionError):
325+
parts = details.split("Failed to establish a new connection: ", maxsplit=1)
326+
if len(parts) == 2:
327+
_, details = parts
328+
elif isinstance(error, ProtocolError):
329+
try:
330+
reason = error.args[1]
331+
except IndexError:
332+
pass
333+
else:
334+
if isinstance(reason, (RemoteDisconnected, ConnectionResetError)):
335+
details = (
336+
"the connection was closed without a reply from the server."
337+
)
338+
339+
super().__init__(
340+
message=f"Failed to connect to [magenta]{host}[/] while fetching {url}",
341+
context=Text(f"Details: {escape(details)}"),
342+
hint_stmt=(
343+
"This is likely a system or network issue. Are you connected to the "
344+
"Internet? If so, check whether your system can connect to "
345+
f"[magenta]{host}[/] before trying again. There may be a firewall or "
346+
"proxy that's preventing the connection."
347+
),
348+
)
349+
350+
351+
class ConnectionTimeoutError(DiagnosticPipError):
352+
reference = "connection-timeout"
353+
354+
def __init__(
355+
self, url: str, host: str, *, kind: Literal["connect", "read"], timeout: float
356+
) -> None:
357+
context = Text.assemble(
358+
(host, "magenta"), f" didn't respond within {timeout} seconds"
359+
)
360+
if kind == "connect":
361+
context.append(" (while establishing a connection)")
362+
super().__init__(
363+
message=f"Unable to fetch {url}",
364+
context=context,
365+
hint_stmt=(
366+
"This is probably a temporary issue with the remote server or the "
367+
"network connection. If this error persists, check your system or "
368+
"pip's network configuration. There may be a firewall or proxy that's "
369+
"preventing the connection."
370+
),
371+
)
372+
373+
374+
class SSLVerificationError(DiagnosticPipError):
375+
reference = "ssl-verification-failed"
376+
377+
def __init__(
378+
self,
379+
url: str,
380+
host: str,
381+
error: "urllib3.exceptions.SSLError",
382+
*,
383+
is_tls_available: bool,
384+
) -> None:
385+
message = (
386+
"Failed to establish a secure connection to "
387+
f"[magenta]{host}[/] while fetching {url}"
388+
)
389+
if not is_tls_available:
390+
# A lack of TLS support isn't guaranteed to be the cause of this error,
391+
# but we'll assume that it's the culprit.
392+
context = Text("The built-in ssl module is not available.")
393+
hint = (
394+
"Your Python installation is missing SSL/TLS support, which is "
395+
"required to access HTTPS URLs."
396+
)
397+
else:
398+
context = Text(f"Details: {escape(str(error))}")
399+
hint = (
400+
"This was likely caused by the system or pip's network configuration."
401+
)
402+
super().__init__(message=message, context=context, hint_stmt=hint)
403+
404+
405+
class ProxyConnectionError(DiagnosticPipError):
406+
reference = "proxy-connection-failed"
407+
408+
def __init__(
409+
self, url: str, proxy: str, error: "urllib3.exceptions.ProxyError"
410+
) -> None:
411+
super().__init__(
412+
message=(
413+
f"Failed to connect to proxy [magenta]{proxy}[/] while fetching {url}"
414+
),
415+
context=Text(f"Details: {escape(str(error.original_error))}"),
416+
hint_stmt="This is likely a proxy configuration issue.",
417+
)
418+
419+
315420
class InvalidWheelFilename(InstallationError):
316421
"""Invalid wheel filename."""
317422

src/pip/_internal/index/collector.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,16 @@
2828
Union,
2929
)
3030

31-
from pip._vendor import requests
3231
from pip._vendor.requests import Response
33-
from pip._vendor.requests.exceptions import RetryError, SSLError
34-
35-
from pip._internal.exceptions import NetworkConnectionError
32+
from pip._vendor.requests.exceptions import RetryError
33+
34+
from pip._internal.exceptions import (
35+
ConnectionFailedError,
36+
ConnectionTimeoutError,
37+
NetworkConnectionError,
38+
ProxyConnectionError,
39+
SSLVerificationError,
40+
)
3641
from pip._internal.models.link import Link
3742
from pip._internal.models.search_scope import SearchScope
3843
from pip._internal.network.session import PipSession
@@ -365,17 +370,16 @@ def _get_index_content(link: Link, *, session: PipSession) -> Optional["IndexCon
365370
exc.request_desc,
366371
exc.content_type,
367372
)
368-
except NetworkConnectionError as exc:
369-
_handle_get_simple_fail(link, exc)
370-
except RetryError as exc:
373+
except (RetryError, NetworkConnectionError) as exc:
371374
_handle_get_simple_fail(link, exc)
372-
except SSLError as exc:
373-
reason = "There was a problem confirming the ssl certificate: "
374-
reason += str(exc)
375+
except SSLVerificationError as exc:
376+
reason = f"There was a problem confirming the ssl certificate: {exc.context}"
375377
_handle_get_simple_fail(link, reason, meth=logger.info)
376-
except requests.ConnectionError as exc:
377-
_handle_get_simple_fail(link, f"connection error: {exc}")
378-
except requests.Timeout:
378+
except ConnectionFailedError as exc:
379+
_handle_get_simple_fail(link, f"connection error: {exc.context}")
380+
except ProxyConnectionError as exc:
381+
_handle_get_simple_fail(link, f"proxy connection error: {exc.context}")
382+
except ConnectionTimeoutError:
379383
_handle_get_simple_fail(link, "timed out")
380384
else:
381385
return _make_index_content(resp, cache_link_parsing=link.cache_link_parsing)

src/pip/_internal/network/session.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
from pip._internal.models.link import Link
4444
from pip._internal.network.auth import MultiDomainBasicAuth
4545
from pip._internal.network.cache import SafeFileCache
46-
from pip._internal.network.utils import Urllib3RetryFilter
46+
from pip._internal.network.utils import Urllib3RetryFilter, raise_connection_error
4747

4848
# Import ssl from compat so the initial import occurs in only one place.
4949
from pip._internal.utils.compat import has_tls
@@ -522,4 +522,7 @@ def request(self, method: str, url: str, *args: Any, **kwargs: Any) -> Response:
522522
kwargs.setdefault("proxies", self.proxies)
523523

524524
# Dispatch the actual request
525-
return super().request(method, url, *args, **kwargs)
525+
try:
526+
return super().request(method, url, *args, **kwargs)
527+
except requests.ConnectionError as e:
528+
raise_connection_error(e, timeout=kwargs["timeout"])

src/pip/_internal/network/utils.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
import logging
22
import re
3+
import urllib.parse
34
from http.client import RemoteDisconnected
45
from typing import Dict, Generator
56

6-
from pip._vendor import urllib3
7+
from pip._vendor import requests, urllib3
78
from pip._vendor.requests.models import Response
89

910
from pip._internal.exceptions import (
11+
ConnectionFailedError,
12+
ConnectionTimeoutError,
1013
NetworkConnectionError,
14+
ProxyConnectionError,
15+
SSLVerificationError,
1116
)
17+
from pip._internal.utils.compat import has_tls
1218
from pip._internal.utils.logging import VERBOSE
1319

1420
# The following comments and HTTP headers were originally added by
@@ -107,6 +113,47 @@ def response_chunks(
107113
yield chunk
108114

109115

116+
def raise_connection_error(error: requests.ConnectionError, *, timeout: float) -> None:
117+
"""Raise a specific error for a given ConnectionError, if possible.
118+
119+
Note: requests.ConnectionError is the parent class of
120+
requests.ProxyError, requests.SSLError, and requests.ConnectTimeout
121+
so these errors are also handled here. In addition, a ReadTimeout
122+
wrapped in a requests.MayRetryError is converted into a
123+
ConnectionError by requests internally.
124+
"""
125+
url = error.request.url
126+
reason = error.args[0]
127+
if not isinstance(reason, urllib3.exceptions.MaxRetryError):
128+
# This is unlikely (or impossible as even --retries 0 still results in a
129+
# MaxRetryError...?!), but being defensive can't hurt.
130+
host = urllib.parse.urlsplit(url).netloc
131+
raise ConnectionFailedError(url, host, reason)
132+
133+
assert isinstance(reason.pool, urllib3.connectionpool.HTTPConnectionPool)
134+
host = reason.pool.host
135+
proxy = reason.pool.proxy
136+
# Narrow the reason further to the specific error from the last retry.
137+
reason = reason.reason
138+
139+
if isinstance(reason, urllib3.exceptions.SSLError):
140+
raise SSLVerificationError(url, host, reason, is_tls_available=has_tls())
141+
# NewConnectionError is a subclass of TimeoutError for some reason...
142+
if isinstance(reason, urllib3.exceptions.TimeoutError) and not isinstance(
143+
reason, urllib3.exceptions.NewConnectionError
144+
):
145+
if isinstance(reason, urllib3.exceptions.ConnectTimeoutError):
146+
raise ConnectionTimeoutError(url, host, kind="connect", timeout=timeout)
147+
else:
148+
raise ConnectionTimeoutError(url, host, kind="read", timeout=timeout)
149+
if isinstance(reason, urllib3.exceptions.ProxyError):
150+
assert proxy is not None
151+
raise ProxyConnectionError(url, str(proxy), reason)
152+
153+
# Unknown error, give up and raise a generic error.
154+
raise ConnectionFailedError(url, host, reason)
155+
156+
110157
class Urllib3RetryFilter:
111158
"""A logging filter which attempts to rewrite urllib3's retrying
112159
warnings to be more readable and less technical.

0 commit comments

Comments
 (0)