Опирается на правила:
R-RES-FB-1,R-RES-FB-2,R-RES-FB-X1,R-RES-FB-X2,R-RES-FB-X3из Resilience Style Guide → раздел 7. Fallback.
Важно знать
- Fallback допустим в трёх случаях: cached read, default value для read, async-mode для write.
- В Go нет аннотаций — fallback реализуется явно:
errors.Asпослеbreaker.Execute, осознанный результат в if-ветке.- Cached read —
GetProductDetailsприCatalogUnavailableErrorвозвращает результат из in-memory / Redis кеша.- Default value —
GetRecommendationsвозвращаетnil-срез, когда отсутствие данных бизнесом допускается.- Async-mode write —
RegisterприPaymentSystemUnavailableErrorкладёт задачу в task-queue и возвращаетRegisterResult.Queued.- Fallback не скрывает ошибку от клиента: либо явный статус
queued/stale, либо пробрасывает ошибку.Money{Amount: 0}как fallback для money-операций — бизнес-баг: клиент видит нулевой баланс вместо реальной суммы.- Fallback в другой outbound-провайдер обязан оборачивать второй вызов в собственный
gobreaker.CircuitBreaker.
Fallback — ответ на вопрос «что отдать клиенту, когда защищаемая система недоступна». В Java это аннотация fallbackMethod; в Go — явная if-ветка после breaker.Execute. Сама механика проще, но соблазн тихо вернуть «что-нибудь нейтральное» никуда не исчезает. Раскрытие правил R-RES-FB-* для Go-стека.
Три случая, когда fallback оправдан
R-RES-FB-1 определяет допустимые случаи по убыванию приемлемости.
1. Cached read — отдать последний успешный ответ
Подходит для каталогов, словарей, конфигов — данных, которые меняются редко и допускают stale-значение.
// adapters/out/catalog/catalog_adapter.go
type CatalogAdapter struct {
client *http.Client
breaker *gobreaker.CircuitBreaker
sem *semaphore.Weighted
cache ProductCache
cfg CatalogClientConfig
}
func (a *CatalogAdapter) GetProductDetails(ctx context.Context, id ProductID) (ProductDetails, error) {
ctx, span := otel.Tracer("catalog-adapter").Start(ctx, "CatalogAdapter.GetProductDetails")
defer span.End()
span.SetAttributes(
attribute.String("external.system", "catalog"),
attribute.String("circuit_breaker.state", a.breaker.State().String()),
)
if err := a.sem.Acquire(ctx, 1); err != nil {
return a.fromCache(ctx, id, &CatalogUnavailableError{Cause: err})
}
defer a.sem.Release(1)
raw, err := a.breaker.Execute(func() (any, error) {
callCtx, cancel := capTimeout(ctx, a.cfg.CallTimeout)
defer cancel()
return a.doGetDetails(callCtx, id)
})
if err != nil {
return a.fromCache(ctx, id, err)
}
details := raw.(ProductDetails)
a.cache.Put(ctx, id, details) // обновляем кеш при успехе, не в fallback
return details, nil
}
func (a *CatalogAdapter) fromCache(ctx context.Context, id ProductID, cause error) (ProductDetails, error) {
var unavail *CatalogUnavailableError
if errors.As(cause, &unavail) || errors.Is(cause, gobreaker.ErrOpenState) {
cached, cacheErr := a.cache.Get(ctx, id)
if cacheErr == nil {
slog.WarnContext(ctx, "catalog unavailable, returning cached",
"product_id", id,
"cause", cause,
)
return cached, nil
}
}
return ProductDetails{}, fmt.Errorf("get product details: %w", cause)
}
cache.Put — только в happy-path, не в fallback. Fallback только читает кеш. Лог уровня WARN фиксирует событие для SRE.
2. Default value для read
Подходит, когда отсутствие данных — допустимая норма. Пример: персональные рекомендации на главной.
// adapters/out/recommendations/recommendations_adapter.go
func (a *RecommendationsAdapter) GetRecommendations(
ctx context.Context, id CustomerID,
) ([]Product, error) {
if err := a.sem.Acquire(ctx, 1); err != nil {
slog.WarnContext(ctx, "recommendations semaphore full, returning empty",
"customer_id", id, "cause", err)
return nil, nil
}
defer a.sem.Release(1)
raw, err := a.breaker.Execute(func() (any, error) {
callCtx, cancel := capTimeout(ctx, a.cfg.CallTimeout)
defer cancel()
return a.doGetRecommendations(callCtx, id)
})
if err != nil {
if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) {
slog.WarnContext(ctx, "recommendations CB open, returning empty", "customer_id", id)
return nil, nil // пустой срез — допустимый ответ для UI
}
return nil, fmt.Errorf("get recommendations: %w", err)
}
return raw.([]Product), nil
}
Бизнес заранее согласен: пустой список — нормальное состояние, UI отобразит «рекомендаций нет». Если пустой список является ошибкой — это не fallback, а сокрытие проблемы (R-RES-FB-X2).
3. Async-mode для write — task-queue + явный статус
Для write-операций fallback не возвращает success. Только явный queued-статус с идентификатором задачи.
// adapters/out/sber/sber_adapter.go
type RegisterResult struct {
Status RegisterStatus
TaskID *int64 // заполнен при Status == Queued
FormURL string // заполнен при Status == Registered
}
type RegisterStatus string
const (
RegisterStatusRegistered RegisterStatus = "registered"
RegisterStatusQueued RegisterStatus = "queued"
)
func (a *SberAdapter) Register(ctx context.Context, order Order) (RegisterResult, error) {
ctx, span := otel.Tracer("sber-adapter").Start(ctx, "SberAdapter.Register")
defer span.End()
span.SetAttributes(
attribute.String("external.system", "sber"),
attribute.String("circuit_breaker.state", a.breaker.State().String()),
)
if err := a.sem.Acquire(ctx, 1); err != nil {
return a.registerAsync(ctx, order, &PaymentSystemUnavailableError{System: "sber", Cause: err})
}
defer a.sem.Release(1)
raw, err := a.breaker.Execute(func() (any, error) {
callCtx, cancel := capTimeout(ctx, a.cfg.CallTimeout)
defer cancel()
return a.doRegister(callCtx, order)
})
if err != nil {
return a.registerAsync(ctx, order, err)
}
return raw.(RegisterResult), nil
}
func (a *SberAdapter) registerAsync(
ctx context.Context, order Order, cause error,
) (RegisterResult, error) {
if errors.Is(cause, gobreaker.ErrOpenState) || errors.Is(cause, gobreaker.ErrTooManyRequests) {
taskID, enqueueErr := a.taskQueue.Enqueue(ctx, toRegisterTask(order))
if enqueueErr != nil {
return RegisterResult{}, fmt.Errorf("enqueue register task: %w", enqueueErr)
}
slog.WarnContext(ctx, "sber CB open, order queued for retry",
"order_id", order.ID, "task_id", taskID)
return RegisterResult{Status: RegisterStatusQueued, TaskID: &taskID}, nil
}
return RegisterResult{}, fmt.Errorf("sber register: %w", cause)
}
Контроллер маппит RegisterStatusQueued в 202 Accepted с телом {"status":"queued","task_id":...} и Location на polling endpoint. Клиент знает, что результат не финальный.
Контракт fallback-ветки в Go
R-RES-FB-2: в Go нет аннотационного fallback, поэтому контракт — соглашение по коду.
Три правила:
- Явная проверка типа ошибки через
errors.As/errors.Is— никогдаcatch-allбез разбора. - Fallback-ветка не возвращает тот же тип, что и happy-path, если это write: возвращает обогащённый результат с явным
Status. - Логирование на WARN с
slog.WarnContext— всегда при уходе в fallback.
// Явное разделение — CB open vs другая ошибка
raw, err := a.breaker.Execute(fn)
if err != nil {
if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) {
// CB open — знаем точно, что система лежит: в кеш / очередь
return a.fromCache(ctx, id, err)
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
// timeout — транзиент, может быть stale из кеша
return a.fromCache(ctx, id, err)
}
// остальные ошибки — пробрасываем, не маскируем
return ProductDetails{}, fmt.Errorf("catalog get: %w", err)
}
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
return Balance{Amount: 0}, nil при ошибке money-операции | R-RES-FB-X1 | return Balance{}, fmt.Errorf(...) или Optional-обёртка |
_, err = api.Send(...); if err != nil { log.Warn(...) } — проглотить ошибку | R-RES-FB-X2 | Явный queued/failed возврат или return err |
Fallback в backup-провайдер без собственного gobreaker на второй вызов | R-RES-FB-X3 | Каждый провайдер — свой gobreaker.CircuitBreaker |
return nil, nil для write при отказе (тихий «успех») | R-RES-FB-1 | RegisterResult{Status: Queued, TaskID: &id}, nil |
errors.Is(err, gobreaker.ErrOpenState) без обработки ErrTooManyRequests (half-open) | R-RES-FB-2 | Проверять оба: ErrOpenState и ErrTooManyRequests |
Fallback с нулевым money
// ПЛОХО
func (a *SberAdapter) GetBalance(ctx context.Context, id AccountID) (Balance, error) {
raw, err := a.breaker.Execute(func() (any, error) {
return a.doGetBalance(ctx, id)
})
if err != nil {
return Balance{Amount: 0}, nil // ← клиент видит «баланс = 0», паникует
}
return raw.(Balance), nil
}
// ХОРОШО
func (a *SberAdapter) GetBalance(ctx context.Context, id AccountID) (Balance, error) {
raw, err := a.breaker.Execute(func() (any, error) {
return a.doGetBalance(ctx, id)
})
if err != nil {
return Balance{}, &SberUnavailableError{Cause: err} // ← 503 на уровне handler
}
return raw.(Balance), nil
}
Каскадный fallback без CB на второй вызов
// ПЛОХО — fallback в OdnaKassa без своего breaker
func (a *PaymentRouter) registerFallback(
ctx context.Context, order Order, cause error,
) (RegisterResult, error) {
// если OdnaKassa тоже лежит — нет защиты, горутина зависает на callTimeout
return a.odnaKassaAdapter.Register(ctx, order)
}
// ХОРОШО — odnaKassaAdapter имеет собственный gobreaker.CircuitBreaker
func (a *PaymentRouter) registerFallback(
ctx context.Context, order Order, cause error,
) (RegisterResult, error) {
result, err := a.odnaKassaAdapter.Register(ctx, order) // свой CB внутри
if err != nil {
// обе системы недоступны — в task-queue
return a.registerAsync(ctx, order, err)
}
return result, nil
}
Куда дальше
- Async и polling — task-queue с 202 Accepted: структура таблицы
*_task, scheduler, polling endpoint. - Circuit Breaker — когда
gobreaker.ErrOpenStateиErrTooManyRequestsтриггерят fallback. - Bulkhead —
semaphore.Acquireдо CB-вызова: почему ошибка семафора тоже идёт в fallback. - Retry —
retry.RetryIf: какие ошибки ретраить, какие сразу отдать в fallback. - Timeouts —
capTimeoutи иерархияconnectTimeout < readTimeout < callTimeout. - Конфигурация — параметры CB/bulkhead через
envconfig, без хардкода в коде. - Observability —
slog.WarnContextпри fallback, OTel-span сcircuit_breaker.state. - Health checks — TTL-кеш probe; связь состояния CB и readiness.
- Per-system isolation — отдельный
*http.Client+gobreakerна каждую систему. - Где какая защита — outbound vs internal vs inbound: где fallback нужен, где нет.
- OpenAPI generator binding —
gobreakerна public-методе адаптера, не на сгенерированном клиенте.