ちりもつもればミルキーウェイ

好奇心に可処分時間が奪われる

Goのnet/httpでtimeout指定したときにどう制御されるのか追ってみた

はじめに

net/httpを使ったhttpリクエストでは2つの方法でtimeout管理ができます。

前者は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やサードパーティ実装を使うときは要チェック!

追いかけ

検証

ソースを読んだ感じだと以下のことが言えそう

  • 独自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系はずっと下位互換保証するぜ!というのはすごくありがたいけど、やはりいろいろな努力を要するんだな