はじめに
net/httpを使ったhttpリクエストでは2つの方法でtimeout管理ができます。
Client.Timeout
値の設定( コメントを読む感じrequestにcontextを入れてもOKだぞいというのは言及されてるRequest.WithContext
なりNewRequestWithContext
なりでcontextを食わせる
前者はtimeoutに特化した制御で、後者はより広範囲な制御です。
(contextの場合は WithTimeout
だけでなく WithCancel
などで実装時に任意のタイミングでcancelされうる)
で、それぞれどのように制御されるのか気になって追ったメモ。
要約
- Client.Timeoutの制御はRoundTripperの実装が標準、準標準の実装かそうでないかで挙動がことなる
- 標準、準標準だったらWithDeadlineしてreq.ctxにつめるだけ。ハンドルはRoundTripperに任せる。
- そうでなかったら、ctx.DoneをRoundTripperがハンドルする保証がないのでWithDeadlineしてreq.ctxに詰めつつ、CancelRequest(下位互換用の処理)を実装してたらそれも呼ぶ。どっちもやっとけばなんとかなるだろ感
- contextのほうはnet/httpのRoundTripper実装であるhttp.Transportのなかで終了シグナルがハンドルされる
- つまりRoundTripperがハンドルを実装していないとcontext.Doneは検知されない可能性がある
- 自作RoundTripperやサードパーティ実装を使うときは要チェック!
追いかけ
- clientがrequestを送るときは、最終的には引数に time.Now().Add(c.Timeout) もしくはゼロ値 の値をいれて
send()
が呼び出される。 - リクエストを実際に送る直前に timeoutの計測を停止するfuncとtimeout済みか判断するfuncを受け取る
- timeoutが設定されてないとnopのstopTimerと常に期限切れじゃないよと返すくんが戻されるだけ
- timeoutが設定されててknownTransportでreq.ctxが切れてなければ ctx.WithDeadlineでreqのctxに期限を設定してるっぽい
- knownTransport扱いされるのはコメントにある通り標準、準標準実装のみっぽい
- この文脈ではctx.DoneをRoundTripper内でハンドルしてくれるかどうかの判断に使ってそう
- knownTransport扱いされるのはコメントにある通り標準、準標準実装のみっぽい
- ↑ではない場合はrequest.Cancelに値を詰めつつ もともとのreqがCancelされたとき、またはdeadlineの時刻が経過したときにcancelしたりする。 timeout扱いになるのはdeadlineの時間が経過したときのみ。それはそう
- request.Cancel自体は もうdeprecatedだからNewRequestWithContextつかってctxでcancel管理しろよな っていってる。下位互換のためCancelもみているのかー
- ここでRoudTripperをよびだして実際にリクエストする。 デフォルトの実装はhttp.Transportで、特に差し替えてない限りはその実装が使われる
- 標準のnet/http.Transportの実装だとここでreqからctxを取り出して、ここでctx.Doneをハンドル してる
検証
ソースを読んだ感じだと以下のことが言えそう
- 独自RoundTripper1がctx.Doneをハンドルしないとcontextがcancelされてもrequestはtimeoutしない
- client.Timeoutを設定した状態で独自RoundTripper1を使うとRoundTripper.CancelRequestが実装されていれば呼び出されるはず(下位互換のためのcancel機構)
- 独自RoundTripper2がctx.Doneをハンドルすればcontextがcancelされたらrequestは中断するはず
- client.Timeoutを設定した状態で独自RoundTripper2を使うと、req.ctxにtimeout値がWithDeadlineで詰められて、設定時刻が経過したら中断されるはず
それぞれのケースを試してみた。ソースは以下
package main import ( "context" "fmt" "log" "net/http" "time" ) // contextをハンドルしない自作RT // http.Client.Timeout を設定するとCancelRequestが呼ばれてcancelされるはず type rt struct{ cancel chan struct{} } func (rt rt) RoundTrip(r *http.Request) (*http.Response, error) { if r.Body != nil { defer r.Body.Close() } ticker := time.Tick(5 * time.Second) select { case <-rt.cancel: return nil, fmt.Errorf("timeout error") case <-ticker: return &http.Response{}, nil } } func (rt rt) CancelRequest(_ *http.Request) { rt.cancel <- struct{}{} } // contextをハンドルする自作RT type ctxHandleRT struct {} func (ctxHandleRT) RoundTrip(r *http.Request) (*http.Response, error) { if r.Body != nil { defer r.Body.Close() } ctx := r.Context() ticker := time.Tick(5 * time.Second) select { case <-ctx.Done(): return nil, ctx.Err() case <-ticker: return &http.Response{}, nil } } func main() { // ctx.Doneをハンドルしない自作RTをもったClientでリクエストすると、contextのcancelは無視されて5秒待たされるはず ctx1, cancel1 := context.WithTimeout(context.Background(), 3*time.Second) defer cancel1() r1, err := http.NewRequestWithContext(ctx1, http.MethodGet, "https://golang.org", nil) if err != nil { log.Fatal(err) } c1 := http.Client{ Transport: rt{cancel: make(chan struct{})}, } log.Println("request1 start") if _, err := c1.Do(r1); err != nil { log.Printf("request failed: %v", err) } else { log.Println("request succeeded") } // client.Timeoutを設定するとCancelRequestが呼び出されてcancelされるはず c1.Timeout = time.Second * 3 log.Println("request2 start") if _, err := c1.Do(r1); err != nil { log.Printf("request failed: %v", err) } else { log.Println("request succeeded") } // ctx.Doneをハンドルする自作RTを使ったClientでリクエストすると、contextのcancel時間でリクエストが中断されるはず ctx2, cancel2 := context.WithTimeout(context.Background(), 3*time.Second) defer cancel2() r2, err := http.NewRequestWithContext(ctx2, http.MethodGet, "https://golang.org", nil) if err != nil { log.Fatal(err) } ctxHandleClient := http.Client{ Transport: ctxHandleRT{}, } log.Println("request3 start") if _, err := ctxHandleClient.Do(r2); err != nil { log.Printf("request failed: %v", err) } else { log.Println("request succeeded") } // ctx.Doneをハンドルする自作RTを使ったClient.Timeoutを設定してリクエストすると、 // 内部でclient.TimeoutでWithDeadlineしたctxがreqに詰め直されるので, RTがCancelRequestを実装していなくても // Client.Timeoutの時間で処理が中断されるはず r3, err := http.NewRequest(http.MethodGet, "https://golang.org", nil) if err != nil { log.Fatal(err) } ctxHandleClient.Timeout = 3 * time.Second log.Println("request4 start") if _, err := ctxHandleClient.Do(r3); err != nil { log.Printf("request failed: %v", err) } else { log.Println("request succeeded") } }
実行すると以下
$ go run main.go 2021/03/07 23:58:20 request1 start 2021/03/07 23:58:25 request succeeded 2021/03/07 23:58:25 request2 start 2021/03/07 23:58:28 request failed: Get "https://golang.org": timeout error (Client.Timeout exceeded while awaiting headers) 2021/03/07 23:58:28 request3 start 2021/03/07 23:58:31 request failed: Get "https://golang.org": context deadline exceeded 2021/03/07 23:58:31 request4 start 2021/03/07 23:58:34 request failed: Get "https://golang.org": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
- request1はctx.DoneをハンドルしないRTを使っているので、contextのcancelを無視してtimeoutせずに5秒たったあと成功logがでている
- request2はclient.Timeoutが設定されているので、設定された3秒でCancelRequestが呼び出されてtimeoutしている
- request3はctx.DoneをハンドルするRTを使っているので、context.WithTimeoutで設定された3秒で処理が中断されている
- request4はctx.DoneをハンドルするRTを使っていてclient.Timeoutが3秒で設定されているので、内部でctx.WithDeadlineされてctxのcancelとしてRTでハンドリングされ3秒で処理が中断されている
OK!読み取った内容に間違いはなさそう
感想
- contextはかなりよい抽象概念だし、contextを使ったリソース開放はcool
- Goの標準パッケージ、github上でもみやすい
- client.goとか、同じものの記述が同じファイルにまとまってるので、コードジャンプしなくても単語でfindすればすぐ見つかる
- コードジャンプするまでもなく便利
- 差し替え可能なRoundTripper内でctx.Doneをハンドルしてるのは知れてよかった
- 知らずに独自RoundTripperを使うとあれ?reqに詰めたctxのcancelFunc読んでるのにcancelされないのじゃが?となる可能性があったので
- 標準、準標準の実装以外ではctx.Doneがハンドルされる保証がないので、使うときは必ず確認すること
- 故あって自作するときはctx.Doneをハンドルするようにつくること
- 下位互換のための記述がわりとあった
- とくにcontextはGo1.7?(うろ覚え)くらいから追加された記憶があるので色々なパッケージに同様の記述がありそうだなと思った
- 1系はずっと下位互換保証するぜ!というのはすごくありがたいけど、やはりいろいろな努力を要するんだな