Skip to content

Commit 3c4c844

Browse files
committed
Rewrite urllib3 retry warnings and raise a diagnostic error on
connection errors
1 parent 47ffcdd commit 3c4c844

File tree

4 files changed

+251
-9
lines changed

4 files changed

+251
-9
lines changed

src/pip/_internal/cli/index_command.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ def _build_session(
103103
trusted_hosts=options.trusted_hosts,
104104
index_urls=self._get_index_urls(options),
105105
ssl_context=ssl_context,
106+
timeout=(timeout if timeout is not None else options.timeout),
106107
)
107108

108109
# Handle custom ca-bundles from the user
@@ -113,10 +114,6 @@ def _build_session(
113114
if options.client_cert:
114115
session.cert = options.client_cert
115116

116-
# Handle timeouts
117-
if options.timeout or timeout:
118-
session.timeout = timeout if timeout is not None else options.timeout
119-
120117
# Handle configured proxies
121118
if options.proxy:
122119
session.proxies = {

src/pip/_internal/exceptions.py

Lines changed: 101 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,105 @@ 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+
_, details = str(error).split("Failed to establish a new connection: ")
326+
elif isinstance(error, ProtocolError):
327+
try:
328+
reason = error.args[1]
329+
except IndexError:
330+
pass
331+
else:
332+
if isinstance(reason, RemoteDisconnected):
333+
details = "the server closed the connection without replying."
334+
335+
super().__init__(
336+
message=f"Failed to connect to [magenta]{host}[/] while fetching {url}",
337+
context=Text(f"Details: {details}"),
338+
hint_stmt=(
339+
"This is likely a system or network issue. Are you connected to the "
340+
"Internet? Otherwise, check whether your system can connect to "
341+
f"[magenta]{host}[/] before trying again (check your firewall/proxy "
342+
"settings)."
343+
),
344+
)
345+
346+
347+
class ConnectionTimeoutError(DiagnosticPipError):
348+
reference = "connection-timeout"
349+
350+
def __init__(
351+
self, url: str, host: str, *, kind: Literal["connect", "read"], timeout: float
352+
) -> None:
353+
context = Text()
354+
context.append(host, style="magenta")
355+
context.append(f" didn't respond within {timeout} seconds")
356+
if kind == "connect":
357+
context.append(" (while establishing a connection)")
358+
super().__init__(
359+
message=f"Unable to fetch {url}",
360+
context=context,
361+
hint_stmt=(
362+
"This is likely a network problem, or a transient issue with the "
363+
"remote server."
364+
),
365+
)
366+
367+
368+
class SSLVerificationError(DiagnosticPipError):
369+
reference = "ssl-verification-failed"
370+
371+
def __init__(
372+
self,
373+
url: str,
374+
host: str,
375+
error: "urllib3.exceptions.SSLError",
376+
*,
377+
is_tls_available: bool,
378+
) -> None:
379+
message = (
380+
"Failed to establish a secure connection to "
381+
f"[magenta]{host}[/] while fetching {url}"
382+
)
383+
if not is_tls_available:
384+
context = Text("The built-in ssl module is not available.")
385+
hint = (
386+
"Your Python installation is missing SSL/TLS support, which is "
387+
"required to access HTTPS URLs."
388+
)
389+
else:
390+
context = Text(f"Details: {error!s}")
391+
hint = (
392+
"This was likely caused by the system or pip's network configuration."
393+
)
394+
super().__init__(message=message, context=context, hint_stmt=hint)
395+
396+
397+
class ProxyConnectionError(DiagnosticPipError):
398+
reference = "proxy-connection-failed"
399+
400+
def __init__(
401+
self, url: str, host: str, error: "urllib3.exceptions.ProxyError"
402+
) -> None:
403+
try:
404+
reason = error.args[1]
405+
except IndexError:
406+
reason = error
407+
super().__init__(
408+
message=(
409+
f"Failed to connect to proxy [magenta]{host}[/] while fetching {url}"
410+
),
411+
context=Text(f"Details: {reason!s}"),
412+
hint_stmt="This is likely a proxy configuration issue.",
413+
)
414+
415+
315416
class InvalidWheelFilename(InstallationError):
316417
"""Invalid wheel filename."""
317418

src/pip/_internal/network/session.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +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, raise_connection_error
4647

4748
# Import ssl from compat so the initial import occurs in only one place.
4849
from pip._internal.utils.compat import has_tls
@@ -318,11 +319,10 @@ def cert_verify(
318319

319320

320321
class PipSession(requests.Session):
321-
timeout: Optional[int] = None
322-
323322
def __init__(
324323
self,
325324
*args: Any,
325+
timeout: float = 60,
326326
retries: int = 0,
327327
cache: Optional[str] = None,
328328
trusted_hosts: Sequence[str] = (),
@@ -336,6 +336,10 @@ def __init__(
336336
"""
337337
super().__init__(*args, **kwargs)
338338

339+
retry_filter = Urllib3RetryFilter(timeout=timeout)
340+
logging.getLogger("pip._vendor.urllib3.connectionpool").addFilter(retry_filter)
341+
342+
self.timeout = timeout
339343
# Namespace the attribute with "pip_" just in case to prevent
340344
# possible conflicts with the base class.
341345
self.pip_trusted_origins: List[Tuple[str, Optional[int]]] = []
@@ -519,4 +523,7 @@ def request(self, method: str, url: str, *args: Any, **kwargs: Any) -> Response:
519523
kwargs.setdefault("proxies", self.proxies)
520524

521525
# Dispatch the actual request
522-
return super().request(method, url, *args, **kwargs)
526+
try:
527+
return super().request(method, url, *args, **kwargs)
528+
except requests.ConnectionError as e:
529+
raise_connection_error(e, timeout=self.timeout)

src/pip/_internal/network/utils.py

Lines changed: 139 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
1-
from typing import Dict, Generator
1+
import logging
2+
import urllib.parse
3+
from http.client import RemoteDisconnected
4+
from typing import Dict, Generator, Literal, Optional
25

6+
from pip._vendor import requests, urllib3
37
from pip._vendor.requests.models import CONTENT_CHUNK_SIZE, Response
48

5-
from pip._internal.exceptions import NetworkConnectionError
9+
from pip._internal.exceptions import (
10+
ConnectionFailedError,
11+
ConnectionTimeoutError,
12+
NetworkConnectionError,
13+
ProxyConnectionError,
14+
SSLVerificationError,
15+
)
16+
from pip._internal.utils.compat import has_tls
17+
from pip._internal.utils.logging import VERBOSE
618

719
# The following comments and HTTP headers were originally added by
820
# Donald Stufft in git commit 22c562429a61bb77172039e480873fb239dd8c03.
@@ -94,3 +106,128 @@ def response_chunks(
94106
if not chunk:
95107
break
96108
yield chunk
109+
110+
111+
def raise_connection_error(error: requests.ConnectionError, *, timeout: float) -> None:
112+
"""Raise a specific error for a given ConnectionError, if possible.
113+
114+
Note: requests.ConnectionError is the parent class of
115+
requests.ProxyError, requests.SSLError, and requests.ConnectTimeout
116+
so these errors are also handled here. In addition, a ReadTimeout
117+
wrapped in a requests.MayRetryError is converted into a
118+
ConnectionError by requests internally.
119+
"""
120+
# NOTE: this function was written defensively to degrade gracefully when
121+
# a specific error cannot be raised due to issues inspecting the exception
122+
# state. This logic isn't pretty.
123+
124+
url = error.request.url
125+
# requests.ConnectionError.args[0] should be an instance of
126+
# urllib3.exceptions.MaxRetryError.
127+
reason = error.args[0]
128+
# Attempt to query the host from the urllib3 error, otherwise fall back to
129+
# parsing the request URL.
130+
if isinstance(reason, urllib3.exceptions.MaxRetryError):
131+
host = reason.pool.host
132+
# Narrow the reason further to the specific error from the last retry.
133+
reason = reason.reason
134+
else:
135+
host = urllib.parse.urlsplit(error.request.url).netloc
136+
137+
if isinstance(reason, urllib3.exceptions.SSLError):
138+
raise SSLVerificationError(url, host, reason, is_tls_available=has_tls())
139+
# NewConnectionError is a subclass of TimeoutError for some reason ...
140+
if isinstance(reason, urllib3.exceptions.TimeoutError) and not isinstance(
141+
reason, urllib3.exceptions.NewConnectionError
142+
):
143+
kind: Literal["connect", "read"] = (
144+
"connect"
145+
if isinstance(reason, urllib3.exceptions.ConnectTimeoutError)
146+
else "read"
147+
)
148+
raise ConnectionTimeoutError(url, host, kind=kind, timeout=timeout)
149+
if isinstance(reason, urllib3.exceptions.ProxyError):
150+
raise ProxyConnectionError(url, host, reason)
151+
152+
# Unknown error, give up and raise a generic error.
153+
raise ConnectionFailedError(url, host, reason)
154+
155+
156+
class Urllib3RetryFilter:
157+
"""A logging filter which attempts to rewrite urllib3's retrying
158+
warnings to be more readable and less technical.
159+
"""
160+
161+
def __init__(self, *, timeout: Optional[float]) -> None:
162+
self.timeout = timeout
163+
self.verbose = logging.getLogger().isEnabledFor(VERBOSE) # HACK
164+
165+
def filter(self, record: logging.LogRecord) -> bool:
166+
# Attempt to "sniff out" the retrying warning.
167+
if not isinstance(record.args, tuple):
168+
return True
169+
170+
retry = next(
171+
(a for a in record.args if isinstance(a, urllib3.util.Retry)), None
172+
)
173+
if record.levelno != logging.WARNING or retry is None:
174+
# Not the right warning, leave it alone.
175+
return True
176+
177+
error = next((a for a in record.args if isinstance(a, Exception)), None)
178+
if error is None:
179+
# No error information available, leave it alone.
180+
return True
181+
182+
rewritten = False
183+
if isinstance(error, urllib3.exceptions.NewConnectionError):
184+
rewritten = True
185+
connection = error.pool
186+
record.msg = f"failed to connect to {connection.host}"
187+
if isinstance(connection, urllib3.connection.HTTPSConnection):
188+
record.msg += " via HTTPS"
189+
elif isinstance(connection, urllib3.connection.HTTPConnection):
190+
record.msg += " via HTTP"
191+
elif isinstance(error, urllib3.exceptions.SSLError):
192+
rewritten = True
193+
# Yes, this is the most information we can provide as urllib3
194+
# doesn't give us much.
195+
record.msg = "SSL verification failed"
196+
elif isinstance(error, urllib3.exceptions.TimeoutError):
197+
rewritten = True
198+
record.msg = f"server didn't respond within {self.timeout} seconds"
199+
elif isinstance(error, urllib3.exceptions.ProtocolError):
200+
try:
201+
if isinstance(error.args[1], RemoteDisconnected):
202+
rewritten = True
203+
record.msg = "Server closed connection unexpectedly"
204+
except IndexError:
205+
pass
206+
elif isinstance(error, urllib3.exceptions.ProxyError):
207+
rewritten = True
208+
record.msg = "failed to connect to proxy"
209+
try:
210+
reason = error.args[1]
211+
proxy = reason.pool.host
212+
except Exception:
213+
pass
214+
else:
215+
record.msg += f" {proxy}"
216+
217+
if rewritten:
218+
# The total remaining retries is already decremented when this
219+
# warning is raised.
220+
retries = retry.total + 1
221+
assert isinstance(retry, urllib3.util.Retry)
222+
if retries > 1:
223+
record.msg += f", retrying {retries} more times"
224+
elif retries == 1:
225+
record.msg += ", retrying 1 last time"
226+
227+
if self.verbose:
228+
# As it's hard to provide enough detail, show the original
229+
# error under verbose mode.
230+
record.msg += f": {error!s}"
231+
record.args = ()
232+
233+
return True

0 commit comments

Comments
 (0)