Geonode logo
Geonode Team

Geonode Team

更新于:2026年10月16日

发布于:2026年10月16日

C# HttpClient 代理配置:凭据、超时和验证

本指南介绍了如何在 .NET 8 的 C# 环境中配置 HttpClient 代理:使用 `WebProxy` 配合 `NetworkCredential`, `HttpClientHandler` 与 `SocketsHttpHandler` 之间的选择、使用 `IHttpClientFactory` 时正确的客户端复用、超时与取消、环境变量配置、绕过列表,以及你在生产环境中实际会遇到的两种异常。 文章最后提供了一个完整的控制台程序示例,该程序会建立一个持久的住宅会话,验证退出时的 IP 地址,并在失败时使用新会话重试。 下文中的所有内容均针对 .NET 8 并使用顶级语句,但该 API 自 .NET Core 3.0 以来一直保持稳定,因此相同的代码在 .NET 6 和 7 上也能编译通过。

本指南介绍了如何在 .NET 8 的 C# 环境中配置 HttpClient 代理:使用 WebProxy 配合 NetworkCredential, HttpClientHandler 与 SocketsHttpHandler 之间的选择、使用 IHttpClientFactory 时正确的客户端复用、超时与取消、环境变量配置、绕过列表,以及你在生产环境中实际会遇到的两种异常。 文章最后提供了一个完整的控制台程序示例,该程序会建立一个持久的住宅会话,验证退出时的 IP 地址,并在失败时使用新会话重试。

下文中的所有内容均针对 .NET 8 并使用顶级语句,但该 API 自 .NET Core 3.0 以来一直保持稳定,相同的代码在 .NET 6 和 7 上也能编译通过。

WebProxy 和 NetworkCredential

其基础组件是 System.Net.WebProxy,相关文档请参见 learn.microsoft.com。 它存储代理地址,并通过其 Credentials 属性存储用户名和密码——当代理发出 407 身份验证请求时,处理程序会发送这些信息。

using System.Net;

var proxy = new WebProxy("http://premium-residential.geonode.com:9000")
{
    Credentials = new NetworkCredential("USERNAME", "PASSWORD")
};

即使目标是 HTTPS,地址方案也是 http://。 对于 https:// 格式的 URL,处理程序会向代理发送一个 CONNECT 请求,并在该隧道内与目标建立 TLS 连接,因此代理跳转本身仍保持明文 HTTP 形式,且证书验证对目标的处理方式与不使用代理时完全一致。

由于凭据存储在 NetworkCredential 中而非 URL 中,因此无需对密码中的特殊字符进行百分比编码。这是相对于仅接受在代理 URL 中包含用户信息的客户端而言的一大实际优势。

HttpClientHandler 与 SocketsHttpHandler 的对比

这两个处理程序类都支持代理。HttpClientHandler 是沿用已久的 API;自 .NET Core 2.1 起,它在所有平台上都委托给 SocketsHttpHandler,因此在代理支持方面没有功能上的区别。 直接使用 SocketsHttpHandler 的原因在于它提供了额外的配置选项,其中一项对代理配置至关重要。

// Option A: HttpClientHandler
var handlerA = new HttpClientHandler
{
    Proxy = proxy,
    UseProxy = true
};

// Option B: SocketsHttpHandler (preferred for new code)
var handlerB = new SocketsHttpHandler
{
    Proxy = proxy,
    UseProxy = true,
    PooledConnectionLifetime = TimeSpan.FromMinutes(2)
};

PooledConnectionLifetime 就是这个配置选项。HttpClient 会将连接放入连接池,并在不同请求间复用这些连接。当端口 9000 上运行着轮转代理时,出口 IP 是在与代理建立连接时选定的;后续通过同一连接池连接发起的请求可能会保留相同的出口 IP。 为连接池设置有限的生存周期会强制进行周期性重连,这也能同步代理主机的 DNS 变更。两分钟是一个合理的默认值;若希望轮换频率高于连接自然更新的频率,可缩短该时间。

下表总结了各项选项的适用场景。

需求处理程序设置
最简代理配置,现有代码HttpClientHandlerProxy, UseProxy = true
有限连接复用且 IP 轮换SocketsHttpHandlerPooledConnectionLifetime
限制每个代理主机的连接数SocketsHttpHandlerMaxConnectionsPerServer
为单个客户端禁用代理任意UseProxy = false

复用一个 HttpClient

HttpClient 的设计初衷是仅创建一次,并在应用程序的整个生命周期内重复使用。如果为每个请求都创建一个新实例,在高负载下会导致套接字耗尽;而且,如果使用了代理,还会白白浪费掉您刚刚花费延迟建立的池化 CONNECT 隧道。 在控制台应用程序或工作线程中,请保留一个静态实例。

var http = new HttpClient(handlerB)
{
    Timeout = TimeSpan.FromSeconds(30)
};

ASP.NET Core 中的 IHttpClientFactory

在 ASP.NET Core 中,通过 ConfigurePrimaryHttpMessageHandler 注册一个命名客户端并挂载处理程序。该工厂会为您管理处理程序的生命周期,并按计划轮换处理程序,从而同时避免了套接字耗尽问题和 DNS 过期问题。

// Program.cs
using System.Net;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient("geonode", client =>
{
    client.Timeout = TimeSpan.FromSeconds(30);
})
.ConfigurePrimaryHttpMessageHandler(() =>
{
    var proxy = new WebProxy("http://premium-residential.geonode.com:9000")
    {
        Credentials = new NetworkCredential(
            Environment.GetEnvironmentVariable("GEONODE_USER"),
            Environment.GetEnvironmentVariable("GEONODE_PASS"))
    };

    return new SocketsHttpHandler
    {
        Proxy = proxy,
        UseProxy = true,
        PooledConnectionLifetime = TimeSpan.FromMinutes(2)
    };
});

var app = builder.Build();

app.MapGet("/ip", async (IHttpClientFactory factory) =>
{
    var client = factory.CreateClient("geonode");
    return await client.GetStringAsync("https://api.ipify.org?format=json");
});

app.Run();

在需要客户端的任何位置注入 IHttpClientFactory,并调用 CreateClient("geonode")。返回的 HttpClient 创建成本很低,因为底层耗时的处理程序是共享的。

超时与取消令牌

HttpClient.Timeout 是请求的总允许时间,包括连接到代理、CONNECT 握手、与目标服务器建立 TLS 连接以及读取响应头。 默认值为 100 秒,对于爬虫工作者而言过长。请在客户端进行设置;若需更精细的控制,可为每个请求传递一个 CancellationToken。

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));

try
{
    var body = await http.GetStringAsync("https://example.com/", cts.Token);
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
    // HttpClient.Timeout elapsed
}
catch (TaskCanceledException)
{
    // the CancellationToken was cancelled (our 15 s budget)
}

与直接连接相比,住宅出口会增加延迟,因此请为每个请求预留几秒钟的额外时间。如果多个请求在同一阶段出现一致的超时,通常表明通往代理主机的网络路径存在问题,而非目标服务器速度慢。

DefaultProxy 和环境变量

如果在处理程序上未设置 Proxy,.NET 将使用 HttpClient.DefaultProxy。 在 .NET Core 3.0 及更高版本中,该静态属性由 HTTP_PROXY、HTTPS_PROXY 和 NO_PROXY 环境变量初始化,且 URL 可能包含凭据。

export HTTP_PROXY="http://USERNAME:PASSWORD@premium-residential.geonode.com:9000"
export HTTPS_PROXY="http://USERNAME:PASSWORD@premium-residential.geonode.com:9000"
export NO_PROXY="localhost,127.0.0.1,.internal"

您还可以在启动时通过代码赋值 HttpClient.DefaultProxy,这将影响所有未重写 Proxy 的处理程序。 这在容器环境中非常实用,但属于全局状态:除非 NO_PROXY 列出了相应主机,否则健康检查客户端或对内部 API 的调用也会通过住宅代理。对于一次性脚本以外的任何场景,在命名处理程序上显式设置 WebProxy 会更清晰。

BypassList 和 BypassProxyOnLocal

WebProxy 拥有自己的排除机制。BypassProxyOnLocal = true 会跳过名称中不包含点的主机(例如 localhost 或纯主机名)。 BypassList 接受针对目标 URI 的正则表达式匹配。

var proxy = new WebProxy("http://premium-residential.geonode.com:9000")
{
    Credentials = new NetworkCredential("USERNAME", "PASSWORD"),
    BypassProxyOnLocal = true,
    BypassList = new[] { @"^https?://(.*\.)?internal\.example(:\d+)?/" }
};

请注意,这些是正则表达式模式,而非通配符模式;未转义的点将匹配任何字符。

处理 407 及其他错误

几乎所有的 HttpClient 代理凭据问题都可归因于两种错误模式。第一种是 407 代理身份验证所需(Proxy Authentication Required),这意味着代理在转发任何内容之前就拒绝了凭据。 该错误通常表现为 HttpRequestException;根据 .NET 版本和故障发生点不同,您也可能收到状态码为 HttpStatusCode.ProxyAuthenticationRequired 的响应。请同时检查这两种情况。

using System.Net;
using System.Net.Http;

try
{
    using var response = await http.GetAsync("https://example.com/");

    if (response.StatusCode == HttpStatusCode.ProxyAuthenticationRequired)
    {
        throw new InvalidOperationException("proxy rejected credentials (407)");
    }

    response.EnsureSuccessStatusCode();
    var html = await response.Content.ReadAsStringAsync();
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.ProxyAuthenticationRequired)
{
    // Also 407: fix the username grammar or password; retrying will not help.
}
catch (HttpRequestException ex)
{
    // Connection refused, DNS failure, TLS error, or EnsureSuccessStatusCode on 4xx/5xx.
    Console.Error.WriteLine($"request failed: {ex.Message} (status {ex.StatusCode})");
}

第二种是表示超时的 TaskCanceledException,上文已作说明。针对这两种情况,有一条重要规则:407 错误属于配置错误,绝不能重试;而超时或来自目标服务器的 5xx 错误属于暂时性问题,通常可以尝试通过新建会话进行重试。

通过用户名实现会话粘连

9000 端口会为每次新连接分配一个新的出口 IP。当一个流量流需要使用同一个 IP 处理多个请求(例如登录后访问数据页面,或分页结果)时,请使用 10000 端口,并通过用户名来识别会话。 语法规则会在您的基础用户名后附加后缀:-country-xx 用于地理定位,-session-XXXXXXXX 带有 8 位字母数字 ID,以及可选的 -lifetime-SECONDS(范围从 180 到 86400)。

static string NewSessionId() =>
    Guid.NewGuid().ToString("N")[..8]; // 8 alphanumeric chars

static WebProxy StickyProxy(string baseUser, string pass, string country = "us", int lifetime = 600)
{
    var user = $"{baseUser}-country-{country}-session-{NewSessionId()}-lifetime-{lifetime}";
    return new WebProxy("http://premium-residential.geonode.com:10000")
    {
        Credentials = new NetworkCredential(user, pass)
    };
}

通过基于此代理构建的客户端发出的每个请求,在十分钟内将共享相同的出口 IP。若要切换 IP,请使用新的会话 ID 构建一个新的处理程序。由于会话与凭据绑定,因此即使在不同进程中,两个具有相同用户名的客户端也会共享相同的出口 IP。

验证出口 IP

切勿假设 HttpClient 代理正在使用中;请务必确认。 通过客户端发送请求 https://api.ipify.org?format=json(或 https://httpbin.org/ip),并将结果与您的实际地址进行对比。在粘性会话模式下,两次调用返回的 IP 地址相同;若使用端口 9000 并建立新连接,则返回的 IP 地址会有所不同。

using System.Text.Json;

var json = await http.GetStringAsync("https://api.ipify.org?format=json");
var ip = JsonDocument.Parse(json).RootElement.GetProperty("ip").GetString();
Console.WriteLine($"exit IP: {ip}");

完整的控制台示例(.NET 8)

下面的程序将所有功能整合在一起:粘性会话、有限超时、407 错误检测,以及在发生暂态故障时最多尝试三次并建立新会话。 使用 dotnet new console -n ProxyDemo 创建它,替换 Program.cs,设置 GEONODE_USER 和 GEONODE_PASS,然后运行 dotnet run。

// Program.cs  (.NET 8, top-level statements)
using System.Net;
using System.Text.Json;

var baseUser = Environment.GetEnvironmentVariable("GEONODE_USER")
    ?? throw new InvalidOperationException("GEONODE_USER not set");
var password = Environment.GetEnvironmentVariable("GEONODE_PASS")
    ?? throw new InvalidOperationException("GEONODE_PASS not set");

var target = args.Length > 0 ? args[0] : "https://api.ipify.org?format=json";

for (var attempt = 1; attempt <= 3; attempt++)
{
    var sessionId = Guid.NewGuid().ToString("N")[..8];
    var user = $"{baseUser}-country-us-session-{sessionId}-lifetime-600";

    var handler = new SocketsHttpHandler
    {
        Proxy = new WebProxy("http://premium-residential.geonode.com:10000")
        {
            Credentials = new NetworkCredential(user, password)
        },
        UseProxy = true,
        PooledConnectionLifetime = TimeSpan.FromMinutes(2)
    };

    using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));

    try
    {
        // 1. Confirm the exit IP for this session.
        var ipJson = await http.GetStringAsync("https://api.ipify.org?format=json", cts.Token);
        var ip = JsonDocument.Parse(ipJson).RootElement.GetProperty("ip").GetString();
        Console.WriteLine($"[attempt {attempt}] session {sessionId} exit IP {ip}");

        // 2. Fetch the real target on the same IP.
        using var response = await http.GetAsync(target, cts.Token);

        if (response.StatusCode == HttpStatusCode.ProxyAuthenticationRequired)
        {
            Console.Error.WriteLine("407: proxy rejected credentials, not retrying");
            return;
        }

        if ((int)response.StatusCode is 403 or 429 or >= 500)
        {
            Console.Error.WriteLine($"[attempt {attempt}] got {(int)response.StatusCode}, rotating session");
            continue;
        }

        var body = await response.Content.ReadAsStringAsync(cts.Token);
        Console.WriteLine($"{(int)response.StatusCode} {target} ({body.Length} chars)");
        return;
    }
    catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.ProxyAuthenticationRequired)
    {
        Console.Error.WriteLine("407: proxy rejected credentials, not retrying");
        return;
    }
    catch (HttpRequestException ex)
    {
        Console.Error.WriteLine($"[attempt {attempt}] network error: {ex.Message}");
    }
    catch (TaskCanceledException)
    {
        Console.Error.WriteLine($"[attempt {attempt}] timed out");
    }

    await Task.Delay(TimeSpan.FromMilliseconds(500 * attempt));
}

Console.Error.WriteLine("gave up after 3 attempts");

处理程序在每次尝试时都会被有意重新创建:粘性会话与凭据中的用户名相关联,因此新会话需要新的 WebProxy。在长期运行的服务中,您应按会话 ID 而不是按请求来缓存每个客户端。

常见错误

每次请求都创建一个 HttpClient

每个新客户端都会打开一个新的 CONNECT 隧道,并在 TIME_WAIT 中留下套接字。请复用一个实例,或者使用 IHttpClientFactory,并让 PooledConnectionLifetime 负责轮询。

使用https://代理地址

代理跳转采用明文 HTTP;HTTPS 目标则通过 CONNECT 隧道传输。在 WebProxy 地址上使用 https:// 方案会指示 .NET 与代理本身协商 TLS,但这在预期明文 HTTP 的端点上会导致失败。

407 错误的重试

407 错误表示代理未接受凭据。使用相同的用户名和密码重试只会浪费时间。应快速失败并检查用户名格式,特别是会话 ID 的长度和有效期范围。

忽略 NO_PROXY 和 BypassList

当代理来自 DefaultProxy 时,该进程中的所有出站调用都会使用该代理,包括健康检查和内部 API。请将这些主机列在 NO_PROXY 或 BypassList 中,或者仅在指定的客户端上配置该代理。

下一步

上述代码无需修改即可在 Geonode 住宅代理上运行:覆盖 195 多个国家/地区、在线 IP 数量达 250 万个,支持 HTTP、HTTPS 及同一主机上的 SOCKS5 代理,并通过此处展示的用户名语法选择最长 24 小时的粘性会话。 针对国家、州、城市和ASN的定位使用相同的后缀,因此缩小出口位置范围只需修改字符串,无需修改代码。定价方案为pay-as-you-go(无合同),10 GB起,每月7.92美元,并提供5美元的3天试用。 详情请参阅 住宅代理,或在 定价页面 上比较不同套餐。 对于后续流程,请从 网页抓取用例 开始。

常见问题

如何在 C# 中为 HttpClient 设置带用户名和密码的代理?

创建一个包含代理地址的 WebProxy,并将它的 Credentials 属性设置为一个包含用户名和密码的 NetworkCredential。 将该 WebProxy 赋值给 HttpClientHandler 或 SocketsHttpHandler 的 Proxy 属性, 将 UseProxy 设为 true,并将处理程序传递给 HttpClient 的构造函数。当代理返回 407 状态码时,该处理程序会发送凭据。

作为代理,我应该使用 HttpClientHandler 还是 SocketsHttpHandler?

在 .NET Core 2.1 及更高版本中,HttpClientHandler 是 SocketsHttpHandler 的轻量级封装,因此两者均可使用。SocketsHttpHandler 暴露了 PooledConnectionLifetime,允许您定期回收连接池中的连接。 这在轮转代理中尤为重要,因为被重复使用的连接在关闭前会保持相同的出口 IP。新代码建议优先使用 SocketsHttpHandler。

为什么我的 HttpClient 代理请求会抛出 TaskCanceledException?

HttpClient 将超时报错为 TaskCanceledException,而非专门的超时异常。当 HttpClient.Timeout 超时或您传入的 CancellationToken 被取消时,会抛出该异常。 在 .NET 5 及更高版本中,当原因在于客户端超时,内部异常为 TimeoutException,这使您能够区分这两种情况。

HttpClient 会读取 HTTP_PROXY 和 HTTPS_PROXY 环境变量吗?

是的。 在 .NET Core 3.0 及更高版本中,HttpClient.DefaultProxy 由 HTTP_PROXY、HTTPS_PROXY 和 NO_PROXY 初始化,当未显式设置 Proxy 时,处理程序会使用它。 凭据可以包含在 URL 中。在容器环境中设置环境变量虽然方便,但在代码中显式设置 WebProxy 更容易理解。

如何在多个 HttpClient 代理请求中保持相同的 IP 地址?

使用粘性端口 10000,并在用户名后添加会话后缀,例如 USER-country-us-session-a1b2c3d4-lifetime-600。 使用该用户名发送的所有请求将在指定时长(以秒为单位,范围为 180 至 86400)内共享一个出口 IP。若需获取新的 IP,请生成一个新的 8 位字符会话 ID。

通过住宅型 HttpClient 代理进行爬取时有哪些限制?

带宽按每千兆字节计费,因此请仅下载所需内容,并尽可能避免下载图片。粘性会话的 IP 地址最长保留 24 小时,如果设备断开连接,会话可能会提前结束,因此您的代码必须处理 IP 地址变更的情况。使用代理并不意味着您可以免于遵守网站的服务条款、robots 指令或速率限制。