私有 API 上的 Requests ProxyError?先检查环境
Requests 会合并环境中的代理、认证与 CA 设置。先读进程实际看到的环境,排除私有主机,并保持 TLS 校验开启。
VPN 已连接,内部面板能在浏览器里打开,Python 脚本却仍然抛出代理错误。那幅图里隧道没有任何问题:解释器从自己的环境里读到了代理地址,并把请求发往那里。本文先读进程真正看到的环境,采用最窄的排除,然后才考虑显式的会话策略。每一步都以一条命令结束,由它的输出决定你是否继续。
读进程真正看到的环境
桌面代理设置、shell 导出和 systemd 单元的 Environment= 行是三个不同的来源,Requests 只读取它继承到的最后一个。请在运行失败脚本的同一个 shell、容器或单元里执行下面的诊断,而不是在你自己的交互终端里。登录 shell 里导出的变量对容器进程不可见,这个缺口解释了为什么很多人报告代理“凭空出现”。两条命令以不同详细程度打印同一份数据。
env | grep -i -E 'proxy|requests_ca_bundle|curl_ca_bundle'
python3 -c "import os;[print(k,'=',v) for k,v in sorted(os.environ.items()) if 'proxy' in k.lower()]"
每个变量的两种拼写都会被读取,重复条目按进程接收的顺序生效,因此过期的 HTTP_PROXY 可能覆盖你刚导出的值。CA 证书包变量在这里同样重要,因为它们会改变 verify 解析到什么,却不会出现在代码的任何地方。如果输出为空,环境不是你的问题,本节可以跳过。如果列出了一个你不认识的代理,请先找到设置它的文件或单元,再动手修改。
问 Requests 它会如何处理某个具体 URL。这次调用会显示该请求合并后的 proxy、verify 和 cert 值,而不会真正发出数据包。
python3 -c "import requests;s=requests.Session();print(s.merge_environment_settings('https://api.corp.example/status',{},None,None,None))"
私有主机对应的 proxies 条目非空,就是你的答案;打印出的 verify 值会告诉你请求会信任哪个 CA 证书包。如果两者都正确,失败就在调用栈更靠后的位置,应跳到校验一节。不要从代码里猜:合并是按请求进行的,所以同一个会话对两台主机可能表现不同。
在关闭任何东西之前,先排除私有主机
如果代理对其他目标确实必需,最窄的修法是为这台主机加排除。要追加到现有列表,而不是替换它,因为清空 NO_PROXY 会破坏所有依赖它的内部目标。下面的命令会追加条目,然后问 Requests 绕过是否生效。
export NO_PROXY="${NO_PROXY:+$NO_PROXY,}api.corp.example,.corp.example"
python3 -c "import requests;print(requests.utils.should_bypass_proxies('https://api.corp.example/status',None))"
匹配按主机后缀进行,所以 .corp.example 覆盖整个域,而 api.corp.example 只覆盖一个名字。裸星号会为所有主机关闭代理,生产任务几乎从不需要它。CIDR 条目是否被尊重取决于镜像里 Requests 与 urllib3 的版本,所以要测试确切条目,而不是想当然。用真实 URL 检查 should_bypass_proxies 的结果,而不是你凭记忆敲进去的主机名。
在脑子里把两层分开。绕过 HTTP 代理并不会绕过 VPN,因为代理的决定发生在库里,而隧道的决定发生在内核路由表里。
只在把它当作有意的会话策略时才关闭 trust_env
如果整个任务都要忽略环境配置,就显式构建会话,并把决定写进代码。这是策略,不是临时绕行,它应当出现在评审者能看到的 diff 里。下面的示例保留了超时、显式 CA 路径和状态检查。
import requests
with requests.Session() as session:
session.trust_env = False
response = session.get(
"https://api.corp.example/status",
timeout=(5, 20),
verify="/etc/ssl/certs/corp-ca.pem",
)
response.raise_for_status()
关闭 trust_env 会同时改变三种行为,其中只有一种与代理有关。Requests 不再读取 .netrc 凭据,REQUESTS_CA_BUNDLE 和 CURL_CA_BUNDLE 变量也不再影响 verify,它会回退到内置证书库。如果你所在的组织用自己的 CA 终止 TLS,这个回退会抛出看起来与刚才的改动毫无关系的校验错误。显式传入 verify,才能把两个效果分开。
你不该做的是设置 verify=False 来压掉由此产生的报错。那等于用一条未校验的连接换取代理配置错误,并掩盖了排除本来要暴露的证书问题。
校验失败的层级,并保留回滚手段
三种失败在日志里看起来一样,却毫无共同点:无法到达目标的代理会抛出 ProxyError 或返回 502,信任问题会抛出 SSLError,应用层拒绝会返回 401 或 403。在 Requests 之外复现请求,看清是哪一种。下面的命令会去掉代理、测试证书链,然后通过库重复调用。
curl -sv --noproxy '*' https://api.corp.example/status -o /dev/null
openssl s_client -connect api.corp.example:443 -servername api.corp.example </dev/null
python3 -c "import requests;r=requests.get('https://api.corp.example/status',timeout=10);print(r.status_code)"
如果 curl 调用成功而 Python 调用失败,差异仍然在环境。如果两者都在 TLS 阶段失败,代理从来不是问题,证书链才是。如果两者都返回 401,说明请求已经到了应用,剩下的工作是身份认证。记录你看到的是三种中的哪一种,能为下一个人省下一小时。
回滚在任一方向上只有一行:去掉排除,或者把 trust_env 翻回去。追加之前先保存此前的 NO_PROXY 值,并优先用包装脚本或单元级 Environment= 条目,而不是只活在你终端历史里的导出。两处改动都能在进程环境里看到,所以重启通常就把你带回旧行为。
最后确认四件事:私有 URL 的合并设置里没有代理,证书能对预期 CA 校验通过,请求返回你期望的状态,改动被提交在可复现的地方。一个环境来路不明却跑绿的脚本,是下次换机器还会坏的修法。本文里的命令就是该粘进工单的那些。