第34章:源码剖析——adapters.py(HTTPAdapter与urllib3桥接层)

第34章:源码剖析——adapters.py(HTTPAdapter与urllib3桥接层) 1. 项目背景业务场景某基础设施团队的内部代理服务遇到了一个奇怪的问题所有通过 requests 发出的请求Content-Length头和实际 body 大小不一致。排查后发现他们的自定义中间件在 PreparedRequest 修改了 body 内容但Content-Length头没有被重新计算。这是因为prepare_body()只在Session.prepare_request()阶段执行一次——后续对 PreparedRequest 的修改不会触发重新计算。团队需要在HTTPAdapter.send()方法中插入一段代码——在真正发送前重新计算Content-Length。但HTTPAdapter.send()是一个相对复杂的函数200 行理解它的内部流程才能安全地覆写它。痛点问题一HTTPAdapter.send() 是 requests 和 urllib3 之间的桥梁。这个方法是所有 HTTP/HTTPS 请求的最后出口。它负责解析代理配置、构建 SSL 上下文、获取/复用连接、发送请求数据、接收响应数据、将 urllib3 的 HTTPResponse 转换为 requests.Response。理解这个方法是理解 requests 全链路的最后一环。问题二证书验证逻辑隐藏在 cert_verify() 中。verify参数是如何工作的verifyFalse跳过了什么verify/path/to/ca.crt是如何与 certifi 交互的这些逻辑都在adapters.py中但很少有人去读。问题三连接池的生命周期管理。HTTPAdapter维护了一个urllib3.PoolManagerHTTP 连接池和一个urllib3.ProxyManager代理连接池。它们何时创建如何选择使用哪个adapter.close()时发生了什么HTTPAdapter.send() 核心流程图HTTPAdapter.send(prepared_request, ...) │ ├─ 1. 解析代理 │ └─ 从 proxies 参数或环境变量中获取 │ ├─ 2. 选择连接池 │ ├─ 有代理? - ProxyManager │ └─ 无代理? - PoolManager │ ├─ 3. 构建 SSL 上下文 │ ├─ verifyTrue - 使用 certifi CA 证书 │ ├─ verifypath - 使用自定义 CA 证书 │ ├─ verifyFalse - 不验证危险! │ └─ cert... - 客户端证书mTLS │ ├─ 4. 发送请求 │ └─ pool.urlopen(method, url, body, headers, timeout, ...) │ ├─ 5. 接收响应 │ └─ urllib3 HTTPResponse - requests.Response │ └─ 6. 返回 Response2. 项目设计小胖在代码中找不到Content-Length被计算的位置“大师我在 PreparedRequest 中修改了 body发出去之后服务器报 400 Bad Request——说 Content-Length 和实际 body 大小不一致Content-Length到底是在哪里计算的”大师“Content-Length有两个计算时机。第一次是prepare_body()在 Session.prepare_request() 中。第二次是HTTPAdapter.send()调用urllib3的urlopen()时——如果没设 Content-Lengthurllib3 会自动计算。但如果你手动设了一个错误的值urllib3 也会尊重你的设置。”小白追问“那HTTPAdapter.send()是怎么处理代理的为什么 HTTPS 请求传 HTTP 代理能工作”大师“这正是 CONNECT 隧道的魔力。当HTTPAdapter.send()检测到请求是 HTTPS 且有 HTTP 代理时它不会直接连接目标服务器——而是向代理发送一个CONNECT host:port请求。代理建立了一个 TCP 隧道后客户端再在隧道上与目标服务器完成 TLS 握手。整个过程在adapters.py的send()方法和 urllib3 的ProxyManager中协同完成。”# adapters.py 中代理判断的简化逻辑ifproxy_url:# 使用 ProxyManagerconnself.proxy_manager_for(proxy_url)else:# 使用 PoolManagerconnself.poolmanager# 对 HTTPS 目标 HTTP 代理 - CONNECT 隧道小胖“那verifyFalse和verifyTrue的底层区别是什么”大师“都体现在 SSL 上下文的构建上。verifyTrue使用 certifi 的 CA 证书包创建ssl.SSLContext这个上下文的verify_mode设为ssl.CERT_REQUIRED。verifyFalse创建了一个不验证证书的上下文ssl.CERT_NONE并禁用了主机名验证。verify/path使用自定义 CA 文件作为信任锚。”# adapters.py 中 SSL 上下文的简化逻辑ifverify:ifverifyisTrue:ca_bundlecertifi.where()# 使用 certifielse:ca_bundleverify# 自定义 CA 路径ssl_contextssl.create_default_context(cafileca_bundle)else:ssl_contextssl.create_default_context()ssl_context.check_hostnameFalsessl_context.verify_modessl.CERT_NONE生活比喻技术映射快递中转站分拣转运HTTPAdapter 在 requests 和 urllib3 之间代理收件点ProxyManager 处理代理转发身份证验证闸机SSLContext 的证书验证快递员选择交通工具PoolManager vs ProxyManager 选择快件回执单urllib3 HTTPResponse - requests Response3. 项目实战环境准备python-cimport requests.adapters; print(requests.adapters.__file__)分步实现步骤一HTTPAdapter.send() 流程追踪目标通过覆写 HTTPAdapter 插入日志追踪请求在 adapter 层的完整路径。importrequestsfromrequests.adaptersimportHTTPAdapterimporttimeclassTracedHTTPAdapter(HTTPAdapter):带追踪的 HTTPAdapter——在关键步骤插入日志defsend(self,request,streamFalse,timeoutNone,verifyTrue,certNone,proxiesNone):print(f[Adapter]{request.method}{request.url})print(f stream{stream}, timeout{timeout})print(f verify{type(verify).__name__})print(f proxies{proxies})starttime.perf_counter()# 调用真实 send()try:respsuper().send(request,streamstream,timeouttimeout,verifyverify,certcert,proxiesproxies)elapsedtime.perf_counter()-startprint(f -{resp.status_code}({elapsed:.3f}s))print(f 连接:{resp.raw._connection.__class__.__name__ifhasattr(resp.raw,_connection)elseN/A})returnrespexceptExceptionase:elapsedtime.perf_counter()-startprint(f - ERROR ({elapsed:.3f}s):{e})raise# 使用追踪 Adapter sessionrequests.Session()adapterTracedHTTPAdapter()session.mount(https://,adapter)print( 请求追踪 )# 普通 HTTPS 请求respsession.get(https://httpbin.org/get)print(f结果:{resp.status_code})# 查看连接池信息内部结构pooladapter.poolmanagerprint(f\n连接池类型:{type(pool).__name__})print(f连接池数:{len(pool.pools._containers)ifhasattr(pool.pools,_containers)elseN/A})session.close()步骤二SSL 上下文源码走读目标理解 verify 参数如何影响 SSL 上下文构建。importrequestsfromrequests.adaptersimportHTTPAdapterimportsslimportcertifidefdemo_ssl_context():演示 verify 参数如何影响 SSL 上下文的构建adapterHTTPAdapter()# verifyTrue (默认) print( verifyTrue )# 内部会调用 ssl.create_default_context(cafilecertifi.where())print(f certifi CA 路径:{certifi.where()})print(f 验证模式: ssl.CERT_REQUIRED)print(f 主机名验证: 启用)# verifyFalse print(f\n verifyFalse )ctxssl.create_default_context()ctx.check_hostnameFalsectx.verify_modessl.CERT_NONEprint(f 验证模式:{ctx.verify_mode}(CERT_NONE))print(f 主机名验证:{ctx.check_hostname})print(f ⚠ 完全跳过 TLS 安全验证——中间人攻击暴露!)# verify自定义CA路径 print(f\n verifyself-signed-ca.crt )# ctx ssl.create_default_context(cafile/path/to/ca.crt)print(f 使用自定义 CA 文件作为信任锚)print(f 仍验证主机名和证书链)print(f 适合内部 CA 签发的证书)demo_ssl_context()步骤三自定义 Adapter 插入 Content-Length 重算目标解决问题——在 send() 前自动重算 Content-Length。importrequestsfromrequests.adaptersimportHTTPAdapterclassAutoContentLengthAdapter(HTTPAdapter):自动重算 Content-Length 的适配器——解决修改 body 后长度不一致的问题defsend(self,request,**kwargs):# 在发送前重新计算 Content-Lengthifrequest.bodyisnotNone:actual_lengthlen(request.body)declared_lengthrequest.headers.get(Content-Length)ifdeclared_lengthisnotNone:ifint(declared_length)!actual_length:importwarnings warnings.warn(fContent-Length 不一致! 声明{declared_length}, f实际{actual_length}. 已自动修正。)request.headers[Content-Length]str(actual_length)returnsuper().send(request,**kwargs)# 验证 sessionrequests.Session()session.mount(https://,AutoContentLengthAdapter())importjson# 发送请求前修改 body模拟中间件行为reqrequests.Request(POST,https://httpbin.org/post,json{original:data})preparedsession.prepare_request(req)# 这里修改了 bodyprepared.bodyjson.dumps({modified:data}).encode()# 但没有更新 Content-Length# AutoContentLengthAdapter 在 send() 时会自动修正respsession.send(prepared)print(f修正后发送成功:{resp.status_code})print(f服务端收到的body:{resp.json().get(json,resp.json())})session.close()可能遇到的坑及解决方法坑1覆写 send() 时不要忘记 stream 参数# streamTrue 时 resp.raw 不是完整读取的# 如果你在 send() 中访问了 resp.content可能会阻塞坑2adapters.py 中 urllib3 的版本差异# urllib3 1.x 和 2.x 的 PoolManager 接口有差异# urllib3 2.x 使用了新的类型标注和 API# 自定义 adapter 需要注意 urllib3 版本兼容性importurllib3print(furllib3 版本:{urllib3.__version__})# 检查版本坑3ProxyManager 和 PoolManager 不能混用# 对于同一个 host不能同时有 HTTP 代理和无代理的连接# 它们属于不同的连接池管理器测试验证importpytestimportrequestsfromrequests.adaptersimportHTTPAdapterclassTestAdapterInternals:验证 adapter 内部机制deftest_adapter_send_basic(self):srequests.Session()adapterHTTPAdapter()s.mount(https://,adapter)resps.get(https://httpbin.org/get)assertresp.status_code200s.close()deftest_adapter_mount_respected(self):验证 mount 后请求经过指定的 adapterclassSpyAdapter(HTTPAdapter):def__init__(self):super().__init__()self.send_count0defsend(self,request,**kwargs):self.send_count1returnsuper().send(request,**kwargs)spySpyAdapter()srequests.Session()s.mount(https://httpbin.org,spy)s.get(https://httpbin.org/get)assertspy.send_count1s.close()deftest_adapter_poolmanager_exists(self):adapterHTTPAdapter()assertadapter.poolmanagerisnotNone4. 项目总结核心源码要点方法/属性关键逻辑作用HTTPAdapter.send()代理解析→SSL配置→连接获取→发送→接收全部请求的最后出口cert_verify()构建 SSLContext (verify/cert/ca)证书验证的核心poolmanagerurllib3.PoolManager 实例直连连接池proxy_manager_for()按代理 URL 获取 ProxyManager代理连接池close()关闭所有连接池资源清理适用场景覆写 send() 注入自定义逻辑审计/重算/日志理解代理和 SSL 的底层机制排查连接池相关问题自定义非 HTTP 协议的传输适配器注意事项覆写 send() 必须保持与 BaseAdapter 的接口兼容urllib3 版本差异需要关注1.x vs 2.xstreamTrue 时 send() 的行为与普通模式不同连接池资源通过 close() 释放不是 GC常见踩坑经验案例一自定义 Adapter 未调用父类 send()。开发写了一个 AuditAdapter 继承 HTTPAdapter覆写了 send() 但忘了调用super().send()而是自己用 urllib3 直接发请求。结果 response.request 为 None——后续代码依赖response.request.url做日志记录时崩溃。根因未遵循 Liskov 替换原则。修复始终调用super().send()并将返回值原封不动返回仅在前后插入逻辑。案例二ProxyManager 泄露。某服务频繁切换代理proxies参数变化每次 HTTPAdapter.send() 都调用proxy_manager_for()获取新的 ProxyManager。ProxyManager 的缓存 key 是代理 URL但带认证的代理 URL 包含不断变化的 token导致创建了大量 ProxyManager 实例且都不回收。根因token 作为缓存 key。修复将 token 移到 Header 中而非代理 URL 中。思考题源码题阅读adapters.py中build_response()函数找出 urllib3 的HTTPResponse是如何转换为 requests 的Response对象的哪些信息在这个转换过程中丢失了扩展题基于对 HTTPAdapter 的理解设计一个支持 HTTP/2 的适配器使用 httpx 或 hyper 作为底层。主要挑战是什么推广计划提示架构师理解 HTTPAdapter 的桥接模式设计在类似的传输层抽象场景中借鉴开发团队覆写 send() 是实现自定义传输行为缓存、审计、重试的最强大方式步骤四从源码角度理解 PoolManager 连接生命周期目标演示连接池的创建、复用、释放全流程。importrequestsfromrequests.adaptersimportHTTPAdapterdefdemo_pool_lifecycle():演示连接池的完整生命周期sessionrequests.Session()adapterHTTPAdapter(pool_connections5,pool_maxsize10)session.mount(https://,adapter)pooladapter.poolmanagerprint( 连接池生命周期 )print(f初始状态:)print(f 连接池个数:{len(pool.pools._containers)ifhasattr(pool.pools,_containers)elseN/A})# 发起第一次请求——创建新连接respsession.get(https://httpbin.org/get,timeout10)print(f\n第一次请求 ({resp.status_code}):)print(f 新建了 TCP TLS 连接)# 第二次请求——复用连接respsession.get(https://httpbin.org/get,timeout10)print(f\n第二次请求 ({resp.status_code}):)print(f 复用了之前的连接无 TCPTLS 握手)# 请求不同的 hostrespsession.get(https://httpbin.org/ip,timeout10)print(f\n不同 path 但同 host ({resp.status_code}):)print(f 仍复用同一连接池中的连接)# 关闭——释放所有连接session.close()print(f\nSession 关闭:)print(f 所有连接被释放CLOSE_WAIT - CLOSED)demo_pool_lifecycle()步骤五代理模式深入——CONNECT 隧道的源码级理解目标理解 HTTP CONNECT 方法在代码中是如何实现的。importrequestsdefdemo_connect_tunnel():演示 HTTPS 通过 HTTP 代理的 CONNECT 隧道机制print( HTTPS HTTP代理 CONNECT 隧道 )print(流程:)print( 1. 客户端 - 代理: CONNECT target.com:443 HTTP/1.1)print( 2. 代理 - 客户端: HTTP/1.1 200 Connection Established)print( 3. 客户端 - 代理 - target.com: TLS ClientHello)print( 4. [...TLS 握手...])print( 5. 客户端 - 代理 - target.com: 加密的 HTTP 请求)print()print(关键点:)print( - 代理只能看到目标 IP 和端口CONNECT 阶段)print( - TLS 握手是客户端和服务器之间的端到端加密)print( - 代理无法解密 HTTPS 内容与 HTTP 代理不同)print()print(在 adapters.py 中的实现:)print( if proxy_url and scheme https:)print( conn proxy_manager_for(proxy_url))print( # proxy_manager 内部: if target_is_https: use CONNECT)print( else:)print( conn self.poolmanager)print( # 直连不使用 CONNECT)demo_connect_tunnel()补充思考题实践题在HTTPAdapter.send()中插入代码记录每次请求使用的连接是新建还是复用。实现后运行 10 次请求观察新建 vs 复用的比例验证 Session 的连接复用效果。延伸阅读与资源Milvus向量数据库实战修炼从 0 到 1精通向量检索与生产落地后端工程师的 AI 转型第一课Ollama 与私有化大模型实战10倍开发者的 Dify 魔法书从零构建全栈 AI 应用后端工程师转型AI第一课-Ollama 与私有化大模型实战大型语言模型(LLM) vLLM 高性能推理落地实战Agent开发之LlamaIndex 实战修炼与源码进阶大语言模型Transformers 实战修炼与源码剖析