このガイドでは、.NET 8 上の C# で HttpClient プロキシを設定する方法について説明します。具体的には、WebProxy と NetworkCredential の組み合わせ、 HttpClientHandlerとSocketsHttpHandlerの選択、IHttpClientFactoryを用いたクライアントの正しい再利用、タイムアウトとキャンセル、環境変数による設定、バイパスリスト、および本番環境で実際に遭遇する2つの例外について解説します。 最後に、スティッキーなレジデンシャルセッションを開き、終了時の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 のままであり、プロキシを使用しない場合とまったく同じように、ターゲットに対する証明書の検証が行われます。
認証情報は URL ではなく NetworkCredential に格納されるため、パスワード内の特殊文字をパーセントエンコードする必要はありません。これは、プロキシ URL 内の userinfo のみを受け入れるクライアントに比べて、実用的な利点の一つです。
HttpClientHandler と SocketsHttpHandler の比較
2つのハンドラクラスがプロキシを受け付けます。HttpClientHandler は従来からの API ですが、.NET Core 2.1 以降、すべてのプラットフォームで SocketsHttpHandler に委譲されるようになったため、プロキシのサポートに関して機能的な違いはありません。 SocketsHttpHandlerを直接使用する理由は、それが提供する追加の設定項目にあり、そのうちの1つはプロキシにとって非常に重要です。
// 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変更も反映されます。2分が妥当なデフォルト値ですが、接続の自然入れ替わりよりも頻繁にローテーションを行いたい場合は、この時間を短縮してください。
以下の表は、各オプションがどのような場合に適しているかをまとめたものです。
| 要件 | ハンドラー | 設定 |
|---|
| 最小限のプロキシ設定、既存のコード | HttpClientHandler | Proxy, UseProxy = true |
| IPアドレスのローテーションを伴う、有効期限付きの接続再利用 | SocketsHttpHandler | PooledConnectionLifetime |
| プロキシホストごとの接続数制限 | SocketsHttpHandler | MaxConnectionsPerServer |
| 1つのクライアントに対するプロキシの無効化 | いずれか | UseProxy = false |
1つの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 のプロキシチケットに関するエラーのほぼすべては、2 つのエラーモードに起因しています。1 つ目は「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})");
}
2つ目は、前述したタイムアウトを示す「TaskCanceledException」です。両方に共通する重要なルールは、407は設定エラーであり再試行してはならないのに対し、タイムアウトやターゲットからの5xxエラーは一時的なものであり、新しいセッションでの再試行が妥当であるということです。
ユーザー名によるスティッキーセッション
ポート 9000 では、新しい接続ごとに新しい出口 IP が割り当てられます。1 つのフローで複数のリクエスト(ログイン後のデータページやページネーションされた結果など)に同じ IP を使用する必要がある場合は、ポート 10000 を使用し、ユーザー名に基づいてセッションを選択してください。 この構文では、基本ユーザー名に接尾辞が追加されます。ジオターゲティングの場合は「-country-xx」、8文字の英数字IDが付与される場合は「-session-XXXXXXXX」、オプションで180から86400までの値が割り当てられる場合は「-lifetime-SECONDS」となります。
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)
};
}
このプロキシ上に構築されたクライアント経由のすべてのリクエストは、10分間同じ出口IPを共有します。IPを切り替えるには、新しいセッションIDを使用して新しいハンドラーを構築してください。セッションは認証情報に紐付けられているため、同じユーザー名を持つ2つのクライアントは、プロセス間でさえ同じ出口IPを共有します。
出力IPの確認
HttpClientのプロキシが使用されていると決して仮定せず、必ず確認してください。 クライアントから https://api.ipify.org?format=json(または https://httpbin.org/ip)をリクエストし、実際のアドレスと比較してください。スティッキーセッションの場合、2回の呼び出しで同じ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エラーの検出、および一時的な障害発生時の新規セッションによる最大3回の再試行といった要素をすべて統合しています。 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 ごとに 1 つのクライアントをキャッシュすることになります。
よくある間違い
リクエストごとに HttpClient を作成する
新しいクライアントが作成されるたびに、新しい CONNECT トンネルが開かれ、TIME_WAIT にソケットが残ってしまいます。1 つのインスタンスを再利用するか、IHttpClientFactory を使用し、ローテーションは PooledConnectionLifetime に任せてください。
https:// プロキシアドレスの使用
プロキシ経由の通信はプレーンHTTPですが、HTTPSのターゲットはCONNECTトンネル内で転送されます。WebProxyアドレスでhttps://スキームを使用すると、.NETはプロキシ自体とTLSネゴシエーションを行うよう指示されますが、プレーンHTTPを期待するエンドポイントに対してはこれが失敗します。
407エラーの再試行
407エラーは、プロキシが認証情報を承認しなかったことを意味します。同じユーザー名とパスワードで再試行しても時間の無駄です。速やかに処理を中止し、ユーザー名の構文、特にセッションIDの長さと有効期間の範囲を確認してください。
NO_PROXYおよびBypassListの無視
プロキシが DefaultProxy から提供される場合、ヘルスチェックや内部 API を含め、そのプロセス内のすべてのアウトバウンド呼び出しでそれが使用されます。それらのホストを NO_PROXY または BypassList にリストするか、名前付きクライアントでのみプロキシを設定してください。
次のステップ
上記のコードは、Geonodeの住宅用プロキシ(195カ国以上で250万のIPがオンライン、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 が発生するため、これら 2 つを区別することができます。
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秒の範囲で指定された有効期間中、1つの出口IPを共有します。新しいIPが必要な場合は、8文字の新しいセッションIDを生成してください。
一般家庭向けHttpClientプロキシを使用してスクレイピングを行う場合、どのような制限が適用されますか?
帯域幅はギガバイト単位で課金されるため、必要な分のみをダウンロードし、可能な限り画像の取得は避けてください。スティッキーセッションによるIPの保持期間は最大24時間ですが、デバイスがオフラインになるとそれより早く終了する場合があるため、コードではIPの変更に対応する必要があります。プロキシを使用しても、サイトの利用規約、robotsディレクティブ、またはレート制限が適用されないわけではありません。