Yak Shaving With Backoff Libraries in Go

Daisuke Maki
3 min readJan 24, 2018

--

I’m sure you’ve had times when you just needed to use backoffs when making API calls and such. In Go prior arts include github.com/cenkalti/backoff and github.com/jpillora/backoff, among others.

I used to use github.com/cenkalti/backoff, but one thing kept bugging me: namely, that it requires you to create a wrapper closure around the operation to coerce the input in the form of func() error.

For example, when you would want to retry a function ( myFunc below) that returns 3 values and an error, you need to play games with the scope

var a, b, c Result
backoff.Retry(func() error {
var err error
a, b, c, err = myFunc(arg, ...)
return err
}, backoffObject)

In terms of performance this is probably negligible but the scopes are all messed up, and now you have to be careful not to use := instead of = in the assignment to make sure you get the correct return values.

This is the main motivation for writing github.com/lestrrat-go/backoff .

Using this library, you will have to write a bit more boilerplate code (UPDATE: a lot of this has been fixed in the weeks after publishing this article — see “updated usage” below), but you do not need to make a closure and I find the resulting code is more Go-ish. Please note that the actual calculation for backoff duration is still rough, please let me know if there are better algorithms.

To use github.com/lestrrat-go/backoff, you first create a policy object:

policy := backoff.NewExponential(...)

Arguments will configure the policy, such as configuring the maximum number of retries and what not. The policy object may be reused by multiple consumers.

Actual backoff objects are created by calling Start() on the policy object:

b, cancel := policy.Start(ctx)

The method receives a context object, so you can kill the backoff if an operation from the upper scope decided to bail out.

cancel is used to free resources when the backoff b is no longer needed.

The backoff object b contains two methods, Done() and Next() . They both return a channel that signals us for events.

Done() becomes readable when the backoff should stop: this includes when the parent context was canceled, the backoff was canceled, or a certain condition, such as when we have already re-tried the operation over the amount specified by MaxRetries parameter.

Next() becomes readable only when enough time has passed since being called. In case of an exponential backoff, after the call to Next(), you may have to wait in the order of 1, 2, 4, 8, 16… (multiplied by the base interval).

Using these methods, your backoff method will look like this:

func MyFuncWithRetries(ctx context.Context, ... ) (Result1, Result2, Result3, error) {
b, cancel := policy.Start(ctx)
defer cancel()
for {
ret1, ret2, ret3, err := MyFunc(...)
if err == nil { // success
return ret1, ret2, ret3, nil
}
select {
case <-b.Done():
return nil, nil, nil, errors.New(`all attempts failed`)
case <-b.Next():
// continue to beginning of the for loop, execute MyFunc again
}
}
}

The downside to using this method is that you need more boilerplate. The upside is that now there are no weird scope trickeries, and the operations are more straightforward.

I think it’s ultimately a matter of taste, so you should choose whichever library that fits your needs. This just happened to be what I wanted, and I hope it would be useful for you.

Happy hacking!

Updated Usage: this library has evolved a bit more, and the above code now looks like:

func MyFuncWithRetries(ctx context.Context, ... ) (Result1, Result2, Result3, error) {
b, cancel := policy.Start(ctx)
defer cancel()
for backoff.Continue(b) {
r1, r2, r3, err := MyFunc()
if err == nil {
return r1, r2, r3, nil
}
}
return nil, nil, nil, errors.New(`failed to execute`)
}

--

--

Daisuke Maki

Go/perl hacker; author of peco; works @ Mercari; ex-mastermind of builderscon; Proud father of three boys;