Опирается на правила: 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 readGetProductDetails при CatalogUnavailableError возвращает результат из in-memory / Redis кеша.
  • Default valueGetRecommendations возвращает nil-срез, когда отсутствие данных бизнесом допускается.
  • Async-mode writeRegister при 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, поэтому контракт — соглашение по коду.

Три правила:

  1. Явная проверка типа ошибки через errors.As/errors.Is — никогда catch-all без разбора.
  2. Fallback-ветка не возвращает тот же тип, что и happy-path, если это write: возвращает обогащённый результат с явным Status.
  3. Логирование на 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-X1return 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-1RegisterResult{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-методе адаптера, не на сгенерированном клиенте.