Опирается на правила: R-ERR-RETRY-1R-ERR-RETRY-3 и R-ERR-RETRY-X1 из Error Handling Style Guide → раздел 5. Retry / no-retry семантика.

Важно знать

  • Domain / Validation — никогда не retry. Детерминированный fail: те же данные → тот же результат.
  • Integration — retry-safe при идемпотентности. RetryIf через errors.As на конкретном Integration-типе.
  • Technical — обычно retry после latency. DB-timeout, OOM — часто транзиентно.
  • HTTP 4xx от внешней системы → InvalidPaymentRequestError с Kind() Domain. Не retry.
  • HTTP 5xx и timeout → GatewayError с Kind() Integration. Retry-safe только при идемпотентности write.
  • Idempotency-Key для write — обязателен если retry включён. Без него повторный запрос к Сберу спишет деньги дважды.
  • Retry — на out-adapter, не в edge-handler. Edge вне retry-цикла.

Retry — дешёвый способ пережить транзиентный сбой, но та же механика может нарушить целостность: повторно списать деньги, создать дубль заказа, отправить SMS дважды. Категория ошибки — первый критерий «retry или нет». Раскрытие правил R-ERR-RETRY-* ниже.

Таблица по категориям

R-ERR-RETRY-1:

КатегорияRetryПричина
Domain❌ НикогдаБизнес-правило детерминированно — тот же state + тот же запрос → тот же fail.
Validation❌ НикогдаНевалидный input. Те же данные → тот же fail.
Integration✅ При идемпотентностиТранзиентный сбой внешки. Только при Idempotency-Key на write.
Technical✅ После latencyDB-timeout, OOM — часто проходит само.

Domain и Validation — никогда

Если order.New вернула &InsufficientFundsError{...}, retry не поможет:

// ПЛОХО — retry вокруг domain-операции
err := retry.Do(
	func() error {
		_, err := createOrderHandler.Handle(ctx, cmd)
		return err
	},
	retry.Attempts(3),
)

При первой попытке бизнес-правило нарушено. При второй и третьей — состояние Customer не изменилось, правило сработает снова. Получаем три одинаковых WarnContext подряд и ту же 422-ошибку клиенту спустя 2 секунды.

RetryIf фильтрует по типу — Domain и Validation не попадают в retry:

retry.RetryIf(func(err error) bool {
	return apperr.KindOf(err) == apperr.Integration
})

Или точечно по конкретному типу:

retry.RetryIf(func(err error) bool {
	var g *payment.GatewayError
	return errors.As(err, &g)
})

Второй вариант предпочтительнее — явно описывает «retry только при сбое платёжки», не при любом Integration.

Integration — retry-safe при идемпотентности

R-ERR-RETRY-3: HTTP 5xx и timeout — транзиентные сбои, retry имеет смысл. Но только если операция идемпотентна.

Read-операции

Read (GetOrder, FindProduct, ListCustomers) — идемпотентны по природе. Retry safe:

var result Product
err := retry.Do(
	func() error {
		var e error
		result, e = catalogClient.FindProduct(ctx, productID)
		return e
	},
	retry.RetryIf(func(err error) bool {
		var cp *catalog.PortError
		return errors.As(err, &cp)
	}),
	retry.Attempts(3),
	retry.DelayType(retry.BackOffDelay),
	retry.Context(ctx),
)

Write-операции с Idempotency-Key

R-ERR-RETRY-3: без Idempotency-Key retry write-операции опасен.

Сценарий без ключа: Register в Сбере принял запрос, списал деньги, но ответ не дошёл из-за сетевого таймаута. retry.Do повторяет запрос — Сбер видит новый запрос, снова списывает. Пользователь потерял деньги дважды.

С Idempotency-Key Сбер распознаёт повтор и возвращает результат первой попытки без повторного списания:

// core/order/handler.go
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) (Order, error) {
	payCmd := payment.RegisterCommand{
		OrderID:        cmd.OrderID,
		Amount:         cmd.Amount,
		IdempotencyKey: cmd.OrderID, // OrderID как ключ идемпотентности
	}

	var payResult payment.RegisterResult
	err := retry.Do(
		func() error {
			var e error
			payResult, e = h.paymentPort.Register(ctx, payCmd)
			return e
		},
		retry.RetryIf(func(err error) bool {
			var g *payment.GatewayError
			return errors.As(err, &g) // только GatewayError, не InvalidPaymentRequestError
		}),
		retry.Attempts(3),
		retry.DelayType(retry.BackOffDelay),
		retry.Context(ctx),
	)
	if err != nil {
		return Order{}, fmt.Errorf("register payment for order %s: %w", cmd.OrderID, err)
	}
	// ...
}

IdempotencyKey: cmd.OrderID — для регистрации заказа OrderID уникален и стабилен между попытками. Клиент (мобильное приложение) может передать свой ClientRequestID сверху — ещё лучше.

HTTP 4xx от внешней системы — не retry

R-ERR-RETRY-2: 4xx означает «мы послали что-то некорректное». Out-adapter маппит 4xx в InvalidPaymentRequestError с Kind() Domain.

// adapters/out/payment/client.go
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
	return RegisterResult{}, &InvalidPaymentRequestError{OrderID: cmd.OrderID}
}

InvalidPaymentRequestError.Kind() возвращает apperr.Domain. RetryIf отфильтрует его — повтор не будет:

retry.RetryIf(func(err error) bool {
	var g *payment.GatewayError
	return errors.As(err, &g) // InvalidPaymentRequestError — не GatewayError → false → no retry
})

Edge получит Domain → 422. Клиент узнает, что его запрос некорректен с точки зрения платёжки.

Circuit Breaker вместе с retry

gobreaker оборачивает adapter-вызов — при трёх последовательных сбоях CB открывается:

// adapters/out/payment/client.go
type Client struct {
	http *http.Client
	cb   *gobreaker.CircuitBreaker
}

func NewClient() *Client {
	cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
		Name:        "sber-payment",
		MaxRequests: 1,
		Interval:    30 * time.Second,
		Timeout:     10 * time.Second,
		ReadyToTrip: func(counts gobreaker.Counts) bool {
			return counts.ConsecutiveFailures >= 3
		},
	})
	return &Client{cb: cb}
}

func (c *Client) Register(ctx context.Context, cmd RegisterCommand) (RegisterResult, error) {
	result, err := c.cb.Execute(func() (any, error) {
		return c.doRegister(ctx, cmd)
	})
	if err != nil {
		if errors.Is(err, gobreaker.ErrOpenState) {
			return RegisterResult{}, &GatewayError{Op: "register", Err: err}
		}
		return RegisterResult{}, &GatewayError{Op: "register", Err: err}
	}
	return result.(RegisterResult), nil
}

Когда CB открыт — gobreaker сразу возвращает ErrOpenState. GatewayError оборачивает его. В logByKind это распознаётся как ErrorContext (CB открыт = инцидент). Edge отдаёт 503. Retry на 503 не будет — CB open означает «система недоступна, даже не пробуй».

Что запрещено

АнтипаттернПравилоЧто взамен
retry.Do без RetryIf — retry на DomainR-ERR-RETRY-1retry.RetryIf по errors.As на Integration-типе
Write-retry без Idempotency-KeyR-ERR-RETRY-3IdempotencyKey в команде, Сбер/внешка дедуплицирует
Retry в edge-handler (chi)R-ERR-RETRY-X1retry на out-adapter, не в http.Handler
retry.Attempts(10) без backoffR-ERR-RETRY-3retry.DelayType(retry.BackOffDelay) + разумный Attempts(3)

R-ERR-RETRY-X1 — retry в chi-handler бессмысленен:

// ПЛОХО
func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
	var result Order
	err := retry.Do(func() error {
		var e error
		result, e = h.createHandler.Handle(r.Context(), cmd)
		return e
	}, retry.Attempts(3)) // ← retry вне retry-цикла, запрос уже получен
	// ...
}

К моменту, когда ошибка долетела до chi-handler'а, все retry в out-adapter и CB уже отработали. Retry на «как мы отвечаем клиенту» — повторная обработка уже полученного HTTP-запроса, что не имеет смысла. Retry — на уровне port-вызовов внутри UseCase Handler.

Куда дальше

  • Иерархия ошибок — откуда берётся Kind() на GatewayError и InvalidPaymentRequestError.
  • Где return, где обрабатывать — retry как третья точка обработки.
  • Mapping в ProblemDetails — 502 vs 503 vs 504 при разных сбоях Integration.
  • Логирование ошибок — ErrorContext при открытом CB.
  • Errors-as-values vs panic — panic не retry-able, только Recoverer.
  • Observability ошибок — алёрт на integration при открытом CB.
  • Shared-контракт R-ERR-RETRY — нормативные формулировки.