Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 19099] ファイルの概要

このコミットは、Goのnet/httpパッケージにおいて、HTTPレスポンスのgzip圧縮されたボディが不完全(短い)である場合にリクエストが失敗する問題を修正します。具体的には、gzipリーダーの初期化を遅延させることで、不正なgzipボディに対するエラーハンドリングを改善し、クライアントがより堅牢に動作するようにします。

コミット

commit 0944837f7900d18f9be7e6f77673e994399b7ea7
Author: Alexey Borzenkov <snaury@gmail.com>
Date:   Thu Apr 10 14:12:36 2014 -0700

    net/http: fix requests failing on short gzip body
    
    Fixes #7750.
    
    LGTM=bradfitz
    R=golang-codereviews, ibilicc, bradfitz
    CC=golang-codereviews
    https://golang.org/cl/84850043

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/0944837f7900d18f9be7e6f77673e994399b7ea7

元コミット内容

net/http: fix requests failing on short gzip body

Fixes #7750.

LGTM=bradfitz
R=golang-codereviews, ibilicc, bradfitz
CC=golang-codereviews
https://golang.org/cl/84850043

変更の背景

このコミットは、Goの標準ライブラリであるnet/httpパッケージが抱えていた、特定の条件下でHTTPリクエストが失敗するバグ(Issue #7750)を修正するために導入されました。この問題は、サーバーがHTTPレスポンスをgzip圧縮して返送する際に、そのgzipボディが不完全または途中で切れている("short gzip body")場合に発生していました。

従来のnet/httpクライアントの実装では、Content-Encoding: gzipヘッダが検出されると、レスポンスボディを読み始める前に即座にgzip.NewReaderを呼び出してgzipリーダーを初期化していました。しかし、この初期化の段階で、提供されたボディが有効なgzipフォーマットではない(例えば、短すぎる)場合、gzip.NewReaderはエラーを返していました。このエラーが即座に伝播し、結果としてHTTPリクエスト全体が失敗していました。

この挙動は、特にネットワークの不安定性やサーバー側の問題によって不完全なgzipレスポンスが送られてくるような状況で、クライアントアプリケーションの堅牢性を損ねていました。クライアントとしては、ボディを実際に読み込もうとするまで、その内容が不正であるかどうかを知る必要がない場合が多く、早期のエラーは不必要なリクエストの失敗につながっていました。

前提知識の解説

  • HTTP Content-Encoding (gzip): HTTPプロトコルにおいて、サーバーはレスポンスボディを圧縮して送信することができます。Content-Encoding: gzipヘッダは、ボディがgzip形式で圧縮されていることを示します。クライアントはこれを受け取ると、ボディを解凍してからアプリケーションに渡す必要があります。
  • io.Readerio.Closerインターフェース: Go言語における基本的なI/Oインターフェースです。
    • io.ReaderRead(p []byte) (n int, err error)メソッドを持ち、データストリームからの読み込みを抽象化します。
    • io.CloserClose() errorメソッドを持ち、リソースの解放を抽象化します。
    • io.ReadCloserはこれら両方を満たすインターフェースです。
  • gzip.NewReader: Goのcompress/gzipパッケージに含まれる関数で、io.Readerを受け取り、そのリーダーからgzip圧縮されたデータを読み込み、解凍されたデータを提供する新しいio.Reader*gzip.Reader型)を返します。この関数は、入力ストリームが有効なgzipフォーマットでない場合、初期化時にエラーを返すことがあります。
  • io.ErrUnexpectedEOF: ioパッケージで定義されているエラーで、予期せぬファイルの終端(End Of File)に達したことを示します。データが途中で切れている場合などに発生します。

技術的詳細

このコミットの主要な変更点は、net/httpパッケージ内のpersistConn構造体のreadLoopメソッドにおけるgzipリーダーの初期化ロジックです。

変更前: Content-Encoding: gzipヘッダが検出されると、resp.Body(元のレスポンスボディ)を引数としてgzip.NewReader(resp.Body)が即座に呼び出されていました。この呼び出しは、ボディの先頭部分を読み込み、gzipヘッダを解析しようとします。もしボディが短すぎたり、不正なgzipヘッダを含んでいたりすると、この時点でgzip.NewReaderはエラーを返し、そのエラーがreadLooperr変数に代入され、コネクションが閉じられてリクエストが失敗していました。

変更後: gzip.NewReaderの呼び出しを、実際にレスポンスボディからデータを読み込もうとするまで遅延させるように変更されました。これを実現するために、新しい内部型gzipReaderが導入されました。

gzipReaderは以下の特徴を持ちます。

  1. io.Readerio.Closerインターフェースを実装します。
  2. 内部に元のresp.Bodybody io.ReadCloser)と、遅延初期化されるgzip.Readerzr io.Reader)を保持します。
  3. Readメソッドが初めて呼び出されたときにのみ、gzip.NewReader(gz.body)を呼び出してgz.zrを初期化します。この初期化中にエラーが発生した場合、そのエラーがReadメソッドの呼び出し元に返されます。
  4. Closeメソッドは、単に元のgz.body.Close()を呼び出します。

この変更により、不正なgzipボディであっても、Content-Encoding: gzipヘッダが存在するだけで即座にリクエストが失敗することはなくなりました。エラーは、アプリケーションが実際にレスポンスボディを読み込もうとしたときに初めて発生するようになります。これにより、クライアントはより柔軟にエラーをハンドリングできるようになり、例えば、ボディを読み込む必要がない場合にはエラーを回避できるようになります。

また、この修正を検証するために、transport_test.goTestTransportGzipShortという新しいテストケースが追加されました。このテストは、意図的に短いgzipボディ(0x1f, 0x8bというgzipマジックナンバーのみ)を返すHTTPサーバーを立て、クライアントがそのレスポンスを読み込んだ際にio.ErrUnexpectedEOFを適切に返すことを確認します。これは、問題が修正され、期待されるエラーが適切なタイミングで発生することを示すものです。

コアとなるコードの変更箇所

src/pkg/net/http/transport.go

--- a/src/pkg/net/http/transport.go
+++ b/src/pkg/net/http/transport.go
@@ -812,13 +812,7 @@ func (pc *persistConn) readLoop() {
 				resp.Header.Del("Content-Encoding")
 				resp.Header.Del("Content-Length")
 				resp.ContentLength = -1
-				gzReader, zerr := gzip.NewReader(resp.Body)
-				if zerr != nil {
-					pc.close()
-					err = zerr
-				} else {
-					resp.Body = &readerAndCloser{gzReader, resp.Body}
-				}
+				resp.Body = &gzipReader{body: resp.Body}
 			}
 			resp.Body = &bodyEOFSignal{body: resp.Body}
 		}
@@ -1156,6 +1150,27 @@ func (es *bodyEOFSignal) condfn(err error) {
 	es.fn = nil
 }
 
+// gzipReader wraps a response body so it can lazily
+// call gzip.NewReader on the first call to Read
+type gzipReader struct {
+	body io.ReadCloser // underlying Response.Body
+	zr   io.Reader     // lazily-initialized gzip reader
+}
+
+func (gz *gzipReader) Read(p []byte) (n int, err error) {
+	if gz.zr == nil {
+		gz.zr, err = gzip.NewReader(gz.body)
+		if err != nil {
+			return 0, err
+		}
+	}
+	return gz.zr.Read(p)
+}
+
+func (gz *gzipReader) Close() error {
+	return gz.body.Close()
+}
+
 type readerAndCloser struct {
 	io.Reader
 	io.Closer

src/pkg/net/http/transport_test.go

--- a/src/pkg/net/http/transport_test.go
+++ b/src/pkg/net/http/transport_test.go
@@ -803,6 +803,33 @@ func TestTransportGzipRecursive(t *testing.T) {
 	}\n}\n 
+// golang.org/issue/7750: request fails when server replies with
+// a short gzip body
+func TestTransportGzipShort(t *testing.T) {
+	defer afterTest(t)
+	ts := httptest.NewServer(HandlerFunc(func(w ResponseWriter, r *Request) {
+		w.Header().Set("Content-Encoding", "gzip")
+		w.Write([]byte{0x1f, 0x8b})
+	}))
+	defer ts.Close()
+
+	tr := &Transport{}
+	defer tr.CloseIdleConnections()
+	c := &Client{Transport: tr}
+	res, err := c.Get(ts.URL)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer res.Body.Close()
+	_, err = ioutil.ReadAll(res.Body)
+	if err == nil {
+		t.Fatal("Expect an error from reading a body.")
+	}
+	if err != io.ErrUnexpectedEOF {
+		t.Errorf("ReadAll error = %v; want io.ErrUnexpectedEOF", err)
+	}
+}
+
 // tests that persistent goroutine connections shut down when no longer desired.
 func TestTransportPersistConnLeak(t *testing.T) {
 	if runtime.GOOS == "plan9" {

コアとなるコードの解説

src/pkg/net/http/transport.go の変更

  1. readLoop メソッド内の変更:

    • 以前は、Content-Encoding: gzipヘッダが設定されている場合、すぐにgzip.NewReader(resp.Body)を呼び出し、その結果をgzReaderに格納していました。この初期化でエラー(zerr)が発生すると、コネクションを閉じ、エラーを伝播させていました。
    • 新しいコードでは、この即時初期化のロジックが削除され、代わりにresp.Bodyが新しく定義された&gzipReader{body: resp.Body}でラップされます。これにより、gzip.NewReaderの呼び出しがgzipReaderReadメソッドが初めて呼び出されるまで遅延されます。
  2. gzipReader 構造体の追加:

    • gzipReaderは、元のレスポンスボディを保持するbody io.ReadCloserと、遅延初期化されるgzip.Readerを保持するzr io.Readerの2つのフィールドを持ちます。
    • Read メソッド: このメソッドがio.Readerインターフェースを実装します。
      • if gz.zr == nilという条件で、zrがまだ初期化されていないことを確認します。
      • 初めてReadが呼び出されたときに、gzip.NewReader(gz.body)を呼び出してgz.zrを初期化します。ここでgzip.NewReaderがエラーを返した場合(例: 不完全なgzipヘッダのため)、そのエラーが即座にReadの呼び出し元に返されます。
      • gz.zrが初期化された後は、単にgz.zr.Read(p)を呼び出して、解凍されたデータを読み込みます。
    • Close メソッド: このメソッドはio.Closerインターフェースを実装し、元のgz.body.Close()を呼び出して基になるリソースを適切に閉じます。

この変更により、gzip.NewReaderの初期化エラーが、実際にボディを読み込もうとするタイミングまで遅延されるため、不正なgzipボディであっても、リクエスト自体が即座に失敗することはなくなります。

src/pkg/net/http/transport_test.go の変更

  1. TestTransportGzipShort テスト関数の追加:
    • このテストは、golang.org/issue/7750で報告された問題を再現し、修正が正しく機能することを確認するために追加されました。
    • httptest.NewServerを使用して、カスタムのHandlerFuncを持つテストサーバーを起動します。
    • このハンドラは、Content-Encoding: gzipヘッダを設定しますが、ボディとしては非常に短い不正なgzipデータ(0x1f, 0x8b - gzipのマジックナンバーのみ)を書き込みます。
    • クライアント(c := &Client{Transport: tr})を使用してこのサーバーにGETリクエストを送信します。
    • ioutil.ReadAll(res.Body)を呼び出してレスポンスボディを読み込もうとします。
    • 重要なのは、このReadAllの呼び出しがエラーを返すことを期待している点です。
    • 具体的には、エラーがnilでないこと、そしてエラーがio.ErrUnexpectedEOFであることを検証します。これは、ボディが不完全であるために予期せぬファイルの終端に達したことを示す、期待されるエラーです。

このテストの追加により、修正が正しく機能し、不完全なgzipボディに対して適切なエラー(io.ErrUnexpectedEOF)が、ボディを読み込もうとしたタイミングで発生することが保証されます。

関連リンク

参考にした情報源リンク