Любое взаимодействие программных компонентов ненадежно. Вызываемый компонент может быть временно недоступен или возвращать различные ошибки. Особенно если взаимодействие происходит по сети.
Некоторые типы компонентов обязаны быть устойчивы к временным сбоем, которые могут случаться в их среде. Они должны иметь возможность повторять запросы или восстанавливать соединения.
Паттерн Retry можно условно разделить на три части: Backoff, Retry Policy и Retrier.
Retry Policy
Стратегия повторных вызовов должна быть настроена в соответствии с бизнес-требованиями приложения и типами сбоя.
Отмена
Если ошибка указывает, что сбой является не временным или при повторной попытке его обработка не будет успешной, приложение должно отменить операцию и сформировать отчет об ошибке. Например, сбой аутентификации, вызванный предоставлением неверных учетных данных, не завершится успешно, независимо от количества повторных попыток.
Хороший пример можно увидеть в пакете go-retryablehttp от HashiCorp.
//https://github.com/hashicorp/go-retryablehttp/blob/master/client.go#L396
// DefaultRetryPolicy provides a default callback for Client.CheckRetry, which
// will retry on connection errors and server errors.
func DefaultRetryPolicy(ctx context.Context, resp *http.Response, err error) (bool, error) {
// do not retry on context.Canceled or context.DeadlineExceeded
if ctx.Err() != nil {
return false, ctx.Err()
}
if err != nil {
if v, ok := err.(*url.Error); ok {
// Don't retry if the error was due to too many redirects.
if redirectsErrorRe.MatchString(v.Error()) {
return false, nil
}
// Don't retry if the error was due to an invalid protocol scheme.
if schemeErrorRe.MatchString(v.Error()) {
return false, nil
}
// Don't retry if the error was due to TLS cert verification failure.
if _, ok := v.Err.(x509.UnknownAuthorityError); ok {
return false, nil
}
}
// The error is likely recoverable so retry.
return true, nil
}
// Check the response code. We retry on 500-range responses to allow
// the server time to recover, as 500's are typically not permanent
// errors and may relate to outages on the server side. This will catch
// invalid response codes as well, like 0 and 999.
if resp.StatusCode == 0 || (resp.StatusCode >= 500 && resp.StatusCode != 501) {
return true, nil
}
return false, nil
}
Важно обозначить ситуации при которых повторные запросы не имеют смысл или даже вредны.
Повторная попытка
Если определенная ошибка в отчете является нестандартной или редкой, вероятнее всего, она вызвана непредвиденными обстоятельствами, например повреждением сетевого пакета во время его передачи. В этом случае приложение может попытаться сразу же снова отправить запрос, завершившийся сбоем, так как маловероятно, что этот сбой повторится, и запрос, скорее всего, завершится успешно.
Повтор через некоторое время
Если сбой вызван одной из множества распространенных проблем с подключением или занятостью сети, или службе может потребоваться короткий промежуток на устранение ошибок подключения или удаления списка невыполненных работ. По истечении необходимого времени приложение повторно выполнит запрос.
Идемпотентность операций
Если запрос является идемпотентным, то выполнение повторной попытки безопасно. В противном случае повторные попытки могут вызвать дополнительное количество выполнений операции с непредвиденными побочными эффектами. Например, служба может принять запрос, успешно обработать его, но отправка ответа завершается сбоем. На этом этапе логика повторных попыток может повторно отправить запрос, предполагая, что не был получен первый.
Backoff
Если нам нужны повторы с интервалами, то нужно определиться с алгоритмом роста интервала и их лимитами.
maxAttempts
— лимит по количеству попытокattemptNum
— номер попыткиmin
— начальный интервал повтораmax
— ограничение на длительность интервала повтораfactor
— коэффициент нарастания задержкиjitter
— определяет случайную составляющую
import (
"math"
"math/rand"
"time"
)
type FnBackoff func(attemptNum int, min, max time.Duration) time.Duration
type Backoff struct {
min, max time.Duration
maxAttempt int
attemptNum int
backoff FnBackoff
}
func NewBackoff(min, max time.Duration, maxAttempt int, backoff FnBackoff) *Backoff {
if backoff == nil {
backoff = ExponentialBackoff
}
return &Backoff{
min: min,
max: max,
maxAttempt: maxAttempt,
backoff: backoff,
}
}
const Stop time.Duration = -1
func (b *Backoff) Next() time.Duration {
if b.attemptNum >= b.maxAttempt {
return Stop
}
b.attemptNum++
return b.backoff(b.attemptNum, b.min, b.max)
}
func (b *Backoff) Reset() {
b.attemptNum = 0
}
У Backoff
есть лимиты(min
, max
, maxAttempt
), счетчик попыток(attemptNum
) и функция которая по attemptNum
, min
, max
вернет нужны интервал ожидание.
Next
возвращает следующий интервал.
Reset
сбрасывает счетчик попыток.
Фиксированный интервал времени
Это самая простая стратегия. В случае неуспешного выполнения запроса клиент повторяет выполнение запроса через константный интервал.
func ConstantBackoff(factor time.Duration) FnBackoff {
return func(attemptNum int, min, max time.Duration) time.Duration {
if factor < min {
return min
}
if factor > max {
return max
}
return factor
}
}
Линейный рост
Интервал между попытками растет линейно на выбранное значение. Например, мы может ограничить кол-во повторов дестью и увеличивать каждый новый интервал на 100мл начинания с 500мл.
func LinerBackoff(factor time.Duration) FnBackoff {
return func(attemptNum int, min, max time.Duration) time.Duration {
delay := factor * time.Duration(attemptNum)
if delay < min {
delay = min
}
jitter := time.Duration(rand.Float64() * float64(delay-min))
delay = delay + jitter
if delay > max {
delay = max
}
return delay
}
}
Экспоненциальный рост
Экспоненциальный рост будет предпочтительной стратегией для большинства случаев.
- интервал увеличивается с каждой попыткой, если сервер не может справиться с потоком запросов, поток запросов от клиентов будет уменьшаться со временем
- интервалы рандомизированы, т.е. не будет «шквала» запросов в такты, пропорциональные фиксированному интервалу
2^X
func ExponentialBackoff(attemptNum int, min, max time.Duration) time.Duration {
factor := 2.0
rand.Seed(time.Now().UnixNano())
delay := time.Duration(math.Pow(factor, float64(attemptNum)) * float64(min))
jitter := time.Duration(rand.Float64() * float64(min) * float64(attemptNum))
delay = delay + jitter
if delay > max {
delay = max
}
return delay
}
Retrier
Тут возможны различные варианты. Напишем простой пример.
type Worker func(ctx context.Context) error
Worker
— функция клиента которую необходимо выполнить и в случае ошибки повторить попытку.
type Action int
const (
Succeed Action = iota
Fail
Retry
)
type RetryPolicy func(err error) Action
RetryPolicy
— проверяет ошибку от Worker
. Если ошибка попадает под правило Retry
, то повторяем попытку.
Простой пример RetryPolicy:
func DefaultRetryPolicy(err error) Action {
if err == nil {
return Succeed
}
return Retry
}
Реализация Retry
https://github.com/GermanGorelkin/go-patterns/tree/master/retry
type Action int
const (
Succeed Action = iota
Fail
Retry
)
type Worker func(ctx context.Context) error
type RetryPolicy func(err error) Action
type Retrier struct {
backoff *Backoff
retryPolicy RetryPolicy
}
func NewRetrier(backoff *Backoff, retryPolicy RetryPolicy) Retrier {
if retryPolicy == nil {
retryPolicy = DefaultRetryPolicy
}
return Retrier{
backoff: backoff,
retryPolicy: retryPolicy,
}
}
func (r Retrier) Run(ctx context.Context, work Worker) error {
defer r.backoff.Reset()
for {
err := work(ctx)
switch r.retryPolicy(err) {
case Succeed, Fail:
return err
case Retry:
// log.Println(err) // error logging
var delay time.Duration
if delay = r.backoff.Next(); delay == Stop {
return err
}
timeout := time.After(delay)
if err := r.sleep(ctx, timeout); err != nil {
return err
}
}
}
}
func (r *Retrier) sleep(ctx context.Context, t <-chan time.Time) error {
select {
case <-t:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func DefaultRetryPolicy(err error) Action {
if err == nil {
return Succeed
}
return Retry
}
Вариант использования:
type Client struct {
*sql.DB
runner Retrier
}
func (c *Client) PingContext(ctx context.Context) error {
return c.runner.Run(ctx, c.DB.PingContext)
}
Наш Retrier
написан в учебных целях и для продакшена не предназначен. Например, он не потокобезопасен, что может быть проблемой. Но его можно использовать как базис для нужного решения.
Retry на go
- https://github.com/Rican7/retry
- https://github.com/eapache/go-resiliency
- https://github.com/jpillora/backoff
- https://github.com/cenkalti/backoff
Дополнительная информация
- https://aws.amazon.com/ru/blogs/architecture/exponential-backoff-and-jitter/
- https://github.com/googleapis/google-http-java-client/blob/da1aa993e90285ec18579f1553339b00e19b3ab5/google-http-client/src/main/java/com/google/api/client/util/ExponentialBackOff.java
Комментарии в Telegram-группе!