emahiro/b.log

日々の勉強の記録とか育児の記録とか。

i/o timeout エラーをハンドリングする

Overview

HTTP のりクエスをしたときに遭遇する dial tcp $IP: i/o timeout をハンドリングする方法について、そもそもこれがどうして発生するのか?と併せて調べた備忘録です。

http.httpError と net.Error

Go で API クライアントを実装する際に遭遇する Timeout エラーのパターンとして http package 内で発生するのか net package 内で発生するのかで2種類が存在(※) します。
この2つはそもそも発生するユースケースが違います。
http.httpError の方は Context deadline exeeded なので Context の生存期間が切れてそれ以上処理を継続できなくなったために発生するエラーです。一方で net.Error は i/o timeout なのでコネクションの Timeout に起因するエラーになります。
net.Error の方が一段深いレイヤー(net.Conn のレイヤー)で発生したエラーをハンドリングするエラーになります。

※ net.Error は Interface なので net package 内のその他定義されてるエラーも必要な interface を実装していれば net.Error として振る舞うことができます。

net.Error で発生する Timeout のハンドリング

net.Error は interface になっている のでこの interface を実装してるエラーであれば Timeout() を call して Timeout かどうかをハンドリングできます。

そのため以下のように net.Error でキャストします。

resp, err := http.DefaultClient.Do(r)
if err != nil {
    if ne, ok := err.(net.Error); ok && ne.Timeout() {
            // Timeout 時の何かしらの処理
        }
    }
    return nil, err
}

余談: 実装を追っかけてみた

そもそも最初の疑問は timeout のエラーにも関わらず、http package を探しても見つからなかったので、どういうケースで i/o timeout がエラーが出力されるのかわからなかったので、調べることにしました。調べ方ですが、まずはどこでエラーが発火してるのか調べ、順々に呼び出し先を追っかけていきます。

エラー自体は net pacakge の timeoutError になります → https://cs.opensource.google/go/go/+/master:src/net/net.go;l=600

よく読むと timeoutError は net.Error を満たす interface を実装してるので net.Error の Timeout メソッドでハンドリングが可能になります → https://cs.opensource.google/go/go/+/master:src/net/net.go;l=598-602;drc=55590f3a2b89f001bcadf0df6eb2dde62618302b

では実際どこで呼ばれることで i/o timeout が発火するのでしょうか?この timoutError が発火する先を遡って探していきます。

timeoutErrror が定義されているのは https://cs.opensource.google/go/go/+/master:src/net/net.go;l=596;drc=55590f3a2b89f001bcadf0df6eb2dde62618302b になります。

この errTimeout が呼ばれてるところを探すと dial.go の partialDeadline で呼ばれてることがわかります → https://cs.opensource.google/go/go/+/master:src/net/dial.go;l=138;drc=55590f3a2b89f001bcadf0df6eb2dde62618302b;bpv=1;bpt=1

さらにこの partialDeadline を遡ります。そうすると同じく dial.go 内部の dialSerial 内で呼ばれてることがわかります → https://cs.opensource.google/go/go/+/master:src/net/dial.go;drc=55590f3a2b89f001bcadf0df6eb2dde62618302b;bpv=1;bpt=1;l=523

この dialSerial がどこで呼ばれてるか遡ると、その1つに dial.go 内の dialParallel の内部で呼ばれてることがわかります → https://cs.opensource.google/go/go/+/master:src/net/dial.go;drc=55590f3a2b89f001bcadf0df6eb2dde62618302b;bpv=1;bpt=1;l=449

最後にこの dialParallel がどこで呼ばれてるのか調べると dial.go 内の DialContext の中で呼ばれてることがわかります → https://cs.opensource.google/go/go/+/master:src/net/dial.go;drc=55590f3a2b89f001bcadf0df6eb2dde62618302b;bpv=1;bpt=1;l=376

実はこの DialContext は http.DefaultClient を使うときに自動的に指定される DefaultTransport の内部で呼ばれている defaultTransportDialContext ので呼ばれています。

defaultTransportDialContexthttps://cs.opensource.google/go/go/+/master:src/net/http/transport_default_other.go;drc=55590f3a2b89f001bcadf0df6eb2dde62618302b;bpv=1;bpt=1;l=15

DefaultTransporthttps://cs.opensource.google/go/go/+/master:src/net/http/transport.go;l=43-54;drc=55590f3a2b89f001bcadf0df6eb2dde62618302b;bpv=1;bpt=1

これからわかるのは i/o timeout は package が示す通り http のレイヤーではなく、一つ下の connection を管理するレイヤーで発火したエラーであることがわかります。Transport は DialContext で指定された値で接続されるので、timeout を発火させない場合はこの DialContext で指定する Timeout の値を増やせばいいことになります。

追記

Go1.19 で net.Error でキャストしなくて良くなる?

教えてもらいました。

実際に Go 1.19 のリリースノート(予定) を見ると、net package の箇所に以下のように記載してあります。

When a net package function or method returns an "I/O timeout" error, the error will now satisfy errors.Is(err, context.DeadlineExceeded). When a net package function returns an "operation was canceled" error, the error will now satisfy errors.Is(err, context.Canceled). These changes are intended to make it easier for code to test for cases in which a context cancellation or timeout causes a net package function or method to return an error, while preserving backward compatibility for error messages.

Go1.19 以降は net.Error でキャストして Timeout を呼ぶ必要はなく、context.DeadlineExceeded でハンドリングできるのは良いですね(http と意味合いが重なってしまうような気もしましたが)