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

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

このコミットは、Go言語の標準ライブラリである net/http パッケージの Client 型に、リクエストの「エンドツーエンド」タイムアウトを設定するための Timeout フィールドを追加するものです。これにより、HTTPリクエストの接続確立からレスポンスボディの読み込み完了まで、全体にかかる時間を制限できるようになります。

コミット

net/http: add Client.Timeout for end-to-end timeouts

Fixes #3362

LGTM=josharian R=golang-codereviews, josharian CC=adg, dsymonds, golang-codereviews, n13m3y3r https://golang.org/cl/70120045

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

https://github.com/golang/go/commit/2ad72ecf341d553fe9b698343f9bae6d26344619

元コミット内容

commit 2ad72ecf341d553fe9b698343f9bae6d26344619
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date:   Sun Mar 2 20:39:20 2014 -0800

    net/http: add Client.Timeout for end-to-end timeouts
    
    Fixes #3362
    
    LGTM=josharian
    R=golang-codereviews, josharian
    CC=adg, dsymonds, golang-codereviews, n13m3y3r
    https://golang.org/cl/70120045
---
 src/pkg/net/http/client.go      | 91 ++++++++++++++++++++++++++++++++++-------
 src/pkg/net/http/client_test.go | 67 ++++++++++++++++++++++++++++++
 2 files changed, 144 insertions(+), 14 deletions(-)

変更の背景

この変更は、Goの net/http クライアントが、HTTPリクエストのライフサイクル全体にわたるタイムアウトを直接設定するメカニズムを欠いていたという問題(Issue #3362)に対応するために導入されました。

以前の net/http クライアントでは、接続確立のタイムアウト(Dialer の設定)や、個々の読み書き操作のタイムアウト(TransportResponseHeaderTimeout など)は設定できましたが、リクエストの開始からレスポンスボディの読み込み完了まで、すべてを含んだ「エンドツーエンド」のタイムアウトを設定する統一された方法がありませんでした。

これにより、以下のような問題が発生する可能性がありました。

  • リソースの枯渇: レスポンスボディの読み込みが途中で停止したり、非常に遅くなったりした場合、クライアントは無期限に待機し続け、接続やメモリなどのリソースを解放しない可能性がありました。これは、特に多数の同時リクエストを処理するサーバーアプリケーションにおいて、リソースリークやパフォーマンス低下の原因となります。
  • ユーザーエクスペリエンスの低下: クライアントアプリケーションが外部サービスからの応答を長時間待ち続けると、ユーザーはアプリケーションがフリーズしたように感じ、不満を抱く可能性があります。
  • 複雑なタイムアウト管理: エンドツーエンドのタイムアウトを実現するためには、開発者が手動でタイマーを管理し、リクエストのキャンセルロジックを実装する必要があり、コードが複雑になりがちでした。

Client.Timeout の導入により、これらの問題が解決され、開発者はより簡単に堅牢なHTTPクライアントを構築できるようになりました。

前提知識の解説

このコミットの理解を深めるために、以下のGo言語の net/http パッケージおよび関連する概念について解説します。

net/http パッケージ

Go言語の標準ライブラリであり、HTTPクライアントとサーバーの実装を提供します。ウェブアプリケーションやAPIクライアントを構築する上で不可欠なパッケージです。

http.Client

HTTPリクエストを送信するための高レベルなクライアント構造体です。Get, Post, Do などのメソッドを提供し、リクエストの送信、リダイレクトの処理、クッキーの管理などを行います。

http.Transport

http.Client の内部で使用されるインターフェースで、実際のHTTPリクエストの送信(TCP接続の確立、リクエストの書き込み、レスポンスの読み込みなど)を担当します。http.DefaultTransport は、ほとんどのユースケースで十分なデフォルトの実装を提供します。

http.RoundTripper インターフェース

Transport が実装するインターフェースです。単一の RoundTrip メソッドを持ち、*http.Request を受け取り、*http.Response とエラーを返します。これは、HTTPリクエストの「ラウンドトリップ」(リクエストの送信からレスポンスの受信まで)を抽象化します。

CancelRequest(*Request) メソッド

http.Transport インターフェースには直接含まれていませんが、http.DefaultTransport のような具体的な Transport 実装が提供する可能性のあるメソッドです。このメソッドは、進行中のHTTPリクエストをキャンセルするために使用されます。タイムアウト処理において、リクエストを途中で中断するために重要です。

time.Duration

Go言語で時間の長さを表す型です。time.Second, time.Millisecond などの定数を使って、人間が読みやすい形で時間を指定できます。

io.ReadCloser インターフェース

io.Readerio.Closer の両方のインターフェースを組み合わせたものです。HTTPレスポンスボディは通常、このインターフェースを実装しており、データを読み込み(Read)、読み込み完了後にリソースを解放(Close)する必要があります。

time.AfterFunc

指定された時間が経過した後に、一度だけ関数を実行するタイマーを設定します。このコミットでは、タイムアウト時間を監視し、タイムアウトが発生した際にリクエストをキャンセルするために使用されます。

sync.Mutex

Go言語の標準ライブラリ sync パッケージに含まれる相互排他ロックです。複数のゴルーチンが共有リソース(この場合は req 変数)に同時にアクセスするのを防ぎ、データ競合を回避するために使用されます。

技術的詳細

このコミットの主要な変更点は、http.ClientTimeout フィールドを追加し、そのタイムアウトロジックを doFollowingRedirects メソッド内に実装したことです。

  1. Client.Timeout フィールドの追加: http.Client 構造体に Timeout time.Duration フィールドが追加されました。このフィールドは、リクエストの接続時間、リダイレクト、およびレスポンスボディの読み込みを含む、エンドツーエンドのタイムアウトを指定します。Timeout がゼロの場合、タイムアウトは設定されません。

  2. CancelRequest の利用: Client.Timeout を有効にするためには、Client が使用する TransportCancelRequest(*Request) メソッドをサポートしている必要があります。http.DefaultTransport はこのメソッドをサポートしているため、デフォルトのクライアントを使用する場合には追加の設定は不要です。もしカスタムの Transport を使用していて CancelRequest をサポートしていない場合、タイムアウトを設定するとエラーが返されます。

  3. タイムアウトの監視とリクエストのキャンセル: Client.Do メソッドから呼び出される内部メソッド doFollowingRedirects 内でタイムアウトロジックが実装されています。

    • c.Timeout > 0 の場合、time.AfterFunc を使用してタイマーが設定されます。このタイマーは、指定された c.Timeout 時間が経過すると、匿名関数を実行します。
    • 匿名関数内では、reqmu という sync.Mutex を使用して req 変数へのアクセスを保護し、TransportCancelRequest(req) メソッドを呼び出して、現在進行中のリクエストをキャンセルします。
    • reqmu は、リダイレクト処理中に req 変数が新しいリクエストオブジェクトに置き換えられる可能性があるため、CancelRequest が常に最新のリクエストオブジェクトをキャンセルするようにするために導入されました。
  4. レスポンスボディの読み込み中のタイムアウト処理: タイムアウトは、Get, Head, Post, Do メソッドがレスポンスを返した後も実行され続けます。これは、レスポンスヘッダーが受信された後でも、レスポンスボディの読み込みが遅延した場合にタイムアウトを適用するためです。

    • レスポンスが返される際、timer が設定されている場合、元の resp.BodycancelTimerBody というカスタムの io.ReadCloser でラップされます。
    • cancelTimerBodyRead メソッドは、内部の rc.Read を呼び出し、io.EOF が返された(つまり、ボディの読み込みが完了した)場合に timer.Stop() を呼び出してタイマーを停止します。
    • cancelTimerBodyClose メソッドも、内部の rc.Close() を呼び出した後、timer.Stop() を呼び出してタイマーを停止します。これにより、ボディの読み込みが完了する前に Close が呼び出された場合でも、タイマーが適切に停止されます。
  5. リダイレクト時のリクエストオブジェクトの更新: リダイレクトが発生した場合、新しいリクエストオブジェクトが作成されます。この際、reqmu を使用して req 変数を保護し、新しいリクエストオブジェクトを安全に req に割り当てます。これにより、タイムアウトタイマーがキャンセルを試みる際に、常に正しい(最新の)リクエストオブジェクトを参照できるようになります。

この実装により、Client.Timeout は、ネットワーク接続の確立、リクエストの送信、レスポンスヘッダーの受信、そしてレスポンスボディの読み込みという、HTTPリクエストの全フェーズをカバーする包括的なタイムアウトメカニズムを提供します。

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

src/pkg/net/http/client.go

// A Client is an HTTP client. Its zero value (DefaultClient) is a
// ...
type Client struct {
	// ...
	Jar CookieJar

	// Timeout specifies the end-to-end timeout for requests made
	// via this Client. The timeout includes connection time, any
	// redirects, and reading the response body. The timeout
	// remains running once Get, Head, Post, or Do returns and
	// will interrupt the read of the Response.Body if EOF hasn't
	// been reached.
	//
	// A Timeout of zero means no timeout.
	//
	// The Client's Transport must support the CancelRequest
	// method or Client will return errors when attempting to make
	// a request with Get, Head, Post, or Do. Client's default
	// Transport (DefaultTransport) supports CancelRequest.
	Timeout time.Duration
}

// ...

func (c *Client) doFollowingRedirects(ireq *Request, shouldRedirect func(int) bool) (resp *Response, err error) {
	if ireq.URL == nil {
		return nil, errors.New("http: nil Request.URL")
	}

	var reqmu sync.Mutex // guards req
	req := ireq

	var timer *time.Timer
	if c.Timeout > 0 {
		type canceler interface {
			CancelRequest(*Request)
		}
		tr, ok := c.transport().(canceler)
		if !ok {
			return nil, fmt.Errorf("net/http: Client Transport of type %T doesn't support CancelRequest; Timeout not supported", c.transport())
		}
		timer = time.AfterFunc(c.Timeout, func() {
			reqmu.Lock()
			defer reqmu.Unlock()
			tr.CancelRequest(req)
		})
	}

	urlStr := "" // next relative or absolute URL to fetch (after first request)
	redirectFailed := false
	for redirect := 0; ; redirect++ {
		if redirect != 0 {
			nreq := new(Request)
			nreq.Method = ireq.Method
			if ireq.Method == "POST" || ireq.Method == "PUT" {
				nreq.Method = "GET"
			}
			nreq.Header = make(Header)
			nreq.URL, err = base.Parse(urlStr)
			if err != nil {
				break
			}
			if len(via) > 0 {
				// Add the Referer header.
				lastReq := via[len(via)-1]
				if lastReq.URL.Scheme != "https" {
					nreq.Header.Set("Referer", lastReq.URL.String())
				}

				err = redirectChecker(nreq, via)
				if err != nil {
					redirectFailed = true
					break
				}
			}
			reqmu.Lock()
			req = nreq
			reqmu.Unlock()
		}

		// ... (既存のコード) ...

		if timer != nil {
			resp.Body = &cancelTimerBody{timer, resp.Body}
		}
		return resp, nil
	}

	// ... (既存のコード) ...
}

type cancelTimerBody struct {
	t  *time.Timer
	rc io.ReadCloser
}

func (b *cancelTimerBody) Read(p []byte) (n int, err error) {
	n, err = b.rc.Read(p)
	if err == io.EOF {
		b.t.Stop()
	}
	return
}

func (b *cancelTimerBody) Close() error {
	err := b.rc.Close()
	b.t.Stop()
	return err
}

src/pkg/net/http/client_test.go

func TestClientTimeout(t *testing.T) {
	if testing.Short() {
		t.Skip("skipping in short mode")
	}
	defer afterTest(t)
	sawRoot := make(chan bool, 1)
	sawSlow := make(chan bool, 1)
	ts := httptest.NewServer(HandlerFunc(func(w ResponseWriter, r *Request) {
		if r.URL.Path == "/" {
			sawRoot <- true
			Redirect(w, r, "/slow", StatusFound)
			return
		}
		if r.URL.Path == "/slow" {
			w.Write([]byte("Hello"))
			w.(Flusher).Flush()
			sawSlow <- true
			time.Sleep(2 * time.Second)
			return
		}
	}))
	defer ts.Close()
	const timeout = 500 * time.Millisecond
	c := &Client{
		Timeout: timeout,
	}

	res, err := c.Get(ts.URL)
	if err != nil {
		t.Fatal(err)
	}

	select {
	case <-sawRoot:
		// good.
	default:
		t.Fatal("handler never got / request")
	}

	select {
	case <-sawSlow:
		// good.
	default:
		t.Fatal("handler never got /slow request")
	}

	var all []byte
	errc := make(chan error, 1)
	go func() {
		var err error
		all, err = ioutil.ReadAll(res.Body)
		errc <- err
		res.Body.Close()
	}()

	const failTime = timeout * 2
	select {
	case err := <-errc:
		if err == nil {
			t.Error("expected error from ReadAll")
		}
		t.Logf("Got expected ReadAll error of %v after reading body %q", err, all)
	case <-time.After(failTime):
		t.Errorf("timeout after %v waiting for timeout of %v", failTime, timeout)
	}
}

コアとなるコードの解説

Client 構造体への Timeout フィールド追加

type Client struct {
	// ...
	Timeout time.Duration
}

Client 構造体に Timeout フィールドが追加されました。これは time.Duration 型で、リクエスト全体のエンドツーエンドタイムアウトを指定します。このフィールドのコメントには、タイムアウトが接続時間、リダイレクト、レスポンスボディの読み込みを含むこと、そして TransportCancelRequest をサポートする必要があることが明記されています。

doFollowingRedirects メソッド内のタイムアウトロジック

doFollowingRedirects は、リダイレクトを処理しながらHTTPリクエストを送信する内部メソッドです。このメソッド内にタイムアウトの主要なロジックが組み込まれています。

var reqmu sync.Mutex // guards req
req := ireq

var timer *time.Timer
if c.Timeout > 0 {
    type canceler interface {
        CancelRequest(*Request)
    }
    tr, ok := c.transport().(canceler)
    if !ok {
        return nil, fmt.Errorf("net/http: Client Transport of type %T doesn't support CancelRequest; Timeout not supported", c.transport())
    }
    timer = time.AfterFunc(c.Timeout, func() {
        reqmu.Lock()
        defer reqmu.Unlock()
        tr.CancelRequest(req)
    })
}
  • reqmu sync.Mutex: req 変数(現在処理中のリクエスト)を保護するためのミューテックスです。リダイレクト中に req が更新される可能性があるため、タイムアウト処理が正しいリクエストをキャンセルできるようにするために使用されます。
  • if c.Timeout > 0: Timeout が設定されている場合にのみ、タイムアウトロジックが実行されます。
  • type canceler interface { CancelRequest(*Request) }: CancelRequest メソッドを持つインターフェースを定義しています。これは、Transport がこの機能を提供するかどうかをチェックするために使用されます。
  • tr, ok := c.transport().(canceler): 現在の ClientTransportcanceler インターフェースを実装しているかを確認します。実装していない場合はエラーを返します。
  • timer = time.AfterFunc(c.Timeout, func() { ... }): c.Timeout で指定された時間が経過した後に実行されるタイマーを設定します。タイマーが発火すると、reqmu をロックし、tr.CancelRequest(req) を呼び出して現在のアクティブなリクエストをキャンセルします。

リダイレクト時の req の更新とミューテックスの使用

if redirect != 0 {
    nreq := new(Request)
    // ... (新しいリクエスト nreq の設定) ...
    reqmu.Lock()
    req = nreq
    reqmu.Unlock()
}

リダイレクトが発生し、新しいリクエストオブジェクト nreq が作成される際、reqmu.Lock()reqmu.Unlock() を使用して req 変数を保護しながら nreqreq に割り当てています。これにより、タイムアウトタイマーが CancelRequest を呼び出す際に、常に最新のリクエストオブジェクトが参照されることが保証されます。

cancelTimerBody によるレスポンスボディ読み込み中のタイムアウト処理

if timer != nil {
    resp.Body = &cancelTimerBody{timer, resp.Body}
}
return resp, nil

レスポンスが返される直前に、timer が設定されている場合、元の resp.Body はカスタムの cancelTimerBody でラップされます。

type cancelTimerBody struct {
	t  *time.Timer
	rc io.ReadCloser
}

func (b *cancelTimerBody) Read(p []byte) (n int, err error) {
	n, err = b.rc.Read(p)
	if err == io.EOF {
		b.t.Stop()
	}
	return
}

func (b *cancelTimerBody) Close() error {
	err := b.rc.Close()
	b.t.Stop()
	return err
}
  • cancelTimerBody は、元の time.Timer (t) と io.ReadCloser (rc) を保持します。
  • Read メソッドは、内部の rc.Read を呼び出します。もし io.EOF が返された場合(つまり、レスポンスボディの読み込みが完了した場合)、b.t.Stop() を呼び出してタイマーを停止します。これにより、ボディの読み込みが正常に完了した後に不要なタイムアウトイベントが発生するのを防ぎます。
  • Close メソッドは、内部の rc.Close() を呼び出した後、b.t.Stop() を呼び出してタイマーを停止します。これは、ボディの読み込みが完了する前に Close が呼び出された場合(例えば、エラーが発生した場合や、クライアントがボディの読み込みを途中でやめた場合)に、タイマーを確実に停止させるために重要です。

テストケース TestClientTimeout

client_test.go に追加された TestClientTimeout は、この新機能の動作を検証します。

  • httptest.NewServer を使用して、リダイレクトと遅延応答をシミュレートするテストサーバーをセットアップします。
  • / へのリクエストは /slow にリダイレクトされ、/slow は一部のデータを書き込んだ後、2秒間スリープします。
  • ClientTimeout500 * time.Millisecond に設定し、Get リクエストを送信します。
  • テストは、リダイレクトが正しく処理され、ReadAll がタイムアウトエラーを返すことを検証します。これは、レスポンスボディの読み込み中にタイムアウトが発生し、リクエストが中断されたことを示します。

これらの変更により、net/http.Client は、より堅牢で制御可能なタイムアウトメカニズムを獲得し、様々なネットワーク条件下での信頼性を向上させました。

関連リンク

参考にした情報源リンク