Error message
Max retries exceeded with url: / (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate
Also printed as
- requests.exceptions.SSLError: HTTPSConnectionPool(host='api.example.com', port=8443): Max retries exceeded with url: / (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate
Host, port and URL path are taken from your request. The innermost reason varies ("self-signed certificate in certificate chain", "Hostname mismatch…", "certificate has expired"), and the line ends with a source location such as "(_ssl.c:1082)')))".
requests / urllib3 (Python) — requests.exceptions.SSLError wrapping urllib3 MaxRetryError
What it means
requests asked urllib3 for a connection, the TLS handshake failed certificate verification, and urllib3 gave up (with requests' default of no retries, after the first attempt). The useful part is inside the parentheses: here OpenSSL could not find the issuer in the CA bundle requests uses, which by default is certifi's copy of the Mozilla roots, not the operating system's store.
Why it happens
requests verifies with certifi, not with the system store
commonA company or private CA installed with update-ca-certificates, in the Windows store or the macOS Keychain is ignored: requests.certs.where() points at certifi's cacert.pem. That is exactly what the reproduction shows.
A TLS-inspecting proxy re-signs the traffic
commonRequests to public APIs fail only on the company network because the proxy's root is not in certifi.
The server omits its intermediate certificate
occasionalIf the API's server sends only its leaf, OpenSSL cannot reach a root in any bundle; browsers hide this by fetching intermediates.
REQUESTS_CA_BUNDLE or CURL_CA_BUNDLE points at a partial bundle
occasionalA bundle that contains only the company root makes all public hosts fail (and vice versa); the variables replace certifi rather than adding to it.
How to fix it
- 1.
Pass the right bundle with verify=
verify accepts a path to a CA bundle file or a hashed CA directory; it keeps hostname and date checks on.
python import requests # A CA file (or a directory prepared with c_rehash) for this call requests.get("https://api.example.com/", verify="/etc/ssl/certs/ca-certificates.crt") - 2.
Set REQUESTS_CA_BUNDLE for the process
Pointing it at the system bundle (after installing your CA there) makes every requests call in that process trust the same roots as curl and the OS tools.
bash # For every requests call in the process (also honoured: CURL_CA_BUNDLE) export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt # Debian/Ubuntu system bundle python3 app.py - 3.
Use the operating system's trust store
truststore.inject_into_ssl() at start-up lets requests verify through the OS, so roots deployed by IT work without maintaining bundles.
python # pip install truststore==0.10.5 — requests then verifies with the OS trust store import truststore truststore.inject_into_ssl() import requests requests.get("https://api.example.com/") - 4.
Configure a Session once
For applications, set Session.verify to a bundle that contains public roots plus the company root, and reuse the session.
python import requests session = requests.Session() session.verify = "/etc/ssl/private-ca/corp-ca-bundle.pem" # public roots + company root session.get("https://api.example.com/")