Python 遭遇 ProxyError 問題記錄


最近遇到的一個問題,在搞清楚之后才發現這么多年的 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 解決了。

參見:https://v2ex.com/t/738031

先重新安裝 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 的修改導致的:

image-20210205233412643

按照這個更新日志,明明應該是增加了 HTTPS 的支持,怎么反而讓它失效了呢?

我一時搞不明白這個問題,但是想起了我最近遭遇到了另一個問題,然后意外地找到了真相:

同樣遭遇代理錯誤的 pip

同樣是在這個環境中,其實在一開始我就遭遇了 pip install 安裝包失敗的問題,報錯信息是:

​ 'ProxyError('Cannot connect to proxy.', FileNotFoundError(2, 'No such file or directory'))'

同樣是取消系統代理就能正常安裝,就沒太在意了。

但是在降級 urllib3 解決了 requestsProxyError 之后,我開始懷疑 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 所說,更改代理配置可以解決問題:

image-20210206000424172

繞了好大一圈大概明白是怎么回事了:

以前 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 中的代理服務器設置如下,並沒有區分什么 httphttps:

image-20210207121301539

手動給 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_PROXYHTTPS_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'}

上面顯示的結果就是對應到截圖中的代理配置。

注意,urlliburllib3 不是一個庫,前者是 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。按照其中的代碼,從注冊表中獲取的配置如下:

image-20210207124727405

代碼里有兩個處理邏輯:

                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 中如何配置代理

第一種方法:通過環境變量設置

image-20210207130112079

結果:


>>> getproxies()
{'https': 'http://127.0.0.1:7890', 'http': 'http://127.0.0.1:7890'}

一旦設置了環境變量,程序就直接從環境變量獲取,系統的配置也就失效了。

第二種方法:按照上面的代碼倒推出來系統配置

image-20210207131015338

其中的地址框里的內容是:

http=http://127.0.0.1:1080;https=http://127.0.0.1

其中最后的端口只對最后面的那個地址有效,分號前面的地址需要加上端口。

對應的注冊表中的值是:

image-20210207131206485

顯然這種方式有點詭異和麻煩,目前也沒看到有相關的說明,不確定是否會影響其它程序的判斷。

如此看來,還是第一種方法比較靠譜,就是不能利用系統配置有點遺憾。

我的疑惑

那么,在用戶只給出了代理服務器的 IP 和 端口的情況下,原有的處理邏輯是不是錯誤,我也不敢斷言。

回到最初犯錯的地方:

HTTP_PROXY=http://proxy_ip:proxy_port
HTTPS_PROXY=https://proxy_ip:proxy_port

我現在還有一個疑惑點是,真的會存在一個代理服務器,能夠在同一個端口同時支持 httphttps 么?如果是那樣的話,為啥平常的 web 服務器還要有 80 和 443 兩個端口對應不同的服務。

反過來,如果一個端口不可以同時支持兩個協議的話,那么上面的配置的錯誤則更加明顯,處理邏輯也就很有問題。

你對此有什么看法和理解,歡迎在評論區討論!

總結

通過解決代理服務器錯誤,對 Python 是如何處理代理服務器配置有了更深入的了解。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM