ConnectionError is a family name. Python's built-in ConnectionError groups the socket-level failures (refused, reset, aborted, broken pipe), and the requests library raises its own ConnectionError wrapping everything that went wrong before an HTTP response existed, including DNS failures and refused connections. Either way, the top line is a category; the nested cause inside the message (the urllib3 or OSError text after the colon) is the specific failure to fix.
import requests
requests.get("http://localhost:8000/api") # nothing is
# listening there
Traceback (most recent call last):
File "main.py", line 2, in <module>
requests.exceptions.ConnectionError:
HTTPConnectionPool(host='localhost', port=8000):
Max retries exceeded ... [Errno 111] Connection refused
# the tail is the diagnosis: refused = host reachable,
# port closed; start the server or fix the port
nslookup; the code cannot fix what the resolver cannot find.HTTP_PROXY/HTTPS_PROXY; a stale corporate proxy makes every call fail identically. echo $HTTPS_PROXY (and the NO_PROXY list) explains failures that skip some hosts and hit others.If connections fail only under load (parallel calls, test suites), the bottleneck is usually connection limits: the server's accept queue, ephemeral ports on your machine, or a pool sized for serial use. The error is the same; the remedy is pooling discipline and limits, not the single-request checks above.
Why does requests raise ConnectionError for a DNS problem? Because from the caller's perspective the outcomes are identical: no connection, no response. requests groups every pre-response failure into ConnectionError and chains the precise cause inside it, which is why reading the full chained message is step one.
Is ConnectionError the same as the built-in ConnectionError? Same name, different classes. The built-in (a subclass of OSError) comes from socket code directly; requests.exceptions.ConnectionError is that library's own class. Catch the one your code actually raises; importing the wrong one catches nothing.