Shogo's Blog

Jul 22, 2019 - 3 minute read - go golang

Goで指数的バックオフをやってくれるgo-retryを書いた

完全に車輪の再発明なんですが、他の実装には色々と思うところがあり書いてみました。

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
}

僕らの旅路はまだまだ続く

参考