mcpField
D
dotnet-http-client

dotnet-http-client

github.com/wshaddix開発8684 回インストール35 週間前に更新
リポジトリ

dotnet-http-client について

`.NET` で `IHttpClientFactory` を使って HTTP API を利用するための開発スキルです。クライアントの設計、レジリエンス処理、ハンドラー構成、テストを扱います。

主な機能

  1. 01名前付き・型付き HTTP クライアント
  2. 02レジリエンスハンドラーの統合
  3. 03DelegatingHandler パイプライン
  4. 04クライアント設定とライフタイム
  5. 05クライアントとハンドラーのテスト

活用シーン

  1. 01API に適したクライアント方式の選択
  2. 02認証やリクエストログ用ハンドラーの追加
  3. 03送信 HTTP 呼び出しへのレジリエンス適用
  4. 04制御された応答による HTTP クライアントのテスト

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-http-client

インストール後、Claude Code で/dotnet-http-clientを入力して読み込まれたか確認します。

詳細

dotnet-http-client

IHttpClientFactory を使用して、.NET アプリケーションで HTTP API を利用するためのベストプラクティスです。名前付きクライアントと型付きクライアント、レジリエンスパイプラインとの統合、横断的関心事に対応する DelegatingHandler チェーン、テスト戦略を取り上げます。

対象外: DI コンテナーの仕組みとサービスのライフタイムについては、[skill:dotnet-csharp-dependency-injection] を参照してください。Async/await のパターンとキャンセレーショントークンの伝播については、[skill:dotnet-csharp-async-patterns] を参照してください。レジリエンスパイプラインの構成(Polly v8、リトライ、サーキットブレーカー、タイムアウト戦略)は、[skill:dotnet-resilience] が担当します。統合テストフレームワークについては、WebApplicationFactory と HTTP クライアントのテストパターンを扱う [skill:dotnet-integration-testing] を参照してください。

相互参照:[skill:dotnet-resilience] はレジリエンスパイプラインの構成、[skill:dotnet-csharp-dependency-injection] はサービスの登録、[skill:dotnet-csharp-async-patterns] は非同期 HTTP パターンについて説明しています。


IHttpClientFactory を使用する理由

HttpClient インスタンスを直接作成すると、2 つの問題が発生します。

  1. ソケットの枯渇 -- 各 HttpClient インスタンスは独自の接続プールを保持します。多数のインスタンスを作成して破棄すると、利用可能なソケットが枯渇します(SocketException: Address already in use)。
  2. DNS 情報の陳腐化 -- 長期間存続するシングルトンの HttpClient は DNS ルックアップを無期限にキャッシュするため、ブルーグリーンデプロイやフェイルオーバー時の DNS 変更を反映できません。

IHttpClientFactory は、HttpMessageHandler のライフタイムを自動的なプール化とローテーション(デフォルト:ハンドラーのライフタイムは 2 分)で管理することで、両方の問題を解決します。

// Do not do this
var client = new HttpClient(); // Socket exhaustion risk

// Do not do this either
static readonly HttpClient _client = new(); // DNS staleness risk

// Do this -- use IHttpClientFactory
builder.Services.AddHttpClient();

名前付きクライアント

異なる構成で複数の API を利用する場合は、名前を付けてクライアントを登録します。

// Registration
builder.Services.AddHttpClient("catalog-api", client =>
{
    client.BaseAddress = new Uri("https://catalog.internal");
    client.DefaultRequestHeaders.Add("Accept", "application/json");
    client.Timeout = TimeSpan.FromSeconds(30);
});

builder.Services.AddHttpClient("payment-api", client =>
{
    client.BaseAddress = new Uri("https://payments.internal");
    client.DefaultRequestHeaders.Add("X-Api-Version", "2");
});

// Usage
public sealed class OrderService(IHttpClientFactory clientFactory)
{
    public async Task<Product?> GetProductAsync(
        string productId, CancellationToken ct)
    {
        var client = clientFactory.CreateClient("catalog-api");
        var response = await client.GetAsync($"/products/{productId}", ct);

        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }

        response.EnsureSuccessStatusCode();
        return await response.Content
            .ReadFromJsonAsync<Product>(ct);
    }
}

型付きクライアント

型付きクライアントは、強く型付けされたインターフェイスの背後に HTTP ロジックをカプセル化します。サービスが複数の操作を持つ単一の API を利用する場合は、型付きクライアントを優先してください。

// Typed client class
public sealed class CatalogApiClient(HttpClient httpClient)
{
    public async Task<Product?> GetProductAsync(
        string productId, CancellationToken ct = default)
    {
        var response = await httpClient.GetAsync(
            $"/products/{productId}", ct);

        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }

        response.EnsureSuccessStatusCode();
        return await response.Content
            .ReadFromJsonAsync<Product>(ct);
    }

    public async Task<PagedResult<Product>> ListProductsAsync(
        int page = 1,
        int pageSize = 20,
        CancellationToken ct = default)
    {
        var response = await httpClient.GetAsync(
            $"/products?page={page}&pageSize={pageSize}", ct);

        response.EnsureSuccessStatusCode();
        return (await response.Content
            .ReadFromJsonAsync<PagedResult<Product>>(ct))!;
    }

    public async Task<Product> CreateProductAsync(
        CreateProductRequest request,
        CancellationToken ct = default)
    {
        var response = await httpClient.PostAsJsonAsync(
            "/products", request, ct);

        response.EnsureSuccessStatusCode();
        return (await response.Content
            .ReadFromJsonAsync<Product>(ct))!;
    }
}

// Registration
builder.Services.AddHttpClient<CatalogApiClient>(client =>
{
    client.BaseAddress = new Uri("https://catalog.internal");
    client.DefaultRequestHeaders.Add("Accept", "application/json");
});
インターフェイスを使用する型付きクライアント

テスト容易性を高めるため、インターフェイスを定義します。

public interface ICatalogApiClient
{
    Task<Product?> GetProductAsync(string productId, CancellationToken ct = default);
    Task<PagedResult<Product>> ListProductsAsync(int page = 1, int pageSize = 20, CancellationToken ct = default);
}

public sealed class CatalogApiClient(HttpClient httpClient) : ICatalogApiClient
{
    // Implementation as above
}

// Registration with interface
builder.Services.AddHttpClient<ICatalogApiClient, CatalogApiClient>(client =>
{
    client.BaseAddress = new Uri("https://catalog.internal");
});

レジリエンスパイプライン

Microsoft.Extensions.Http.Resilience を使用して HTTP クライアントにレジリエンスを適用します。パイプラインの構成、戦略オプション、移行ガイダンスの詳細については、[skill:dotnet-resilience] を参照してください。

標準レジリエンスハンドラー(推奨)

標準ハンドラーは、適切なデフォルト値で完全なパイプライン(レートリミッター、合計タイムアウト、リトライ、サーキットブレーカー、試行タイムアウト)を適用します。

builder.Services
    .AddHttpClient<CatalogApiClient>(client =>
    {
        client.BaseAddress = new Uri("https://catalog.internal");
    })
    .AddStandardResilienceHandler();
カスタムオプションを指定した標準ハンドラー
builder.Services
    .AddHttpClient<CatalogApiClient>(client =>
    {
        client.BaseAddress = new Uri("https://catalog.internal");
    })
    .AddStandardResilienceHandler(options =>
    {
        options.Retry.MaxRetryAttempts = 5;
        options.Retry.Delay = TimeSpan.FromSeconds(1);
        options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(15);
        options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(5);
        options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(60);
    });
ヘッジングハンドラー(読み取り専用 API 向け)

テールレイテンシーが重要となる、冪等な読み取り操作向けです。

builder.Services
    .AddHttpClient("search-api")
    .AddStandardHedgingHandler(options =>
    {
        options.Hedging.MaxHedgedAttempts = 2;
        options.Hedging.Delay = TimeSpan.FromMilliseconds(500);
    });

ヘッジングと標準リトライのどちらを使うべきかについては、[skill:dotnet-resilience] を参照してください。


DelegatingHandler

DelegatingHandler は、送信リクエストと受信レスポンスを処理するメッセージハンドラーのパイプラインを提供します。HTTP トラフィックに適用する横断的関心事には、これらを使用します。

ハンドラーパイプラインの順序

ハンドラーは、リクエストでは登録順(外側から内側)、レスポンスでは逆順に実行されます。

Request  --> Handler A --> Handler B --> Handler C --> HttpClientHandler --> Server
Response <-- Handler A <-- Handler B <-- Handler C <-- HttpClientHandler <-- Server
一般的なハンドラー
リクエストのログ記録
public sealed class RequestLoggingHandler(
    ILogger<RequestLoggingHandler> logger) : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var stopwatch = Stopwatch.StartNew();

        logger.LogInformation(
            "HTTP {Method} {Uri}",
            request.Method,
            request.RequestUri);

        var response = await base.SendAsync(request, cancellationToken);

        stopwatch.Stop();
        logger.LogInformation(
            "HTTP {Method} {Uri} responded {StatusCode} in {ElapsedMs}ms",
            request.Method,
            request.RequestUri,
            (int)response.StatusCode,
            stopwatch.ElapsedMilliseconds);

        return response;
    }
}

// Registration
builder.Services.AddTransient<RequestLoggingHandler>();
builder.Services
    .AddHttpClient<CatalogApiClient>(/* ... */)
    .AddHttpMessageHandler<RequestLoggingHandler>();
API キー認証
public sealed class ApiKeyHandler(IConfiguration config) : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var apiKey = config["ExternalApi:ApiKey"]
            ?? throw new InvalidOperationException("API key not configured");

        request.Headers.Add("X-Api-Key", apiKey);

        return base.SendAsync(request, cancellationToken);
    }
}

// Registration
builder.Services.AddTransient<ApiKeyHandler>();
builder.Services
    .AddHttpClient<CatalogApiClient>(/* ... */)
    .AddHttpMessageHandler<ApiKeyHandler>();
ベアラートークン(ダウンストリーム認証から取得)
public sealed class BearerTokenHandler(
    IHttpContextAccessor httpContextAccessor) : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var token = httpContextAccessor.HttpContext?
            .Request.Headers.Authorization
            .ToString()
            .Replace("Bearer ", "");

        if (!string.IsNullOrEmpty(token))
        {
            request.Headers.Authorization =
                new AuthenticationHeaderValue("Bearer", token);
        }

        return base.SendAsync(request, cancellationToken);
    }
}
相関 ID の伝播
public sealed class CorrelationIdHandler : DelegatingHandler
{
    private const string HeaderName = "X-Correlation-Id";

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        if (!request.Headers.Contains(HeaderName))
        {
            var correlationId = Activity.Current?.Id
                ?? Guid.NewGuid().ToString();
            request.Headers.Add(HeaderName, correlationId);
        }

        return base.SendAsync(request, cancellationToken);
    }
}
複数のハンドラーの連結

ハンドラーは実行順に追加します。

builder.Services.AddTransient<CorrelationIdHandler>();
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddTransient<RequestLoggingHandler>();

builder.Services
    .AddHttpClient<CatalogApiClient>(client =>
    {
        client.BaseAddress = new Uri("https://catalog.internal");
    })
    .AddHttpMessageHandler<CorrelationIdHandler>()   // 1st (outermost): add correlation ID
    .AddHttpMessageHandler<BearerTokenHandler>()     // 2nd: add auth token
    .AddHttpMessageHandler<RequestLoggingHandler>()  // 3rd: log request/response
    .AddStandardResilienceHandler();                 // 4th (innermost): resilience pipeline

注: IHttpClientFactory では、先に登録したハンドラーが外側になります。最後に追加した .AddStandardResilienceHandler() は最も内側に配置され、実際の HTTP 呼び出しを直接ラップします。つまり、リトライはレジリエンスハンドラー内で行われ、外側の DelegatingHandler は再実行されません。これは通常、適切な動作です。相関 ID と認証トークンは外側のハンドラーによって一度だけ設定され、レジリエンス層は生の HTTP 呼び出しをリトライします。リトライごとのトークン更新(期限切れのベアラートークンなど)が必要な場合は、トークンハンドラーをレジリエンス境界の内側に移動するか、カスタムの ResiliencePipelineBuilder コールバックを使用してください。


構成パターン

構成からのベースアドレス
builder.Services.AddHttpClient<CatalogApiClient>(client =>
{
    var baseUrl = builder.Configuration["Services:CatalogApi:BaseUrl"]
        ?? throw new InvalidOperationException(
            "CatalogApi base URL not configured");
    client.BaseAddress = new Uri(baseUrl);
});
{
  "Services": {
    "CatalogApi": {
      "BaseUrl": "https://catalog.internal"
    }
  }
}
ハンドラーの有効期間

ハンドラーのデフォルトの有効期間は2分です。DNS の特性が異なるサービスでは調整してください。

builder.Services
    .AddHttpClient<CatalogApiClient>(/* ... */)
    .SetHandlerLifetime(TimeSpan.FromMinutes(5));

短い有効期間(1分):DNS が頻繁に変更されるロードバランサー配下のサービス向けです。 長い有効期間(5~10分):接続を再利用するとパフォーマンスが向上する、安定した内部サービス向けです。


HTTP クライアントのテスト

MockHttpMessageHandler を使った単体テスト

制御された応答を返すモックハンドラーを指定して、型付きクライアントをテストします。

public sealed class CatalogApiClientTests
{
    [Fact]
    public async Task GetProductAsync_ReturnsProduct_WhenFound()
    {
        // Arrange
        var expectedProduct = new Product { Id = "p1", Name = "Widget" };
        var handler = new MockHttpMessageHandler(
            new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = JsonContent.Create(expectedProduct)
            });

        var httpClient = new HttpClient(handler)
        {
            BaseAddress = new Uri("https://test.local")
        };

        var client = new CatalogApiClient(httpClient);

        // Act
        var result = await client.GetProductAsync("p1");

        // Assert
        Assert.NotNull(result);
        Assert.Equal("Widget", result.Name);
    }

    [Fact]
    public async Task GetProductAsync_ReturnsNull_WhenNotFound()
    {
        var handler = new MockHttpMessageHandler(
            new HttpResponseMessage(HttpStatusCode.NotFound));

        var httpClient = new HttpClient(handler)
        {
            BaseAddress = new Uri("https://test.local")
        };

        var client = new CatalogApiClient(httpClient);

        var result = await client.GetProductAsync("missing");

        Assert.Null(result);
    }
}

// Reusable mock handler
public sealed class MockHttpMessageHandler(
    HttpResponseMessage response) : HttpMessageHandler
{
    private HttpRequestMessage? _lastRequest;

    public HttpRequestMessage? LastRequest => _lastRequest;

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        _lastRequest = request;
        return Task.FromResult(response);
    }
}
DelegatingHandler のテスト

内部ハンドラーを指定して、ハンドラーを個別にテストします。

public sealed class ApiKeyHandlerTests
{
    [Fact]
    public async Task AddsApiKeyHeader()
    {
        // Arrange
        var config = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["ExternalApi:ApiKey"] = "test-key-123"
            })
            .Build();

        var innerHandler = new MockHttpMessageHandler(
            new HttpResponseMessage(HttpStatusCode.OK));

        var handler = new ApiKeyHandler(config)
        {
            InnerHandler = innerHandler
        };

        var client = new HttpClient(handler)
        {
            BaseAddress = new Uri("https://test.local")
        };

        // Act
        await client.GetAsync("/test");

        // Assert
        Assert.NotNull(innerHandler.LastRequest);
        Assert.Equal(
            "test-key-123",
            innerHandler.LastRequest.Headers
                .GetValues("X-Api-Key").Single());
    }
}
WebApplicationFactory を使った統合テスト

DI の登録を含む、HTTP クライアントのパイプライン全体をテストします。

// See [skill:dotnet-integration-testing] for WebApplicationFactory patterns

名前付きクライアントと型付きクライアント――選択ガイド

要素名前付きクライアント型付きクライアント
API の規模シンプル(呼び出し1~2回)多機能(複数の操作)
型安全性文字列名が必要強い型付け
カプセル化HTTP ロジックは利用側のクラスに記述HTTP ロジックはクライアントクラスに記述
テスト容易性IHttpClientFactory をモッククライアントのインターフェイスをモック
複数の APIAPI ごとに1つの名前API ごとに1つのクラス
推奨事項臨時またはシンプルな呼び出しAPI 利用の基本パターン

デフォルトでは型付きクライアントを使用してください。 完全な型付きクライアントクラスを作るとかえって冗長になるような、シンプルで単発の HTTP 呼び出しに限り、名前付きクライアントを使用してください。


主な原則

  • 常に IHttpClientFactory を使用する――アプリケーションコードで new HttpClient() を使用しないでください
  • 型付きクライアントを優先する――強い型付けのインターフェイスの背後に HTTP ロジックをカプセル化します
  • パイプラインを使って回復性を適用する――手動の再試行ループではなく、AddStandardResilienceHandler()([skill:dotnet-resilience]を参照)を使用します
  • ハンドラーの役割を絞る――各 DelegatingHandler は認証、ロギング、相関 ID など、1つの処理を担当するようにします
  • ハンドラーを Transient として登録する――DelegatingHandler はクライアントインスタンスごとに作成され、リクエスト間で状態を保持するべきではありません
  • すべての箇所で CancellationToken を渡す――エンドポイントから型付きクライアント、HTTP 呼び出しまで渡します
  • ReadFromJsonAsync / PostAsJsonAsync を使用する――StringContent による手動シリアル化は避けてください

エージェントが陥りやすい注意点

  1. new で HttpClient を作成しないでください――必ず IHttpClientFactory または型付きクライアントを注入します。直接インスタンス化するとソケットが枯渇します。
  2. 型付きクライアントを破棄しないでください――ハンドラーの有効期間はファクトリーが管理します。HttpClient インスタンスを破棄しても問題はありません(プールされた接続は閉じられません)が、using で囲むと誤解を招きます。
  3. 末尾にパスが付いた BaseAddress を設定しないでください――new Uri("https://api.example.com/v2") は相対 URI と結合すると /v2 を削除します。new Uri("https://api.example.com/v2/")(末尾にスラッシュ)を使用するか、呼び出しに絶対 URI を使用してください。
  4. 後から追加した回復性ハンドラーは最も内側になることを理解してください――AddHttpMessageHandler の呼び出し後に登録された AddStandardResilienceHandler() は、HTTP 呼び出しを直接ラップします。再試行時に外側の DelegatingHandler は再実行されません。多くの場合、これは正しい動作です(トークンや相関 ID は一度だけ設定されます)。再試行のたびにトークンを更新する必要がある場合は、回復性ハンドラーの後にトークンハンドラーを配置するか、カスタムのパイプラインコールバックを使用してください。
  5. DelegatingHandler を Singleton として登録しないでください――DelegatingHandler は HttpMessageHandler パイプラインとともにプールされるため、Transient にする必要があります。

参考資料

コメント