代理与代理池
无论是穿越企业网络、为采集任务轮换出口 IP,还是规避目标站点的 IP 封锁,代理都是 HTTP 客户端的高频需求。HTTPC 内置四种代理模式——单代理、系统代理检测、代理池轮换、状态码触发轮换——覆盖从「固定出口」到「每请求换 IP」的完整谱系,并与 SSRF 防护、TLS 校验、重试引擎协同工作。全部代理配置集中在 ConnectionConfig。
代理模式总览
四种模式按优先级自动生效,同时配置多种时只有最高优先级者生效:
| 优先级 | 配置 | 行为 | 典型场景 |
|---|---|---|---|
| 1(最高) | ProxyURL | 始终使用指定代理(单代理模式) | 企业网络出口、本地 VPN 端口 |
| 2 | ProxyPool | 在代理池中轮换,含熔断与恢复 | 采集、负载分散、IP 轮换 |
| 3 | EnableSystemProxy | 自动检测系统代理设置 | 桌面应用跟随用户配置 |
| 4(最低) | 无 | 直连 | 默认行为 |
TIP
若同时设置 ProxyURL 和 ProxyPool,ProxyURL 生效。要用代理池,请清空 ProxyURL。
单代理配置
ProxyURL 指定一个固定代理,支持四种协议:
| 协议 | 写法 | 说明 |
|---|---|---|
| HTTP | http://proxy:8080 | 最常见;HTTPS 请求经 CONNECT 隧道转发 |
| HTTPS | https://proxy:8443 | 与代理服务器本身也走 TLS |
| SOCKS5 | socks5://proxy:1080 | 本地解析目标域名后经代理连接 |
| SOCKS5h | socks5h://proxy:1080 | 域名解析交给代理端,规避本地 DNS 污染 |
package main
import (
"log"
"github.com/cybergodev/httpc"
)
func main() {
cfg := httpc.DefaultConfig()
cfg.Connection.ProxyURL = "socks5://proxy.example.com:1080"
client, err := httpc.New(cfg)
if err != nil {
log.Fatal(err)
}
defer client.Close()
result, err := client.Get("https://api.example.com/data")
if err != nil {
log.Fatal(err)
}
// 输出:状态: 200, 代理: socks5://proxy.example.com:1080
log.Printf("状态: %d, 代理: %s", result.StatusCode(), result.Meta.ProxyURL)
}认证与脱敏
代理凭据直接写在 URL 的 userinfo 部分:
cfg := httpc.DefaultConfig()
cfg.Connection.ProxyURL = "http://user:[email protected]:8080"凭据自动脱敏
Config.String() 会把代理 URL 中的用户名和密码替换为 ***:***;错误信息与日志中的 URL 同样自动脱敏(凭据与敏感查询参数打码)。凭据不会泄漏进日志,但配置本身仍需妥善保管。
系统代理检测与 NO_PROXY
启用后自动检测操作系统的代理设置,无需手动指定 ProxyURL:
cfg := httpc.DefaultConfig()
cfg.Connection.EnableSystemProxy = true平台差异
| 平台 | 检测来源 |
|---|---|
| Windows | 注册表 Internet Settings(ProxyEnable / ProxyServer) |
| macOS | networksetup 命令读取首选网络服务的 Web/Secure Web Proxy |
| Linux | 环境变量 HTTP_PROXY / HTTPS_PROXY |
Meta.ProxyURL 不含系统代理
系统代理的选择不记录到 Result.Meta.ProxyURL(该字段仅在显式 Connection.ProxyURL 或 ProxyPool 下有值,直连与系统代理均为空)。需要逐请求确认出口代理时,请改用显式配置。
检测顺序与细节
- 环境变量优先(全平台):先读
HTTP_PROXY/HTTPS_PROXY/NO_PROXY(大小写均识别),有值则直接使用,不再查系统设置。 - 平台检测兜底:无环境变量时读取平台设置。Linux 桌面(GNOME/KDE)的代理通常已由会话导出为环境变量,引擎不直接读 gsettings/dconf。
- 请求分流:HTTPS 请求优先用
HTTPS_PROXY,未设置回退HTTP_PROXY;HTTP 请求只用HTTP_PROXY,未设置时也回退HTTPS_PROXY——与 net/http 的解析顺序一致。 - CGI 环境直连:来自环境变量的代理在 CGI 环境(
REQUEST_METHOD已设置)下不生效,与 net/http 行为一致。 - 裸地址自动补全:
HTTP_PROXY=proxy:8080这类无协议头的值自动按http://处理。 - 缓存:检测结果在客户端生命周期内缓存,不随环境变量变化热更新;需要感知变化时新建客户端。
NO_PROXY 绕过规则
NO_PROXY 指定不走代理的主机,语义与 net/http 的 httpproxy 包一致:
| 规则 | 示例 | 匹配范围 |
|---|---|---|
| 全部绕过 | * | 所有主机直连 |
| 域名后缀 | example.com 或 .example.com | 该域名及其全部子域 |
| 通配子域 | *.example.com | 等价于 .example.com |
| IP 字面量 | 10.0.0.5 | 精确匹配该 IP |
| CIDR 网段 | 10.0.0.0/8 | 网段内全部 IP |
| 主机 + 端口 | example.com:443 | 主机匹配且端口精确一致 |
多个规则用逗号分隔;localhost 始终直连,无需写入 NO_PROXY。
# Linux/macOS 设置示例
export HTTPS_PROXY=http://proxy.corp.example.com:8080
export NO_PROXY=localhost,127.0.0.1,.internal.corp.com,10.0.0.0/8# Windows (PowerShell) 设置示例
$env:HTTPS_PROXY = "http://proxy.corp.example.com:8080"
$env:NO_PROXY = "localhost,127.0.0.1,.internal.corp.com"动态 localhost 代理的限制
系统代理模式下,SSRF 豁免名单只在客户端构建时探测一次。若运行期间系统代理切换到新的内网/回环地址(如 127.0.0.1),该地址可能被 SSRF 防护拦截。动态场景请显式设置 Connection.ProxyURL(代理地址始终豁免 SSRF 校验)或配置 SSRFExemptCIDRs。
代理池
当需要跨多个代理 IP 分发请求(采集、负载分散、IP 轮换),代理池提供自动轮换、被动熔断与按状态码换代理——无需任何外部组件。
基本用法
package main
import (
"log"
"github.com/cybergodev/httpc"
)
func main() {
cfg := httpc.DefaultConfig()
cfg.Connection.ProxyPool = []string{
"http://proxy1:8080",
"http://proxy2:8080",
"http://proxy3:8080",
}
cfg.Connection.ProxyPoolStrategy = httpc.ProxyStrategyRoundRobin // 默认值,可省略
client, err := httpc.New(cfg)
if err != nil {
log.Fatal(err)
}
defer client.Close()
result, err := client.Get("https://api.example.com/data")
if err != nil {
log.Fatal(err)
}
// 输出:状态: 200, 代理: http://proxy1:8080(下一次请求自动轮到 proxy2)
log.Printf("状态: %d, 代理: %s", result.StatusCode(), result.Meta.ProxyURL)
}池内条目支持 http、https、socks5、socks5h 协议,可以混用。
配置字段
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
ProxyPool | []string | nil | 代理 URL 列表 |
ProxyPoolStrategy | ProxyStrategy | ProxyStrategyRoundRobin | 选择策略 |
ProxyFailureThreshold | int | 3(0 回退) | 连续连接失败熔断阈值 |
ProxyCooldown | time.Duration | 30s(0 回退) | 熔断代理冷却时间 |
ProxyRotatePerRequest | bool | false | 每次独立请求强制换代理(关闭空闲连接复用) |
ProxyRotateOnStatus | []int | nil | 触发换代理重试的状态码(每项须在 100–599) |
选择策略
| 策略 | 常量 | 说明 |
|---|---|---|
| 轮询(默认) | ProxyStrategyRoundRobin | 按顺序循环选择,每次选择推进游标 |
| 随机 | ProxyStrategyRandom | 从健康代理中均匀随机选取 |
轮询 + 重试 = 自动换 IP
轮询策略在每次选择时推进游标,因此重试再次触发选择时天然落到下一个代理,无需任何额外配置。
被动熔断
代理池内置被动健康检查。只有连接层失败(dial/TLS)才会触发熔断,HTTP 状态码不会:
代理连接失败
↓
失败计数 +1
↓
连续失败 ≥ ProxyFailureThreshold → 熔断(移出轮换)
↓
等待 ProxyCooldown → 半开探测(恢复轮换)
↓
成功 → 重置计数,关闭熔断
首次失败 → 重新熔断cfg.Connection.ProxyFailureThreshold = 5 // 更宽容,容忍偶发抖动
cfg.Connection.ProxyCooldown = 60 * time.Second // 冷却更久当所有代理都被熔断时,返回冷却时间最短(最接近恢复)的代理作为兜底,而非直接失败。
状态码轮换
用于 Cloudflare/WAF 等 IP 封锁场景——返回特定状态码时自动换代理重试:
package main
import (
"log"
"github.com/cybergodev/httpc"
)
func main() {
cfg := httpc.DefaultConfig()
cfg.Connection.ProxyPool = []string{
"http://proxy1:8080",
"http://proxy2:8080",
"http://proxy3:8080",
}
cfg.Connection.ProxyRotateOnStatus = []int{403} // 收到 403 时换代理重试
cfg.Retry.MaxRetries = 3 // 必须启用重试
client, err := httpc.New(cfg)
if err != nil {
log.Fatal(err)
}
defer client.Close()
result, err := client.Get("https://protected-site.example.com/data")
if err != nil {
log.Fatal(err)
}
// 输出:状态: 200, 代理: http://proxy2:8080, 尝试: 2
log.Printf("状态: %d, 代理: %s, 尝试: %d",
result.StatusCode(), result.Meta.ProxyURL, result.Meta.Attempts)
}状态码轮换 ≠ 熔断
ProxyRotateOnStatus 触发的轮换不会熔断代理——IP 封锁往往目标特定(一个代理在 A 站被封,在 B 站可能正常)。熔断仅由连接层失败触发。需 Retry.MaxRetries > 0 才生效。
当 ProxyRotateOnStatus 设置且代理池有多个代理时,重试预算自动提升至 len(ProxyPool) - 1(受 MaxRetries 上限 10 约束),确保每个代理都有机会被尝试。
每请求轮换
ProxyRotatePerRequest 解决的是连接复用导致代理隧道固化的问题:HTTP 连接池会复用已建立的 TCP 连接,包括其代理隧道。这意味着对同一主机的连续请求会复用前一次请求的代理,即使 ProxyPoolStrategy 已轮换选择器游标。
启用后,每次请求开始时关闭所有空闲连接,强制 Transport 重新评估代理池——代价是无连接复用(每次请求新建连接+代理隧道),但保证按请求轮换:
package main
import (
"log"
"github.com/cybergodev/httpc"
)
func main() {
cfg := httpc.DefaultConfig()
cfg.Connection.ProxyPool = []string{
"http://proxy1:8080",
"http://proxy2:8080",
"http://proxy3:8080",
}
cfg.Connection.ProxyRotatePerRequest = true // 每次请求换代理
client, err := httpc.New(cfg)
if err != nil {
log.Fatal(err)
}
defer client.Close()
for i := 0; i < 3; i++ {
result, err := client.Get("https://api.example.com/data")
if err != nil {
log.Fatal(err)
}
// 输出(依次):http://proxy1:8080 / http://proxy2:8080 / http://proxy3:8080
log.Printf("第 %d 次请求经由: %s", i+1, result.Meta.ProxyURL)
}
}适用场景
适用于对同一主机的采集——每次请求的源 IP 不同,降低被目标站点 IP 封锁的风险。对于不同主机的请求,连接复用不会绑定同一代理,通常无需启用。
与 ProxyRotateOnStatus 一样,ProxyRotatePerRequest 也会在代理池有多个代理时自动提升重试预算至 len(ProxyPool) - 1(上限 10),确保每个代理至少被尝试一次。
确定性轮换
状态码轮换的重试与普通重试不同:引擎为每个请求预留一个基准代理索引,第 N 次重试固定使用「基准 + N」对应的代理,带来三个保证:
- 重试必换代理:同一请求内,第 N 次重试与第 N-1 次必然落在不同代理上;
- 重定向链不脱轨:同一次尝试内的多次跳转共享同一索引,不会因跟随重定向多消费了几次代理选择而错位;
- 跨请求持续轮换:不同请求的基准索引递增,整体保持轮询/随机分布。
此外,代理本身的连接失败(拨号/TLS 失败)在轮换激活时总是可重试——即使常规分类判定不可重试(如永久性地址错误),下一次也会换代理再试,配合被动熔断把坏代理自然淘汰。重试侧细节见重试与容错。
查询本次请求使用的代理
代理池场景常需要审计「这次请求到底用了哪个出口」。两个工具配合:
Result.Meta.ProxyURL:报告产生最终响应的那次尝试所使用的代理;直连时为空字符串,系统代理(EnableSystemProxy)同样不记录(仍为空)。轮换场景下每次重试可能用不同代理,该字段对应最终那次尝试。WithOnResponse回调:每次尝试(含换代理的重试)都会触发,可观察逐次尝试的状态码与尝试次数。
package main
import (
"log"
"github.com/cybergodev/httpc"
)
func main() {
cfg := httpc.DefaultConfig()
cfg.Connection.ProxyPool = []string{
"http://proxy1:8080",
"http://proxy2:8080",
"http://proxy3:8080",
}
cfg.Connection.ProxyRotateOnStatus = []int{403}
client, err := httpc.New(cfg)
if err != nil {
log.Fatal(err)
}
defer client.Close()
result, err := client.Get("https://protected-site.example.com/data",
httpc.WithOnResponse(func(resp httpc.ResponseMutator) error {
log.Printf("第 %d 次尝试收到 %d", resp.Attempts(), resp.StatusCode())
return nil
}),
)
if err != nil {
log.Fatal(err)
}
// 输出:最终代理: http://proxy2:8080, 总尝试: 2
log.Printf("最终代理: %s, 总尝试: %d", result.Meta.ProxyURL, result.Meta.Attempts)
}回调按尝试执行,中间件按请求执行
WithOnResponse 在引擎内部触发,每次尝试(含重试)都会执行;中间件链则包裹整个重试周期,一个逻辑请求只执行一次。逐次尝试的观测用回调,整请求粒度的审计用中间件。
代理与重试的交互
代理轮换与重试引擎深度联动,两条规则值得记住。
1. 重试预算自动提升。 设置 ProxyRotateOnStatus 或 ProxyRotatePerRequest 且代理池多于 1 个时:
有效 MaxRetries = max(配置的 MaxRetries, len(ProxyPool) - 1)(上限 10)例如 5 个代理、配置 MaxRetries = 3:预算提升为 4(= 5 - 1),首次请求用 proxy1,收到 403 后依次换 proxy2…proxy5,每个代理各尝试一次。
2. 代理连接失败强制重试。 轮换激活时,代理本身的连接失败(拨号失败、TLS 失败)绕过常规可重试分类,直接进入下一次重试并换到下一个代理——永久失效的代理(端口错误、主机不可达)无需人工预先剔除,强制重试 + 连续失败熔断会自然淘汰它们。
重试条件、退避数学与跨重试共享的总超时预算详见重试与容错。
代理场景的安全注意
代理相关功能自动处理以下安全细节,无需手动配置:
- SSRF 豁免:代理主机地址(
ProxyURL与ProxyPool全部条目)自动加入 SSRF 豁免列表,不会被私有 IP 检查阻断——本地代理(如127.0.0.1:7890)也能正常工作。 - 去重:代理池中相同
host:port的条目自动合并,避免轮换偏斜和重复计数。 - URL 验证:所有代理 URL 经安全校验(协议白名单 http/https/socks5/socks5h、host 非空、注入防护),非法值在
New()即返回错误而非静默忽略。
与 TLS 的关系:HTTPS 请求经 HTTP 代理的 CONNECT 隧道仍是端到端 TLS——证书校验、最低 TLS 版本、证书固定均针对目标站点照常生效,代理只能转发密文。担心本地 DNS 污染时优先用 socks5h(域名解析交给代理端);启用 DoH 时业务域名走加密解析,但代理地址本身不经过 DoH(代理由开发者显式配置,直接拨代理主机)。SSRF 防护全貌见 SSRF 防护。
常见问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 代理不生效 | ProxyURL 与 ProxyPool 同时设置,ProxyURL 优先 | 清空 ProxyURL,仅用 ProxyPool |
| 对同一主机连续请求出口 IP 不变 | 连接复用绑定了上一次的代理隧道 | 启用 ProxyRotatePerRequest |
| 代理频繁熔断 | ProxyFailureThreshold 太低 | 增大阈值或 ProxyCooldown |
| 状态码轮换没生效 | Retry.MaxRetries = 0,或代理池只有 1 个代理 | 设 MaxRetries > 0;池内至少 2 个代理 |
| 全部代理熔断会怎样 | 全池连续失败 | 引擎兜底返回最接近恢复的代理,不会直接失败;检查代理可用性与阈值 |
| 系统代理没被检测到 | 环境变量缺失且平台设置未开启 | 确认 HTTP_PROXY/HTTPS_PROXY 或系统代理开关 |
| localhost 代理被 SSRF 拦截 | 系统代理的豁免名单只在构建时探测一次 | 显式设置 Connection.ProxyURL(始终豁免)或配置 SSRFExemptCIDRs |
| 收到 403 换代理后代理被熔断了吗 | 误以为状态码会触发熔断 | 不会——状态码轮换不熔断代理,仅连接层失败才熔断 |
Meta.ProxyURL 一直为空 | 仅系统代理生效——EnableSystemProxy 不记录到该字段 | 需要观测出口时改用显式 ProxyURL 或 ProxyPool |
完整的字段说明见 配置 API — 代理池。
最佳实践
| 场景 | 建议配置 |
|---|---|
| 企业固定出口 | ProxyURL(http/https/socks5 按需) |
| 跟随用户系统设置 | EnableSystemProxy + NO_PROXY 放行内网段 |
| 多代理负载分散 | ProxyPool + 默认轮询策略 |
| 同一主机的采集 | ProxyPool + ProxyRotatePerRequest |
| CF/WAF IP 封锁 | ProxyPool + ProxyRotateOnStatus: []int{403} |
| 代理质量参差 | 增大 ProxyFailureThreshold(容忍抖动)与 ProxyCooldown |
| 需要审计出口 IP | 读取 Result.Meta.ProxyURL,配合 WithOnResponse 观察逐次尝试 |
| 代理凭据管理 | 写在 URL userinfo,日志自动脱敏;配置文件单独保管 |
轮换的性能代价
ProxyRotatePerRequest 与状态码轮换的重试路径都会关闭空闲连接,牺牲连接复用换取出口轮换。对性能敏感且无轮换需求的场景保持默认(复用连接)即可,空闲连接管理详见连接池与 DNS。