Refit+Polly の初回 API 呼び出しで HttpRequestException / ObjectDisposedException が発生する原因と対策

.NET で Refit + Polly を組み合わせた HTTP 通信を実装していると、「初回の API 呼び出しだけ HttpRequestException / ObjectDisposedException で失敗し、2 回目以降は成功する」という不可解な現象に遭遇することがあります。本記事では、この症状がなぜ起こるのか、どこを疑うべきか、そしてどのように設計・実装を見直せば安定した HttpClient / Refit 構成になるのかを、具体的なコード例とチェックリスト付きで詳しく解説します。

目次

初回 API 呼び出し時に発生する HttpRequestException / ObjectDisposedException の症状

まず、今回の前提となる状況を整理します。

  • Refit + Polly を使った HTTP クライアントを実装している。
  • 最初の API 呼び出しで await httpResponseMessage.Content.ReadAsStringAsync() を実行した瞬間に例外が発生する。
  • 例外メッセージは概ね次のような内容:
    • System.Net.Http.HttpRequestException: Error while copying content to a stream.
    • ---> System.ObjectDisposedException: Cannot access a disposed object.
  • 2 回目以降の呼び出しは同じコード・同じエンドポイントでも正常に成功する。
  • HttpClient は以下のような構成になっている:
    • Refit を利用したインターフェースベースの API クライアント。
    • Polly によるリトライ(ほぼ全例外を対象にし、401/403 だけ除外)。
    • ヘッダーに Accept-Encoding: gzip を手動で付加。
    • AutomaticDecompression(GZip / Deflate)を有効化。
    • カスタム NonDisposableHttpClientHandler を使用。

同じコードでも「初回だけ」失敗する場合、HttpClient のライフサイクル や ストリームの dispose タイミング、あるいは リトライと本文読み取りの境界 に問題が潜んでいることが多いです。

このエラーで疑うべき典型パターン

上記のような「初回だけ HttpRequestException / ObjectDisposedException」の現象で、実際によく原因となるパターンを整理したものが次の表です。

パターン概要初回だけ失敗しやすい理由
レスポンス / ストリームの早期破棄ストリームを読み切る前に Dispose される初回だけ DI コンテナやハンドラのライフサイクルが揺らぎやすい
リトライ境界の設計ミス本文読み取りを Polly ポリシーの内側で行う最初の失敗時のレスポンスが破棄され、2 回目以降は別ルートで成功
圧縮ヘッダーの不整合Accept-Encoding 手動設定 + AutomaticDecompression初回コネクション確立時だけ Content-Length 不整合が顕在化する
カスタムハンドラの実装不備独自 NonDisposableHttpClientHandler の Dispose 実装など初回のパイプライン構築時にハンドラが誤って破棄される
CancellationToken の扱い呼び出し側で生成した CTS が途中で Cancel / Dispose される初回リクエスト開始直後に中断されるが、2 回目以降はタイミングがずれて成功
サーバ側の問題CDN やリバプロ配下で Content-Length や chunk が不正コールドスタート時だけ異常なレスポンスが返る

それぞれのパターンについて、もう少し踏み込んで見ていきます。

レスポンス/ストリームの早期破棄(dispose)

Refit でメソッドの戻り値を HttpResponseMessage にすると、レスポンス本文は未バッファの状態 で呼び出し側に渡されます。呼び出し側が ReadAsStringAsync() を呼ぶまでは、内部のストリームはまだ開いている状態です。

ここで、DI のスコープ終了やハンドラの Dispose、あるいは Polly による再実行のタイミングなどが重なってしまうと、

  • ストリームが読み取り中(または読み取り前)に Dispose される
  • その状態で Content.ReadAsStringAsync() を呼ぶ

という順序になり、ObjectDisposedException が発生します。

特に「初回だけ」発生するケースは、最初の DI スコープや HttpClient ハンドラの構築時にだけ余分な処理が走る ため、タイミングのズレが生じやすいという背景があります。

リトライポリシーと本文読み取りの境界

Polly を使った独自 AttemptAndRetry 実装などで、次のような構造になっていないでしょうか。

// 疑似コード
var response = await policy.ExecuteAsync(() => client.SendAsync(request));
var content  = await response.Content.ReadAsStringAsync(); // ここも「実質的に」ポリシー内扱い

表面上は ReadAsStringAsync がポリシー外に見えても、実際には HttpResponseMessage のライフサイクルがポリシーの中に縛られており、

  • 1 回目の SendAsync で得られた HttpResponseMessage を途中で破棄
  • リトライで 2 回目の SendAsync(これは成功)
  • しかし、読み取り対象が既に破棄されているストリーム

という不整合な状態を作り出していることがあります。

本文読み取りは「リトライの対象にしない」 という設計が重要です。リトライで繰り返すのは「リクエスト送信~レスポンスヘッダー取得」までに限定し、本文は 1 回だけ読むようにしましょう。

圧縮設定とヘッダーの不整合

以下のような構成になっている場合も要注意です。

  • HttpClientHandler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
  • さらに、HttpClient.DefaultRequestHeaders.Add("Accept-Encoding", "gzip"); を手動設定

通常は AutomaticDecompression を有効にすると、Accept-Encoding ヘッダーはハンドラ側で自動付与されます。ここに手動でさらにヘッダーを追加すると、

  • サーバが想定よりも変な圧縮レスポンスを返す
  • Content-Length / Transfer-Encoding と実際のデータ長が一致しない
  • 解凍中に「コピー先ストリームの破棄」と見なされて例外が飛ぶ

といった不具合を誘発しやすくなります。特に初回のコネクション確立時はキャッシュもなく、「ぎりぎりおかしな」レスポンスが返りやすいため、初回だけ失敗するという結果につながります。

カスタム NonDisposableHttpClientHandler の実装ミス

「HttpClient を使い回したい」「ハンドラを勝手に Dispose させたくない」といった理由で NonDisposableHttpClientHandler のようなカスタムハンドラを実装している場合、その Dispose 実装にバグがあると、下層のストリームを誤って破棄してしまいます。

さらに DI コンテナのスコープと絡んだとき、初回だけライフサイクルが特殊になり、最初のレスポンスだけハンドラが別インスタンス になっていた…ということも珍しくありません。

CancellationToken の扱いミス

次のようなパターンも見落としがちです。

  • CancellationTokenSource を呼び出し側で作成。
  • Polly の ExecuteAsync には渡すが、Refit のメソッド定義側では CancellationToken を受け取っていない。
  • どこか別の箇所で CTS を Cancel() or Dispose() してしまう。

この状態だと「送信はキャンセルされないが、読み取りタイミングで中断される」という中途半端な動きになり、結果としてストリームが途中で破棄されます。

サーバ側の Content-Length / 転送不備

クライアントをどれだけ整えても直らない場合、サーバ側の問題も疑うべきです。特に以下のような構成では、コールドスタート時だけ妙なレスポンスが返ってくることがあります。

  • クラウド環境でのコールドスタート(Functions / App Service 等)
  • CDN / リバースプロキシ配下
  • 動的圧縮(gzip)や chunked 転送が絡む構成

この場合、レスポンスヘッダーと実際のペイロード長 をログに出して比較するのが有効です。

最短で安定させるための設計方針

ここからは、どのように設計を変えると安定するのか、実践的な対策を優先度順に見ていきます。

Refit に逆シリアライズまで任せる(最優先)

根本的に安全なのは、HttpResponseMessage を自分で扱わない 構成にすることです。つまり、

  • Refit の戻り値を HttpResponseMessage ではなく ApiResponse<T> や T にする。
  • レスポンスのバッファリングとストリーム管理を Refit に任せる。

具体例を見てみます。

// 修正前(例)
public interface ITestApiService
{
    [Get("/API/MasterData/Options")]
    Task&lt;HttpResponseMessage&gt; GetMasterDataOptions(
        [AliasAs("optionCategory")] string optionCategory);
}

// 呼び出し側
var response = await _testApiService.GetMasterDataOptions("testCategory");
var json = await response.Content.ReadAsStringAsync();

これを次のように変更します。

// 修正後(推奨)
public interface ITestApiService
{
    [Get("/API/MasterData/Options")]
    Task&lt;ApiResponse&lt;List&lt;MasterDataOptionsApiModel&gt;&gt;&gt; GetMasterDataOptions(
        [AliasAs("optionCategory")] string optionCategory,
        CancellationToken ct = default);
}

// 呼び出し側
var res = await _testApiService
    .GetMasterDataOptions("testCategory", ct)
    .ConfigureAwait(false);

if (res.IsSuccessStatusCode)
{
    var data = res.Content; // すでにデシリアライズ済み
}
else
{
    // res.Error などから詳細をログ
}

この構成にするだけで、

  • 本文ストリームの二重読み取り
  • 読み取り前の早期 dispose
  • リトライとストリーム破棄の競合

といった事故のリスクを大きく下げることができます。

Polly リトライポリシー:本文読み取り後はリトライしない

次に、Polly のリトライ対象を「送信系の一過性エラー」に絞ります。ポイントは以下の通りです。

  • Handle<Exception> のような「何でもリトライ」は避ける。
  • HttpRequestException、5xx、408、429 などに限定する。
  • 本文の読み取りとデシリアライズはポリシーの外側で 1 回だけ行う。

HttpClientFactory と Polly.Extensions.Http を使った典型的な構成は次のようになります。

builder.Services
    .AddRefitClient&lt;ITestApiService&gt;(new RefitSettings(
        new NewtonsoftJsonContentSerializer()))
    .ConfigureHttpClient(c =&gt;
    {
        c.BaseAddress = new Uri(AppConstants.BaseUrl);
        c.Timeout = TimeSpan.FromSeconds(20);
        c.DefaultRequestHeaders.Add(AppConstants.SubscriptionKey, AppConstants.SubscriptionKeyValue);
    })
    .AddTransientHttpErrorPolicy(p =&gt;
        p.WaitAndRetryAsync(
            retryCount: 3,
            sleepDurationProvider: attempt =&gt; TimeSpan.FromSeconds(Math.Pow(2, attempt))));

この構成では、

  • Refit の HttpClient に Polly のリトライを統合
  • 送信~レスポンス取得までに発生した一過性エラーだけを自動リトライ
  • レスポンス本文の読み取り(逆シリアライズ)は Refit 側が 1 回だけ実施

という形になり、ストリーム破棄とリトライの衝突を防げます。

ハンドラと圧縮周りの整理

ハンドラとヘッダーの設定は、次のように「素直な構成」から始めるのがおすすめです。

項目NG な例推奨される例
Accept-EncodingDefaultRequestHeaders.Add("Accept-Encoding", "gzip")AutomaticDecompression に任せる
AutomaticDecompression未設定、もしくはヘッダーと矛盾する設定DecompressionMethods.GZip | DecompressionMethods.Deflate
カスタムハンドラ独自 NonDisposableHttpClientHandler を挿し込むまずは標準の SocketsHttpHandler のみで検証
ハンドラのライフサイクル手動 new / Dispose を繰り返すIHttpClientFactory に任せる

特に「現象が起きている環境」ですでに複雑なハンドラ構成になっている場合は、

  1. カスタムハンドラをすべて外す。
  2. Accept-Encoding 手動設定を削除する。
  3. 標準構成 + Refit + Polly だけに絞って再現性を確認する。

という順番でシンプル化していくと、原因の切り分けが格段に楽になります。

CancellationToken を一貫して伝播させる

Refit のメソッド定義には、必ず CancellationToken を受け取る引数を用意し、呼び出し元の CT をそのまま渡すようにします。

public interface ITestApiService
{
    [Get("/API/MasterData/Options")]
    Task&lt;ApiResponse&lt;List&lt;MasterDataOptionsApiModel&gt;&gt;&gt; GetMasterDataOptions(
        [AliasAs("optionCategory")] string optionCategory,
        CancellationToken ct = default);
}

呼び出し側は次のようにします。

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

var response = await _testApiService
    .GetMasterDataOptions("testCategory", cts.Token)
    .ConfigureAwait(false);

このように「同じ CT を送信~読み取りまで貫通させる」ことで、途中で謎のタイミングでストリームが破棄されるリスクを避けられます。特に UI アプリケーションでは CancellationToken.None を使って比較テストするのも有効です。

どうしても HttpResponseMessage を扱う必要がある場合の安全策

「監査ログのために生レスポンスを保存したい」「ヘッダーだけ独自処理したい」などの理由で、どうしても HttpResponseMessage を直接扱う必要があるケースもあります。その場合のポイントは以下です。

  • 読み取りは 1 回だけ にする。
  • 完全バッファリング してからデシリアライズする。
  • using でレスポンスのライフサイクルを読み取り完了まで閉じ込める。
using var response = await _testApiService
    .GetMasterDataOptions(category, ct)
    .ConfigureAwait(false);

response.EnsureSuccessStatusCode();

// バイト配列として完全に読み込む
var bytes = await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
var json = Encoding.UTF8.GetString(bytes);

// 自前でデシリアライズ
var model = JsonConvert.DeserializeObject&lt;List&lt;MasterDataOptionsApiModel&gt;&gt;(json);

このパターンでは、ストリームを途中で使い回すことがなくなるため、「Dispose 済みのストリームへ ReadAsStringAsync を呼んでしまう」事故を防げます。

短時間でできる原因切り分けチェックリスト

実際にトラブルシューティングする際、「何から手を付ければよいか」を短時間で判断できるようにチェックリスト形式で整理します。

ステップ確認内容期待する結果 / 判断ポイント
1Accept-Encoding の手動設定を削除初回エラーが消えた場合、圧縮設定の不整合が濃厚
2カスタムハンドラ(NonDisposable 等)を外す標準ハンドラだけで再現しなければ、カスタム実装の問題
3独自 Polly ラッパーを外し、拡張メソッドベースの標準構成に置き換えるリトライ境界の設計ミスが切り分けられる
4素の HttpClient で同エンドポイントを 1 回だけ叩いてみるRefit / Polly を通さずに再現するかどうかを確認
5レスポンスヘッダーをログ出力
(StatusCode / Content-Length / Transfer-Encoding / Content-Encoding / Connection)
Content-Length 不一致や chunked 転送の途中切断がないか確認
6同一 CancellationToken を Refit メソッドに渡す or CancellationToken.None で比較CT 周りの影響が排除できる
7すべての await に ConfigureAwait(false) を付与UI スレッドや同期コンテキストの影響を排除

この表の 1〜3 だけでも実施すると、「クライアント側の設定ミス」か「サーバ側の応答不備」かをかなりの精度で切り分けることができます。

サーバ側でチェックすべきポイント

クライアントの構成をシンプルにしてもなお初回だけエラーが出る場合、サーバ側の挙動を確認しましょう。特に以下を重点的に見てください。

  • Content-Length と実際のレスポンスボディ長が一致しているか。
  • Transfer-Encoding: chunked の場合、すべてのチャンクが正しく送出されているか。
  • 動的圧縮(gzip)が有効な場合、CDN / リバースプロキシとの組み合わせで二重圧縮になっていないか。
  • コールドスタート時だけレスポンス生成ロジックが別経路を通っていないか。

また、サーバ側でもログに

  • レスポンスサイズ
  • Content-Encoding / Transfer-Encoding

を出しておき、クライアント側のログと突き合わせると、どこで不一致が起きているのかを特定しやすくなります。

実際のリファクタリング例

最後に、よくある「不安定な構成」から「安定した構成」へのリファクタリング例を簡単にまとめます。

リファクタリング前の構成(よくあるパターン)

  • Refit メソッドの戻り値が Task<HttpResponseMessage>。
  • 手書きの AttemptAndRetry ポリシーでほぼ全例外をリトライ。
  • Accept-Encoding: gzip を手動で追加。
  • カスタム NonDisposableHttpClientHandler を挿入。
  • CancellationToken を部分的にしか渡していない。

この構成は、一見「頑張って堅牢にしている」ように見えますが、実際にはストリームとライフサイクルの境界があいまいで、今回のような初回エラーを引き起こしがちです。

リファクタリング後の構成(安定版)

  • Refit の戻り値を Task<ApiResponse<T>> に変更。
  • Polly.Extensions.Http を使い、送信系エラーだけをリトライ対象にする。
  • Accept-Encoding は手動で設定せず、AutomaticDecompression に任せる。
  • カスタムハンドラを外し、IHttpClientFactory + 標準ハンドラで運用。
  • CancellationToken を Refit メソッドまで一貫して伝播させる。

このように構成を整理すると、例外の発生箇所も明確になり、「どこが悪いのか分からない」という状況から抜け出しやすくなります。

まとめ:最短で押さえるべき 3 つのポイント

初回の API 呼び出しだけ HttpRequestException / ObjectDisposedException が発生する問題は、一見ランダムなネットワーク障害のように見えますが、実際には 設計上の境界のあいまいさ が原因であることがほとんどです。

特に、次の 3 点を押さえるだけで、多くのケースは解消します。

  1. Refit に逆シリアライズまで任せる
    戻り値を HttpResponseMessage ではなく ApiResponse<T> / T にし、ストリーム管理を Refit に委ねる。
  2. Polly のリトライ対象を送信系エラーに限定する
    HttpRequestException / 5xx / 408 / 429 などに絞り、本文読み取りはポリシー外で 1 回だけ行う。
  3. Accept-Encoding とハンドラをシンプルにする
    手動の Accept-Encoding 設定や複雑なカスタムハンドラを外し、標準構成 + AutomaticDecompression からスタートする。

それでも解消しない場合は、レスポンスヘッダーとペイロード長のログを突き合わせ、サーバ側の Content-Length や転送設定(gzip / chunked / リバースプロキシ配下の挙動)を疑ってみてください。クライアントとサーバ両側から「どこでストリームが不正になっているか」を追っていくことで、原因にたどり着きやすくなります。

安定した HttpClient / Refit + Polly 構成を作ることは、単に例外を消すだけでなく、将来の拡張やメンテナンスコストを大きく下げることにもつながります。本記事の内容をもとに、自身のプロジェクトの設計を一度見直してみてください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次