Любое взаимодействие программных компонентов ненадежно. Вызываемый компонент может быть временно недоступен или возвращать различные ошибки. Особенно если взаимодействие происходит по сети.

Некоторые типы компонентов обязаны быть устойчивы к временным сбоем, которые могут случаться в их среде. Они должны иметь возможность повторять запросы или восстанавливать соединения.

Паттерн 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
	}
}

Экспоненциальный рост

This is an image

Экспоненциальный рост будет предпочтительной стратегией для большинства случаев.

  • интервал увеличивается с каждой попыткой, если сервер не может справиться с потоком запросов, поток запросов от клиентов будет уменьшаться со временем
  • интервалы рандомизированы, т.е. не будет «шквала» запросов в такты, пропорциональные фиксированному интервалу

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


Дополнительная информация


Комментарии в Telegram-группе!