Опирается на правила:
R-ERR-RETRY-1…R-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 | ✅ После latency | DB-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 на Domain | R-ERR-RETRY-1 | retry.RetryIf по errors.As на Integration-типе |
Write-retry без Idempotency-Key | R-ERR-RETRY-3 | IdempotencyKey в команде, Сбер/внешка дедуплицирует |
| Retry в edge-handler (chi) | R-ERR-RETRY-X1 | retry на out-adapter, не в http.Handler |
retry.Attempts(10) без backoff | R-ERR-RETRY-3 | retry.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 — нормативные формулировки.