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

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

このコミットは、Go言語の標準ライブラリ net/http パッケージ内の Transport において、HTTP/1.1 の 100 Continue レスポンスが予期せず送信された場合に、それを適切に無視し、後続の処理が正常に行われるようにするための修正を導入しています。具体的には、Transport がレスポンスを読み取る際に、ステータスコードが 100 であればそれをスキップし、次のレスポンスを読み直すロジックが追加されました。これにより、一部のサーバーが不必要に 100 Continue を送信するケースに対応し、クライアント側の Transport が「オフバイワンエラー」に陥ることを防ぎます。

コミット

commit a79df7bb20c7c19bebfd35674bf686129d7f079f
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date:   Fri Mar 29 20:25:11 2013 -0700

    net/http: ignore 100-continue responses in Transport
    
    "There are only two hard problems in computer science:
    cache invalidation, naming things, and off-by-one errors."
    
    The HTTP server code already strips Expect: 100-continue on
    requests, so httputil.ReverseProxy should be unaffected, but
    some servers send unsolicited HTTP/1.1 100 Continue responses,
    so we need to skip over them if they're seen to avoid getting
    off-by-one on Transport requests/responses.
    
    This does change the behavior of people who were using Client
    or Transport directly and explicitly setting "Expect: 100-continue"
    themselves, but it didn't work before anyway. Now instead of the
    user code seeing a 100 response and then things blowing up, now
    it basically works, except the Transport will still blast away
    the full request body immediately.  That's the part that needs
    to be finished to close this issue.
    
    This is the safe quick fix.
    
    Update #3665
    
    R=golang-dev, dsymonds, dave, jgrahamc
    CC=golang-dev
    https://golang.org/cl/8166045

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

https://github.com/golang/go/commit/a79df7bb20c7c19bebfd35674bf686129d7f079f

元コミット内容

このコミットの目的は、net/http パッケージの Transport が、予期せぬ HTTP/1.1 100 Continue レスポンスを適切に処理できるようにすることです。コミットメッセージは、コンピュータサイエンスにおける「2つの難しい問題」という有名なジョークを引用し、off-by-one errors (オフバイワンエラー) がその一つであることを示唆しています。

GoのHTTPサーバーは既に Expect: 100-continue ヘッダーをリクエストから取り除くため、httputil.ReverseProxy は影響を受けません。しかし、一部のサーバーはクライアントが Expect: 100-continue ヘッダーを送信していないにもかかわらず、自発的に 100 Continue レスポンスを送信することがあります。このような場合、Transport はこの予期せぬ 100 Continue レスポンスを通常のレスポンスとして誤って解釈し、後続の実際のレスポンスとの間で「オフバイワンエラー」を引き起こす可能性がありました。

この修正は、Client または Transport を直接使用し、明示的に Expect: 100-continue を設定していたユーザーの動作を変更する可能性があります。しかし、コミットメッセージによれば、そのようなケースでも以前は正しく機能していなかったため、実質的な問題は少ないとされています。修正後は、ユーザーコードが 100 レスポンスを受け取って処理が中断する代わりに、基本的には機能するようになります。ただし、Transport は引き続きリクエストボディ全体を即座に送信してしまうため、この点は将来的な改善点として残されています(Issue 2184)。

このコミットは「安全な応急処置 (safe quick fix)」と位置づけられており、Issue #3665 の更新に関連しています。

変更の背景

HTTP/1.1 の 100 Continue メカニズムは、クライアントが大きなリクエストボディを送信する前に、サーバーがそのリクエストを受け入れる準備ができているかを確認するために使用されます。クライアントは Expect: 100-continue ヘッダーをリクエストに含め、サーバーはリクエストボディの受信を開始する前に 100 Continue レスポンスを返します。これにより、サーバーがリクエストを拒否する場合に、クライアントが無駄に大きなボディを送信するのを防ぐことができます。

しかし、このメカニズムは常に厳密に実装されるわけではありません。一部のHTTPサーバーやプロキシは、クライアントが Expect: 100-continue ヘッダーを送信していないにもかかわらず、誤って 100 Continue レスポンスを送信することがあります。Goの net/http パッケージの Transport は、このような「自発的な (unsolicited)」100 Continue レスポンスを予期していませんでした。

Transport は、リクエストを送信した後、サーバーからのレスポンスを読み取ります。もし予期せぬ 100 Continue レスポンスが最初に到着した場合、Transport はそれを「実際の」レスポンスとして誤って処理し、その後に続く本来の 200 OK などのレスポンスを、前のリクエストに対するものとして認識できなくなってしまいます。これが「オフバイワンエラー」であり、HTTP通信の同期が崩れる原因となります。

この問題は、特にプロキシ環境や、HTTP/1.1 の仕様に厳密に従わないサーバーとの通信において顕在化しました。このコミットは、このような状況下でも net/http クライアントが堅牢に動作するようにするための修正です。

前提知識の解説

HTTP/1.1 100 Continue

HTTP/1.1 の 100 Continue ステータスコードは、クライアントが大きなリクエストボディを送信する前に、サーバーがそのリクエストのヘッダー部分を受け入れ、ボディの送信を続行してもよいことを示すために使用されます。

通常のフロー:

  1. クライアントは Expect: 100-continue ヘッダーを含むリクエストヘッダーを送信します。
  2. サーバーはリクエストヘッダーを解析し、ボディの受信準備ができていれば HTTP/1.1 100 Continue レスポンスを返します。
  3. クライアントは 100 Continue を受け取った後、リクエストボディの送信を開始します。
  4. サーバーはリクエストボディを受信し、最終的なレスポンス(例: 200 OK)を返します。

もしサーバーがリクエストヘッダーを受け入れられない場合(例: 認証エラー、不正なヘッダー)、4xx5xx のエラーレスポンスを直接返し、クライアントはボディを送信せずに済みます。

Go net/http パッケージ

Go言語の net/http パッケージは、HTTPクライアントとサーバーを実装するための標準ライブラリです。

  • http.Client: HTTPリクエストを送信するための高レベルなインターフェースを提供します。通常、ユーザーはこの構造体を通じてHTTP通信を行います。
  • http.Transport: http.Client の内部で使用される低レベルなコンポーネントで、実際のネットワーク接続の確立、リクエストの送信、レスポンスの受信、接続の再利用(Keep-Alive)などを担当します。Transport は、複数のリクエストに対して単一のTCP接続を再利用することで、パフォーマンスを向上させます。
  • ReadResponse: net/http パッケージ内の関数で、bufio.Reader からHTTPレスポンスを読み取り、*http.Response 構造体にパースします。

オフバイワンエラー (Off-by-one error)

プログラミングにおけるオフバイワンエラーは、ループの境界条件や配列のインデックスなどで、期待される回数よりも1回多く(または少なく)処理が行われたり、期待される範囲よりも1つ多く(または少なく)要素が参照されたりするバグです。このコミットの文脈では、Transport が予期せぬ 100 Continue レスポンスを「余分な」レスポンスとして読み取ってしまい、その後の本来のレスポンスの読み取りがずれてしまうことを指します。

技術的詳細

この修正は、net/http/transport.go ファイル内の (pc *persistConn) readLoop() メソッドに焦点を当てています。readLoop は、永続的な接続 (persistConn) を介してサーバーからのレスポンスを継続的に読み取る役割を担っています。

変更前は、readLoopReadResponse を呼び出してレスポンスを読み取り、エラーがなければそのレスポンスを処理していました。しかし、サーバーが 100 Continue を自発的に送信した場合、ReadResponse はこの 100 Continue を有効なレスポンスとしてパースしてしまいます。readLoop はこの 100 Continue を処理した後、次のレスポンスを待機しますが、サーバーは既に 100 Continue を送信済みであるため、次に送信されるのは本来の最終レスポンスです。これにより、Transport の内部状態と、サーバーが送信するレスポンスのシーケンスとの間に不整合が生じ、結果として「オフバイワンエラー」が発生していました。

このコミットでは、ReadResponse が返したレスポンスの StatusCode100 である場合、そのレスポンスを破棄し、再度 ReadResponse を呼び出して次のレスポンスを読み取るように変更されています。これにより、予期せぬ 100 Continue レスポンスが透過的にスキップされ、Transport は本来の最終レスポンスを正しく受け取ることができるようになります。

コミットメッセージでは、この修正が「安全な応急処置」であると述べられています。これは、Expect: 100-continue ヘッダーを明示的に設定したリクエストに対して、Transport がリクエストボディの送信を一時停止し、100 Continue を待ってから送信を再開するという、より洗練された 100 Continue のハンドリングがまだ実装されていないためです(Issue 2184)。現在の修正では、100 Continue が返されても、Transport はリクエストボディを即座に「ブラストアウェイ(一気に送信)」してしまいます。しかし、予期せぬ 100 Continue によるオフバイワンエラーを防ぐという点では、この修正は効果的です。

テストケース TestTransportReading100Continue は、この修正の動作を検証するために追加されました。このテストは、カスタムの Dial 関数を持つ Transport を設定し、サーバー側が意図的に 100 Continue とそれに続く 200 OK レスポンスを送信するシナリオをシミュレートします。クライアントが複数のリクエストを送信し、それぞれのレスポンスが正しく 200 OK として受信されることを確認することで、100 Continue が適切に無視されていることを検証しています。

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

src/pkg/net/http/transport.go

--- a/src/pkg/net/http/transport.go
+++ b/src/pkg/net/http/transport.go
@@ -686,6 +686,14 @@ func (pc *persistConn) readLoop() {
 		var resp *Response
 		if err == nil {
 			resp, err = ReadResponse(pc.br, rc.req)
+			if err == nil && resp.StatusCode == 100 {
+				// Skip any 100-continue for now.
+				// TODO(bradfitz): if rc.req had "Expect: 100-continue",
+				// actually block the request body write and signal the
+				// writeLoop now to begin sending it. (Issue 2184) For now we
+				// eat it, since we're never expecting one.
+				resp, err = ReadResponse(pc.br, rc.req)
+			}
 		}
 		hasBody := resp != nil && rc.req.Method != "HEAD" && resp.ContentLength != 0

src/pkg/net/http/transport_test.go

--- a/src/pkg/net/http/transport_test.go
+++ b/src/pkg/net/http/transport_test.go
@@ -7,6 +7,7 @@
 package http_test
 
 import (
+	"bufio"
 	"bytes"
 	"compress/gzip"
 	"crypto/rand"
@@ -1399,6 +1400,99 @@ func TestTransportSocketLateBinding(t *testing.T) {
 	dialGate <- true
 }
 
+// Issue 2184
+func TestTransportReading100Continue(t *testing.T) {
+	defer afterTest(t)
+
+	var writers struct {
+		sync.Mutex
+		list []*io.PipeWriter
+	}
+	registerPipe := func(pw *io.PipeWriter) {
+		writers.Lock()
+		defer writers.Unlock()
+		writers.list = append(writers.list, pw)
+	}
+	defer func() {
+		writers.Lock()
+		defer writers.Unlock()
+		for _, pw := range writers.list {
+			pw.Close()
+		}
+	}()
+
+	const numReqs = 5
+	reqBody := func(n int) string { return fmt.Sprintf("request body %d", n) }
+	reqID := func(n int) string { return fmt.Sprintf("REQ-ID-%d", n) }
+
+	send100Response := func(w *io.PipeWriter, r *io.PipeReader) {
+		defer w.Close()
+		defer r.Close()
+		br := bufio.NewReader(r)
+		n := 0
+		for {
+			n++
+			req, err := ReadRequest(br)
+			if err != nil {
+				t.Error(err)
+				return
+			}
+			slurp, err := ioutil.ReadAll(req.Body)
+			if err != nil || string(slurp) != reqBody(n) {
+				t.Errorf("Server got %q, %v; want 'body'", slurp, err)
+				return
+			}
+			id := req.Header.Get("Request-Id")
+			body := fmt.Sprintf("Response number %d", n)
+			v := []byte(strings.Replace(fmt.Sprintf(`HTTP/1.1 100 Continue
+Date: Thu, 28 Feb 2013 17:55:41 GMT
+
+HTTP/1.1 200 OK
+Content-Type: text/html
+Echo-Request-Id: %s
+Content-Length: %d
+
+%s`, id, len(body), body), "\n", "\r\n", -1))
+			w.Write(v)
+			if id == reqID(numReqs) {
+				return
+			}
+		}
+
+	}
+
+	tr := &Transport{
+		Dial: func(n, addr string) (net.Conn, error) {
+			pr, pw := io.Pipe()
+			registerPipe(pw)
+			conn := &rwTestConn{
+				Reader: pr,
+				Writer: pw,
+			}
+			go send100Response(pw, pr)
+			return conn, nil
+		},
+		DisableKeepAlives: false,
+	}
+	defer tr.CloseIdleConnections()
+	c := &Client{Transport: tr}
+	for i := 1; i <= numReqs; i++ {
+		req, _ := NewRequest("POST", "http://dummy.tld/", strings.NewReader(reqBody(i)))
+		req.Header.Set("Request-Id", reqID(i))
+		res, err := c.Do(req)
+		if err != nil {
+			t.Fatalf("Do (i=%d): %v", i, err)
+		}
+		if res.StatusCode != 200 {
+			t.Fatalf("Response Statuscode=%d; want 200 (i=%d): %v", res.StatusCode, i, err)
+		}
+		_, err = ioutil.ReadAll(res.Body)
+		if err != nil {
+			t.Fatalf("Slurp error (i=%d): %v", i, err)
+		}
+	}
+}
+
 type proxyFromEnvTest struct {
 	req     string // URL to fetch; blank means "http://example.com"
 	env     string

コアとなるコードの解説

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

readLoop 関数は、persistConn (永続的な接続) からのレスポンスを読み取る主要なループです。

		var resp *Response
		if err == nil {
			resp, err = ReadResponse(pc.br, rc.req)
			if err == nil && resp.StatusCode == 100 {
				// Skip any 100-continue for now.
				// TODO(bradfitz): if rc.req had "Expect: 100-continue",
				// actually block the request body write and signal the
				// writeLoop now to begin sending it. (Issue 2184) For now we
				// eat it, since we're never expecting one.
				resp, err = ReadResponse(pc.br, rc.req)
			}
		}

このコードブロックは、ReadResponse が正常にレスポンスを読み取り、かつそのレスポンスの StatusCode100 である場合に実行されます。

  • resp, err = ReadResponse(pc.br, rc.req): 最初にレスポンスを読み取ります。
  • if err == nil && resp.StatusCode == 100: 読み取りが成功し、かつステータスコードが 100 (Continue) であるかをチェックします。
  • // Skip any 100-continue for now.: コメントは、これが 100 Continue をスキップするためのものであることを明確に示しています。
  • // TODO(bradfitz): ... (Issue 2184): これは、将来的な改善点として、Expect: 100-continue ヘッダーがリクエストに含まれていた場合に、リクエストボディの送信を一時停止し、100 Continue を待ってから送信を再開するという、より完全な 100 Continue のハンドリングが必要であることを示しています。現在の修正は、あくまで「予期せぬ 100 Continue」を処理するためのものです。
  • resp, err = ReadResponse(pc.br, rc.req): 重要なのはこの行です。100 Continue レスポンスを読み取った後、それを破棄し、再度 ReadResponse を呼び出して次のレスポンスを読み取ろうとします。これにより、100 Continue があたかも存在しなかったかのように扱われ、Transport は本来の最終レスポンスを正しく処理できるようになります。

src/pkg/net/http/transport_test.go の追加

TestTransportReading100Continue テスト関数は、この修正が正しく機能することを確認するために追加されました。

  • send100Response 関数: この関数は、テスト用の「サーバー」として機能します。クライアントからのリクエストを受信した後、意図的に HTTP/1.1 100 Continue レスポンスと、それに続く HTTP/1.1 200 OK レスポンスを送信します。これにより、Transport が予期せぬ 100 Continue を受け取るシナリオをシミュレートします。
    			v := []byte(strings.Replace(fmt.Sprintf(`HTTP/1.1 100 Continue
    Date: Thu, 28 Feb 2013 17:55:41 GMT
    
    HTTP/1.1 200 OK
    Content-Type: text/html
    Echo-Request-Id: %s
    Content-Length: %d
    
    %s`, id, len(body), body), "\n", "\r\n", -1))
    			w.Write(v)
    
    この部分で、100 Continue200 OK の両方が単一のバイトストリームとして書き込まれ、クライアントに送信されます。
  • カスタム Transport.Dial: テストでは、http.TransportDial フィールドをカスタム関数に設定しています。このカスタム Dial 関数は、実際のネットワーク接続の代わりに io.Pipe を使用して、クライアントと send100Response 関数(サーバー側)の間でデータをやり取りします。これにより、ネットワークを介さずに、テスト内でサーバーの動作を完全に制御できます。
  • クライアントリクエストと検証: テストは http.Client を使用して複数のPOSTリクエストを送信し、それぞれのレスポンスのステータスコードが 200 OK であること、そしてエラーが発生しないことを検証します。これにより、Transport100 Continue を透過的に処理し、最終的な 200 OK レスポンスを正しく返すことを確認しています。

このテストは、100 Continue レスポンスが Transport によって正しく「消費」され、実際のレスポンスの読み取りに影響を与えないことを保証するものです。

関連リンク

  • Go Issue 2184: net/http: support Expect: 100-continue - このコミットで言及されている、より完全な 100 Continue サポートに関するIssue。
  • Go Issue 3665: net/http: Transport gets off-by-one with unsolicited 100-continue - このコミットが解決しようとしている、自発的な 100 Continue によるオフバイワンエラーに関するIssue。
  • Go CL 8166045: net/http: ignore 100-continue responses in Transport - このコミットに対応するGoのコードレビューシステム(Gerrit)のチェンジリスト。

参考にした情報源リンク