HttpClientクラスAutomaticDecompressionプロパティを使用してContent-Encodingヘッダに従った自動的なコンテンツの展開を行うと、Content-Encodingヘッダの値が空として取得される問題と、その対応策について。

以下の内容は2023年05月05日時点での状況に基づく。

前提・AutomaticDecompressionを使用した自動的な展開

はじめに前提知識として、HttpClientクラスで圧縮アルゴリズムを有効にする方法について。

まず、AutomaticDecompressionプロパティに対して有効にする圧縮アルゴリズムを指定したSocketsHttpHandlerもしくはHttpClientHandlerを作成する。 これにより、リクエスト送信時にAccept-Encodingヘッダが自動的に設定されるようになる。 これ以降ではSocketsHttpHandlerを使用する。

続いて、作成したSocketsHttpHandlerを使用するHttpClientを作成し、リクエストを送信する。 これにより、レスポンスとして返送されるContent-Encodingヘッダの内容に応じて、自動的にコンテンツが展開される。

HttpClientクラスでAccept-Encoding・Content-Encodingヘッダを扱う
using System;
using System.Net;
using System.Net.Http;

using var httpClient = new HttpClient(
  // リクエストの送受信時に使用するSocketsHttpHandlerを作成・指定する
  // HttpClientHandlerの場合も同様
  handler: new SocketsHttpHandler() {
    // Content-Encodingヘッダの内容に応じて、コンテンツの自動的な展開を行う
    AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
  },
  // このHttpClientのDisposeと同時にSocketsHttpHandlerもDisposeする
  disposeHandler: true
);

// リクエストの送信・レスポンスの受信を行う
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri("http://example.test/"));
using var response = httpClient.Send(request);

// 受信したコンテンツを文字列として読み取る
// ここで、Content-Encodingヘッダで圧縮アルゴリズムが指定されていれば、
// その指定に応じて自動的に展開が行われる
Console.WriteLine(await response.Content.ReadAsStringAsync());

// ただし、サーバーからContent-Encodingヘッダが返送されていたとしても、
// HttpResponseMessage.Content.Headers.ContentEncodingプロパティの値は常に空になる
Console.WriteLine("Content-Encoding = '{0}'", string.Join(", ", response.Content.Headers.ContentEncoding));

// Content-Encoding = '' と表示される

このとき上記のコードにあるように、HttpResponseMessage.Content.Headers.ContentEncodingプロパティは、実際にサーバーから返送されたContent-Encodingヘッダの内容ではなく、常に空になる。

SocketsHttpHandlerはAutomaticDecompressionプロパティに従って自動的な展開を行うと同時に、それ以上展開が必要ない・ヘッダの内容が意味を成さないことを表すために、ContentEncodingを空に設定したHttpContentを返す動作になっている。

また同様に、HttpResponseMessage.Content.Headers.ContentLengthプロパティもnullに設定される動作になっている。

対応策

AutomaticDecompressionを無効にして自前で処理する

ContentEncodingおよびContentLengthが空(もしくはnull)に設定される動作は、AutomaticDecompressionプロパティを使用して自動的な展開を行う場合に特有の動作となっている。

そこで、Accept-Encodingヘッダの設定およびContent-Encodingヘッダの取得、コンテンツの展開を手動で行うことにより、この問題を回避できる。 ただし、SocketsHttpHandler・HttpClientHandlerが内部で行っていることを再実装する形になるので、若干コードが複雑になる。

AutomaticDecompressionを使用せず、Accept-Encoding・Content-Encodingヘッダの処理を自前で行う
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;

// HttpMessageHandlerは指定せず、デフォルトのHttpMessageHandlerを使用するHttpClientを作成する
using var httpClient = new HttpClient();

using var request = new HttpRequestMessage(HttpMethod.Get, new Uri("http://example.test/"));

// 手動でAccept-Encodingヘッダの値を設定する
request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("gzip"));
request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("deflate"));
request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("br"));

// リクエストの送信・レスポンスの受信を行う
using var response = httpClient.Send(request);

// この場合、ContentEncodingプロパティはサーバーから返送されたContent-Encodingヘッダの内容をそのまま返す
Console.WriteLine("Content-Encoding = '{0}'", string.Join(", ", response.Content.Headers.ContentEncoding));

// 一方、コンテンツの展開は手動で行う必要がある
var contentStream = response.Content.ReadAsStream();

// Content-Encodingヘッダに指定されているのと逆順で展開を行うStreamを作成する
foreach (var method in response.Content.Headers.ContentEncoding.Reverse()) {
  contentStream = method switch {
    "gzip" => new GZipStream(contentStream, CompressionMode.Decompress),
    "deflate" => new DeflateStream(contentStream, CompressionMode.Decompress),
    "br" => new BrotliStream(contentStream, CompressionMode.Decompress),
    _ => throw new NotSupportedException(),
  };
}

Console.WriteLine(new StreamReader(contentStream).ReadToEnd());

HttpResponseMessage.Contentの型情報から推測する (不完全)

AutomaticDecompressionプロパティを使用して自動的な展開を行った場合、HttpResponseMessage.Contentプロパティには展開済みのHttpContentが設定される。

そこで、HttpResponseMessage.Contentプロパティに設定されているオブジェクトの型情報を取得することによって、適用されていた圧縮アルゴリズムを推測する。

この方法では、Content-Encoding: deflate, gzipのように複数の圧縮アルゴリズムが適用されていた場合は、最後に展開されたアルゴリズムのみしか推測することができない。 また、公開されていない型の型名に依存した判定方法であるため、将来の変更によって動作が変わる可能性があり、結果は保証されない。

HttpResponseMessage.Contentの型情報からContent-Encodingヘッダの内容を推測する
using System;
using System.IO;
using System.Net;
using System.Net.Http;

using var httpClient = new HttpClient(
  // リクエストの送受信時に使用するSocketsHttpHandlerを作成・指定する
  handler: new SocketsHttpHandler() {
    // Content-Encodingヘッダの内容に応じて、コンテンツの自動的な展開を行う
    AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
  },
  // このHttpClientのDisposeと同時にSocketsHttpHandlerもDisposeする
  disposeHandler: true
);

// リクエストの送信・レスポンスの受信を行う
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri("http://example.test/"));
using var response = httpClient.Send(request);

// ここで、HttpResponseMessage.Content.Headers.ContentEncodingプロパティの値は実際のContent-Encodingの値を表さない
Console.WriteLine("Content-Encoding = '{0}'", string.Join(", ", response.Content.Headers.ContentEncoding));

// 代わりとして、HttpResponseMessage.Contentの型情報をもとに推測を行う
// HttpResponseMessage.Contentに設定されるオブジェクトは、圧縮アルゴリズムごとに
// 定義された型となっているため、その型名から圧縮アルゴリズムの推測を行う
// ただし、"Content-Encoding: deflate, gzip"のように複数の圧縮アルゴリズムが
// 適用されていた場合は、その最初に適用されたアルゴリズムしか推測できない
Console.WriteLine(
  "Content-Encoding (from type of Content) = '{0}'",
  response.Content.GetType().FullName switch {
    "System.Net.Http.DecompressionHandler+GZipDecompressedContent" => "gzip",
    "System.Net.Http.DecompressionHandler+DeflateDecompressedContent" => "deflate",
    "System.Net.Http.DecompressionHandler+BrotliDecompressedContent" => "br",
    _ => "",
  }
);

Console.WriteLine(await response.Content.ReadAsStringAsync());

その他の情報

カスタム処理を記述したDelegatingHandlerを既存処理に挟み込む方法については、公開APIを使ってレスポンスの受信からContent-Typeヘッダの上書きまでの間にカスタム処理を挟み込む方法がないため、実装できない。