最近遇到的一個問題,在搞清楚之後才發現這麼多年的 HTTPS_PROXY
都配置錯了!
起因
想用 Python 在網上下載一些圖片素材,結果 requests
報錯 requests.exceptions.ProxyError
,
具體的錯誤資訊見下面。當然第一時間是把系統代理關了,結果訪問就正常了。
如果只是這樣,可能我就覺得是代理有問題,然後關了用就行了,但是偏偏想要下載的資源裡是必須要走代理的,所以只能想辦法解決。
下面先介紹一下具體的情況:
解決過程
作業系統:Windows 10
Python: 3.8(有虛擬環境)
requests
通過代理訪問外網時報錯如下:
Traceback (most recent call last):
File "E:\code\Python\.venv\smalltools\lib\site-packages\urllib3\connectionpool.py", line 696, in urlopen
self._prepare_proxy(conn)
File "E:\code\Python\.venv\smalltools\lib\site-packages\urllib3\connectionpool.py", line 964, in _prepare_proxy
conn.connect()
File "E:\code\Python\.venv\smalltools\lib\site-packages\urllib3\connection.py", line 359, in connect
conn = self._connect_tls_proxy(hostname, conn)
File "E:\code\Python\.venv\smalltools\lib\site-packages\urllib3\connection.py", line 496, in _connect_tls_proxy
return ssl_wrap_socket(
File "E:\code\Python\.venv\smalltools\lib\site-packages\urllib3\util\ssl_.py", line 432, in ssl_wrap_socket
ssl_sock = _ssl_wrap_socket_impl(sock, context, tls_in_tls)
File "E:\code\Python\.venv\smalltools\lib\site-packages\urllib3\util\ssl_.py", line 474, in _ssl_wrap_socket_impl
return ssl_context.wrap_socket(sock)
File "C:\Users\Davy\AppData\Local\Programs\Python\Python38\lib\ssl.py", line 500, in wrap_socket
return self.sslsocket_class._create(
File "C:\Users\Davy\AppData\Local\Programs\Python\Python38\lib\ssl.py", line 1041, in _create
self.do_handshake()
File "C:\Users\Davy\AppData\Local\Programs\Python\Python38\lib\ssl.py", line 1310, in do_handshake
self._sslobj.do_handshake()
OSError: [Errno 0] Error
因為瀏覽器訪問是沒有問題的,代理本身應該沒有問題。
按照這個錯誤資訊在網上搜了一下,比較接近的帖子給的解決方案有安裝 ssl
模組之類,都照著檢查了一遍,問題還是沒有解決。
因為網上的內容有些年頭了,並且我覺得使用代理是非常常見的場景,既然沒多少人報這個問題,那麼很可能只是偶然的 bug,於是想著把版本再升級試試。
升級到 python 3.9 ,錯誤仍然存在,提示略有變化:
Traceback (most recent call last):
File "C:\Users\Davy\AppData\Local\Programs\Python\Python39\lib\site-packages\urllib3\connectionpool.py", line 696, in urlopen
self._prepare_proxy(conn)
File "C:\Users\Davy\AppData\Local\Programs\Python\Python39\lib\site-packages\urllib3\connectionpool.py", line 964, in _prepare_proxy
conn.connect()
File "C:\Users\Davy\AppData\Local\Programs\Python\Python39\lib\site-packages\urllib3\connection.py", line 359, in connect
conn = self._connect_tls_proxy(hostname, conn)
File "C:\Users\Davy\AppData\Local\Programs\Python\Python39\lib\site-packages\urllib3\connection.py", line 496, in _connect_tls_proxy
return ssl_wrap_socket(
File "C:\Users\Davy\AppData\Local\Programs\Python\Python39\lib\site-packages\urllib3\util\ssl_.py", line 432, in ssl_wrap_socket
ssl_sock = _ssl_wrap_socket_impl(sock, context, tls_in_tls)
File "C:\Users\Davy\AppData\Local\Programs\Python\Python39\lib\site-packages\urllib3\util\ssl_.py", line 474, in _ssl_wrap_socket_impl
return ssl_context.wrap_socket(sock)
File "C:\Users\Davy\AppData\Local\Programs\Python\Python39\lib\ssl.py", line 500, in wrap_socket
return self.sslsocket_class._create(
File "C:\Users\Davy\AppData\Local\Programs\Python\Python39\lib\ssl.py", line 1040, in _create
self.do_handshake()
File "C:\Users\Davy\AppData\Local\Programs\Python\Python39\lib\ssl.py", line 1309, in do_handshake
self._sslobj.do_handshake()
ssl.SSLEOFError: EOF occurred in violation of protocol (_ssl.c:1122)
好歹錯誤資訊有點變化,於是按照最下面 ssl.SSLEOFError: EOF occurred in violation of protocol (_ssl.c:1122)
去谷歌,並沒有找到解決辦法,但是發現有人在不久前遇到了相同的問題,並且通過降級 Python 3.7 解決了。
先重新安裝 Python 3.7 試了一下果然可行,並且意外地發現在 Python 3.8 環境下也是可行的,也就是可以排除 Python 版本的問題,那麼自然就懷疑是某個包引發的。
通過簡單地對比和排除,很快就發現了問題所在:
模組 urllib3
的版本,報錯的是 1.26.3
,沒報錯的是 1.25.11
在原報錯環境中使用下面命令重灌低版本 urllib3
:
pip install urllib3==1.25.11
然後測試果然就沒問題了。
問題根源
先查了一下 urllib3
的更新日誌,應該是 1.26.0
的修改導致的:
按照這個更新日誌,明明應該是增加了 HTTPS
的支援,怎麼反而讓它失效了呢?
我一時搞不明白這個問題,但是想起了我最近遭遇到了另一個問題,然後意外地找到了真相:
同樣遭遇代理錯誤的 pip
同樣是在這個環境中,其實在一開始我就遭遇了 pip install
安裝包失敗的問題,報錯資訊是:
'ProxyError('Cannot connect to proxy.', FileNotFoundError(2, 'No such file or directory'))'
同樣是取消系統代理就能正常安裝,就沒太在意了。
但是在降級 urllib3
解決了 requests
的 ProxyError
之後,我開始懷疑 pip
安裝是不是也是這個問題呢?
直接在降級了 urllib3
的環境中測試了一下,錯誤仍然存在,但是版本整體較低的環境中,是沒有問題的!
於是繼續對比版本包,結果在 pip
包的路徑下發現有一個 _vendor\urllib3
目錄,原來 pip
是直接把 urllib3
整合到了自己的包裡面,不受系統安裝包的影響。檢查其中的 _version.py
裡的版本資訊,果然也是 1.26.x
。
出錯的 pip
的版本是 20.3
,把 pip
也降級到 20.2
以下,就沒有問題了。
顯然,鑑於 pip
的高頻使用,這種致命的問題不可能沒人報,所以在 pip
專案的 issue
列表裡很快就找到了相關討論:
https://github.com/pypa/pip/issues/9216
urllib3
更新了啥
根據 https://github.com/pypa/pip/issues/9216#issuecomment-741836058 所說,更改代理配置可以解決問題:
繞了好大一圈大概明白是怎麼回事了:
以前 urllib3
其實並不支援 https
代理,也就是說代理伺服器的地址雖然大家配置的是 https
,但是一直都是悄無聲息地就按照 http
連線的,剛好代理伺服器確實也只支援 http
,所以皆大歡喜。
現在 urllib3
要支援 https
代理了,那麼既然配置代理是 https
就嘗試用 https
的方式去連線,但是由於代理伺服器其實只支援 http
,所以沒法處理請求,ssl
握手階段就出錯了。
注意,這裡的
https
是指代理伺服器自己的,和我們要訪問的目標網站無關。
因為目標網站的協議和代理伺服器的協議並不要求一樣,所以只需要更改代理配置 ,將訪問 https
網站的代理伺服器地址改為 http
即可,也就是這樣:
HTTPS_PROXY=http://proxy_ip:proxy_port
前面的 HTTPS_
表示,如果訪問的站點是 https
的,需要走這裡配置的代理伺服器;後面的 http://
則表示這個代理伺服器自己只支援 http
。
而我們一直以來看到的配置建議,這兩者前後通常都是保持一致的:
HTTP_PROXY=http://proxy_ip:proxy_port
HTTPS_PROXY=https://proxy_ip:proxy_port
這個是錯誤的!
代理到底該咋配
Windows 10 中的代理伺服器設定如下,並沒有區分什麼 http
和 https
:
手動給 requests
傳入代理配置
requests
的請求引數中是支援指定代理伺服器的,剛開始的程式碼沒有指定:
url = 'https://github.com/'
r = requests.get(url)
前面在嘗試解決問題的時候,也試過了傳入代理伺服器配置:
proxies={
'http': 'http://127.0.0.1:7890',
'https': 'https://127.0.0.1:7890'
}
r = requests.get(url, proxies=proxies)
上面兩種寫法的效果其實是差不多一樣的,結果當然也是一樣出錯。
按照上面 issue
中的修改建議改為:
proxies={
'http': 'http://127.0.0.1:7890',
'https': 'http://127.0.0.1:7890' # https -> http
}
r = requests.get(url, proxies=proxies)
執行結果就 OK 了。
好了,現在我們可以不用降級版本了,但是卻要多出一段配置,要改程式碼,總歸還是不爽。
其實,如果是 Linux 系統是沒這個問題的,本來代理配置就是通過環境變數 HTTP_PROXY
和 HTTPS_PROXY
來設定的,改一下環境變數的值就可以了,麻煩還是在 Windows 系統中。
要搞明白 Python 程式碼是如何獲取 Windows 系統中的代理伺服器設定的。
誰解析的系統代理配置
在程式碼中不難發現,當使用者有傳入 proxies
引數時,requests
是通過標準庫提供的 getproxies
函式來獲取系統代理伺服器配置的:
>>> # 如果是 python 2,則是 from urllib import getproxies
>>> from urllib.request import getproxies
>>> getproxies()
{'http': 'http://127.0.0.1:7890', 'https': 'https://127.0.0.1:7890', 'ftp': 'ftp://127.0.0.1:7890'}
上面顯示的結果就是對應到截圖中的代理配置。
注意,
urllib
和urllib3
不是一個庫,前者是 Python 標準庫自帶。
繼續看程式碼:
elif os.name == 'nt':
def getproxies():
"""Return a dictionary of scheme -> proxy server URL mappings.
Returns settings gathered from the environment, if specified,
or the registry.
"""
return getproxies_environment() or getproxies_registry()
在 Windows 系統中,先從環境變數獲取,如果沒有則從登錄檔獲取。
getproxies_environment
的邏輯比較簡單,基本和 Linux 系統是一致的,就是環境變數配置成啥樣就是啥樣。這裡我並沒有配置環境變數,自然結果是空,最終的結果要看 getproxies_registry
。按照其中的程式碼,從登錄檔中獲取的配置如下:
程式碼裡有兩個處理邏輯:
proxyServer = str(winreg.QueryValueEx(internetSettings,
'ProxyServer')[0])
if '=' in proxyServer:
# Per-protocol settings
for p in proxyServer.split(';'):
protocol, address = p.split('=', 1)
# See if address has a type:// prefix
if not re.match('(?:[^/:]+)://', address):
address = '%s://%s' % (protocol, address)
proxies[protocol] = address
else:
# Use one setting for all protocols
if proxyServer[:5] == 'http:':
proxies['http'] = proxyServer
else:
proxies['http'] = 'http://%s' % proxyServer
proxies['https'] = 'https://%s' % proxyServer
proxies['ftp'] = 'ftp://%s' % proxyServer
其中第一種是「每種協議各自配置」,下面第二種情況是「所有協議一個配置」。
在第二種情況中,如果帶了 http:
就只配置 http
協議,否則(也就是我們現在的場景),針對同一個 proxyServer,新增 3 種協議。
在 Windows 中如何配置代理
第一種方法:通過環境變數設定:
結果:
>>> getproxies()
{'https': 'http://127.0.0.1:7890', 'http': 'http://127.0.0.1:7890'}
一旦設定了環境變數,程式就直接從環境變數獲取,系統的配置也就失效了。
第二種方法:按照上面的程式碼倒推出來系統配置:
其中的地址框裡的內容是:
http=http://127.0.0.1:1080;https=http://127.0.0.1
其中最後的埠只對最後面的那個地址有效,分號前面的地址需要加上埠。
對應的登錄檔中的值是:
顯然這種方式有點詭異和麻煩,目前也沒看到有相關的說明,不確定是否會影響其它程式的判斷。
如此看來,還是第一種方法比較靠譜,就是不能利用系統配置有點遺憾。
我的疑惑
那麼,在使用者只給出了代理伺服器的 IP 和 埠的情況下,原有的處理邏輯是不是錯誤,我也不敢斷言。
回到最初犯錯的地方:
HTTP_PROXY=http://proxy_ip:proxy_port
HTTPS_PROXY=https://proxy_ip:proxy_port
我現在還有一個疑惑點是,真的會存在一個代理伺服器,能夠在同一個埠同時支援 http
和 https
麼?如果是那樣的話,為啥平常的 web 伺服器還要有 80 和 443 兩個埠對應不同的服務。
反過來,如果一個埠不可以同時支援兩個協議的話,那麼上面的配置的錯誤則更加明顯,處理邏輯也就很有問題。
你對此有什麼看法和理解,歡迎在評論區討論!
總結
通過解決代理伺服器錯誤,對 Python 是如何處理代理伺服器配置有了更深入的瞭解。