完全に車輪の再発明なんですが、他の実装には色々と思うところがあり書いてみました。
MOTIVATION
カッコいいインターフェースが欲しい
インターフェースは lestrrat さんのこの資料を参考にしています。
「これ、Loop Condition だ」のあたりで、なるほど!と思ってインターフェースを真似てみました。 このインターフェースに沿って、lestrratさん自身が実装した lestrrat-go/backoff があります。 しかし、個人的にちょっと実装が複雑だなと感じたので、もうちょっとシンプルに書けないかとやってみました。
Context サポート
先行実装たちは Context がGoに取り込まれる前からあるので、 Contextに対応したインターフェースが後付だったり、 そもそもContextに対応していなかったりします。 Context未対応の Go 1.5 はすでにサポート対象外なので、もう Context が存在しない実行環境は考えなくてよいはずです。
SYNOPSIS
Loop Condition Interface
使い方は lestrrat-go/backoff と大体一緒。 指数的バックオフに必要な各種パラメーターをポリシーとして与え、リトライのためのループを回します。
// 指数的バックオフの各種パラメーターをポリシーとして定義
var policy = retry.Policy{
// 初回待ち時間
MinDelay: 100 * time.Millisecond,
// 最大待ち時間
MaxDelay: time.Second,
// 最大試行回数
MaxCount: 10,
}
func DoSomethingWithRetry(ctx context.Context) (Result, error) {
retrier := policy.Start(ctx)
for retrier.Continue() { // 待ち時間の挿入等は Loop Condition の中でやる
res, err := DoSomething(ctx)
if err == nil {
// 成功
return res, nil
}
}
// 最大試行回数を超えても失敗
return 0, errors.New("tried very hard, but no luck")
}
The Go Playground が外部ライブラリのインポートに対応したので、使用例をその場で実行できる!便利!
Function Object Interface
試しに関数を受け取るインターフェースも書いてみた。 戻り値が無いときはこっちのほうがシンプル。
func DoSomethingWithRetry(ctx context.Context) error {
return policy.Do(ctx, func() error {
return DoSomething(ctx)
})
}
err == nil
になるまでリトライを続けます。
その他工夫したところ
エラーのTemporaryをみてリトライするか決める
リトライを中断したい場合、 Continue()
を使ったインターフェースであれば break
なり return
なり標準の制御構文を使えばいいですが、
Do()
インターフェースではリトライを中断する手段がありません。
中断するために MarkPermanent という関数を用意しました。
func DoSomethingWithRetry(ctx context.Context) error {
return policy.Do(ctx, func() error {
// policyの内容にかかわらず、一回目で諦める
return retry.MarkPermanent(errors.New("some error!"))
})
}
これはlestrratさんの実装を参考に、型アサーションしてインターフェースを確認しています。
ただし見ているメソッドが違って、lestrratさんの実装は func IsPermanent() bool
メソッドを見ているのに対し、
func Temporary() bool
メソッドを見ています。
この名前はnet.Errorから拝借しました。
Error handling and Goでも取り上げられているし、
Goの世界ではこっちのほうが一般的なのでしょう。たぶん。
xerrorsサポート
Go 1.13 リリースに先駆けて xerrors のサポートも入れてあります。
func DoSomethingWithRetry(ctx context.Context) error {
return policy.Do(ctx, func() error {
// policyの内容にかかわらず、一回目で諦める
err := retry.MarkPermanent(errors.New("some error!"))
// wrapしてもMarkPermanentは残ったまま
return xerrors.Errorf("while doing: %w", err)
})
}
time.Afterではなくtime.NewTimerを使う
time.After の内部では time.Timer
を使っているのですが、
このタイマーは設定された時間が来るまでGCの対象にはなりません。
Contextがキャンセルされた場合タイマーは不要になりますが、タイマーはその後しばらく動き続けます。
不要になった段階で明示的にタイマーを止める必要があります。
ドキュメントにも効率を重視する場合は time.Timer
を使えと書いてありますね。
とはいえ、キャンセルが起きるほうが稀なので、性能にはほとんど影響ないかもしれない・・・。
Deadlineを見て無駄なSleepはしない
Context.Deadline を呼ぶと Context がいつキャンセルされるかがわかります(明示的に指定されている場合)。
deadline, ok := ctx.Deadline()
次のリトライを行う予定時刻が Deadline より先だったらリトライを行う意味はありません。 Sleepしている間にキャンセルされてしまって、結局なにも実行されないからです。 そのため、Deadlineを過ぎそうな場合は、その時点ではContextが有効な場合でも即終了するようにしてあります。
僕らの旅路はまだまだ続く
さて、書いてはみたものの、実はまだ不満な点が残っています。
エラーを呼び出し元に返したい
いままでの例では、失敗し続けた場合 errors.New
で新しいエラーを返していました。
でも、これだとリトライに失敗したことしかわかりません。
具体的にどんなエラーで失敗しているのか知りたいですよね。
単純にこれを書いてみると・・・
func DoSomethingWithRetry(ctx context.Context) (Result, error) {
retrier := policy.Start(ctx)
for retrier.Continue() {
res, err := DoSomething(ctx)
if err == nil {
return res, nil
}
}
return 0, err // スコープが違うので、errを見つけられずコンパイルエラー
}
コンパイルエラーになるので、 err
をループの外で宣言する必要があります。
func DoSomethingWithRetry(ctx context.Context) (Result, error) {
var res Result
var err error // ループの外で宣言する必要がある
retrier := policy.Start(ctx)
for retrier.Continue() {
res, err = DoSomething(ctx)
if err == nil {
return res, nil
}
}
return 0, err
}
振り出しに戻ってしまった。 関数を渡す場合とまったく同じ形になってしまいました。
func DoSomethingWithRetry(ctx context.Context) (Result, error) {
var res Result
var err error
policy.Do(ctx, func() error {
res, err = DoSomething(ctx)
return err
})
return res, err
}
goto
を使えばなんとかなるか・・・?
func DoSomethingWithRetry(ctx context.Context) (Result, error) {
retrier := policy.Start(ctx)
retrier.Continue() // for文で回す関係上、最初の一回は必ず true を返すので無視
RETRY:
res, err := DoSomething(ctx)
if err != nil && retrier.Continue() {
goto RETRY
}
return ret, err
}
goto
はあまり使わないほうがいいですが、これならまだ許容範囲?
タイムアウトを細かく制御したい
次にタイムアウトを細かく制御することを考えます。 うっかりcontext.WithTimeout の例の通りに書くと間違えます。
func DoSomethingWithRetry(ctx context.Context) (Result, error) {
retrier := policy.Start(ctx)
for retrier.Continue() {
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel() // 間違い。DoSomethingWithRetryを抜けたときにキャンセルされる。
res, err := DoSomething(ctx)
if err == nil {
return res, nil
}
}
return 0, errors.New("tried very hard, but no luck")
}
正しくは DoSomething
のあとに cancel()
func DoSomethingWithRetry(ctx context.Context) (Result, error) {
retrier := policy.Start(ctx)
for retrier.Continue() {
ctx, cancel := context.WithTimeout(ctx, time.Second)
res, err := DoSomething(ctx)
cancel()
if err == nil {
return res, nil
}
}
return 0, errors.New("tried very hard, but no luck")
}
この書き方だと制御フローが複雑で cancel()
の位置を間違えそうです。
・・・そしてまたここに戻ってくる、と。
func DoSomethingWithRetry(ctx context.Context) (Result, error) {
var res Result
var err error
policy.Do(ctx, func() error {
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
res, err = DoSomething(ctx)
return err
})
return 0, errors.New("tried very hard, but no luck")
}
ちなみに goto
を使った場合。
func DoSomethingWithRetry(ctx context.Context) (Result, error) {
retrier := policy.Start(ctx)
retrier.Continue()
RETRY:
ctx, cancel := context.WithTimeout(ctx, time.Second)
res, err := DoSomething(ctx)
cancel()
if err != nil && retrier.Continue() {
goto RETRY
}
return ret, err
}
まとめ
- Goにおけるリトライ処理のきれいな書き方について考えてみた
- wait処理とcancel判定をループの条件式にする方法
- 関数オブジェクトを渡して、リトライをライブラリに任せる方法
- 色々考えたけど、追加の要望を叶えようとすると、結局関数オブジェクトを渡すスタイルが無難そう
- 発生したエラーを呼び出し元に返したい
- タイムアウトを細かく制御したい
func DoSomethingWithRetry(ctx context.Context) (Result, error) {
var res Result
var err error
policy.Do(ctx, func() error {
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
res, err = DoSomething(ctx)
return err
})
return res, err
}
僕らの旅路はまだまだ続く