[インデックス 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.Reader
とio.Closer
インターフェース: Go言語における基本的なI/Oインターフェースです。io.Reader
はRead(p []byte) (n int, err error)
メソッドを持ち、データストリームからの読み込みを抽象化します。io.Closer
はClose() 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
はエラーを返し、そのエラーがreadLoop
のerr
変数に代入され、コネクションが閉じられてリクエストが失敗していました。
変更後:
gzip.NewReader
の呼び出しを、実際にレスポンスボディからデータを読み込もうとするまで遅延させるように変更されました。これを実現するために、新しい内部型gzipReader
が導入されました。
gzipReader
は以下の特徴を持ちます。
io.Reader
とio.Closer
インターフェースを実装します。- 内部に元の
resp.Body
(body io.ReadCloser
)と、遅延初期化されるgzip.Reader
(zr io.Reader
)を保持します。 Read
メソッドが初めて呼び出されたときにのみ、gzip.NewReader(gz.body)
を呼び出してgz.zr
を初期化します。この初期化中にエラーが発生した場合、そのエラーがRead
メソッドの呼び出し元に返されます。Close
メソッドは、単に元のgz.body.Close()
を呼び出します。
この変更により、不正なgzipボディであっても、Content-Encoding: gzip
ヘッダが存在するだけで即座にリクエストが失敗することはなくなりました。エラーは、アプリケーションが実際にレスポンスボディを読み込もうとしたときに初めて発生するようになります。これにより、クライアントはより柔軟にエラーをハンドリングできるようになり、例えば、ボディを読み込む必要がない場合にはエラーを回避できるようになります。
また、この修正を検証するために、transport_test.go
にTestTransportGzipShort
という新しいテストケースが追加されました。このテストは、意図的に短い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
の変更
-
readLoop
メソッド内の変更:- 以前は、
Content-Encoding: gzip
ヘッダが設定されている場合、すぐにgzip.NewReader(resp.Body)
を呼び出し、その結果をgzReader
に格納していました。この初期化でエラー(zerr
)が発生すると、コネクションを閉じ、エラーを伝播させていました。 - 新しいコードでは、この即時初期化のロジックが削除され、代わりに
resp.Body
が新しく定義された&gzipReader{body: resp.Body}
でラップされます。これにより、gzip.NewReader
の呼び出しがgzipReader
のRead
メソッドが初めて呼び出されるまで遅延されます。
- 以前は、
-
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
の変更
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
)が、ボディを読み込もうとしたタイミングで発生することが保証されます。
関連リンク
- Go Issue 7750: https://golang.org/issue/7750
- Go Code Review 84850043: https://golang.org/cl/84850043
参考にした情報源リンク
- Go Issue Tracker: https://github.com/golang/go/issues
- Go
net/http
documentation: https://pkg.go.dev/net/http - Go
compress/gzip
documentation: https://pkg.go.dev/compress/gzip - Go
io
package documentation: https://pkg.go.dev/io - Web search for "golang.org/issue/7750 net/http short gzip body" (used to understand the background of the issue).