Опирается на правила: 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.
  • Cached readlist_products при отказе CDN возвращает копию из Redis с пометкой stale.
  • Default valueget_recommendations возвращает [], когда отсутствие данных — норма с точки зрения бизнеса.
  • Async-mode writeregister_payment при отказе Sber → 202 Accepted + задача в task-queue; клиент явно знает, что обработка отложена.
  • В Python fallback — явная ветка except, а не отдельный fallbackMethod; сигнатура адаптера та же, возвращаемый тип не меняется.
  • Нет fallback с None / Money(amount=0) для money-операций — это бизнес-баг.
  • Нет тихого fallback — проглоченная ошибка скрывает сбой от клиента и SRE.
  • Нет каскадного fallback в другой провайдер без собственного CB — cascading failure.

Fallback — это что отдать клиенту, когда защищаемая система недоступна. Соблазн прост: «вернуть что-нибудь нейтральное и не падать». Но это работает только если «нейтральное» — реально допустимое значение для бизнеса. В большинстве случаев тихий fallback хуже честной ошибки. Раскрытие правил R-RES-FB-*.

Три случая, когда fallback оправдан

R-RES-FB-1: иерархия по убыванию приемлемости.

1. Cached read — отдать последний успешный ответ

Подходит для каталога, словарей, конфигов — данных, которые меняются редко и допускают stale.

# adapters/out/catalog/catalog_adapter.py
import logging
import asyncio
from purgatory import AsyncCircuitBreaker, OpenCircuitException
from core.port.catalog_port import CatalogPort
from core.domain.product import Product
from core.domain.category import CategoryId

log = logging.getLogger(__name__)


class ProductCatalogAdapter(CatalogPort):
    def __init__(
        self,
        client: httpx.AsyncClient,
        breaker: AsyncCircuitBreaker,
        sem: asyncio.Semaphore,
        cache: ProductCatalogCache,
    ) -> None:
        self._client = client
        self._breaker = breaker
        self._sem = sem
        self._cache = cache

    async def list_products(self, category_id: CategoryId) -> list[Product]:
        async with self._sem:
            try:
                async with self._breaker:
                    async with asyncio.timeout(self._settings.total):
                        resp = await self._client.get(
                            "/products", params={"category": category_id.value}
                        )
                        resp.raise_for_status()
                products = [to_domain(p) for p in resp.json()]
                await self._cache.put(category_id, products)   # обновляем при успехе
                return products
            except (OpenCircuitException, TimeoutError, httpx.HTTPError) as exc:
                return await self._from_cache(category_id, exc)

    async def _from_cache(
        self, category_id: CategoryId, exc: Exception
    ) -> list[Product]:
        log.warning(
            "catalog unavailable, returning cached",
            extra={"category_id": category_id.value, "error": str(exc)},
        )
        cached = await self._cache.get(category_id)
        return cached if cached is not None else []

Что важно:

  • cache.put(...) — внутри happy-path, не в fallback. Fallback только читает кеш.
  • Лог уровня WARNING с контекстом. SRE видит «catalog был недоступен, отдали stale».
  • В ответе можно добавить заголовок Cache-Control: stale-if-error — клиент явно знает, что данные могут быть устаревшими.

2. Default value для read

Подходит, когда отсутствие данных — норма. Пример: персональные рекомендации для Customer.

# adapters/out/recommendations/recommendations_adapter.py
class RecommendationsAdapter(RecommendationsPort):
    async def get_recommendations(self, customer_id: CustomerId) -> list[Product]:
        async with self._sem:
            try:
                async with self._breaker:
                    async with asyncio.timeout(self._settings.total):
                        resp = await self._client.get(
                            f"/customers/{customer_id.value}/recommendations"
                        )
                        resp.raise_for_status()
                        return [to_domain(p) for p in resp.json()]
            except (OpenCircuitException, TimeoutError, httpx.HTTPError) as exc:
                log.warning(
                    "recommendations unavailable, returning empty list",
                    extra={"customer_id": customer_id.value, "error": str(exc)},
                )
                return []

Что важно:

  • Бизнес заранее согласен, что пустой список — допустимый ответ. UI покажет «Нет рекомендаций» — это normal-path.
  • Если пустой список был бы ошибкой (например, у Customer всегда есть хоть одна рекомендация), return [] — это сокрытие проблемы, не fallback.

3. Async-mode для write — 202 Accepted + task-queue

Для write-операций fallback никогда не возвращает success. Только 202 Accepted с явным указанием, что обработка отложена.

# adapters/out/sber/sber_adapter.py
from core.domain.order import Order
from core.domain.payment import PaymentRef, PaymentResult

class SberAdapter(PaymentPort):
    async def register(self, order: Order) -> PaymentResult:
        async with self._sem:
            try:
                async with self._breaker:
                    async with asyncio.timeout(self._settings.total):
                        resp = await self._client.post(
                            "/register", json=to_sber_request(order)
                        )
                        resp.raise_for_status()
                        return PaymentResult.success(to_domain(resp.json()))
            except (OpenCircuitException, TimeoutError, httpx.HTTPError) as exc:
                return await self._register_async(order, exc)

    async def _register_async(self, order: Order, exc: Exception) -> PaymentResult:
        log.warning(
            "Sber unavailable, queuing for async retry",
            extra={"order_id": str(order.id), "error": str(exc)},
        )
        task_id = await self._task_queue.enqueue(to_register_task(order))
        return PaymentResult.queued(task_id)
# api/v1/payment_router.py
@router.post("/orders/{order_id}/payment", status_code=200)
async def register_payment(order_id: UUID, handler: RegisterPaymentHandler = Depends()):
    result = await handler.handle(RegisterPaymentCommand(order_id=order_id))
    if result.is_queued:
        return JSONResponse(
            status_code=202,
            content={"status": "queued", "task_id": str(result.task_id)},
            headers={"Location": f"/orders/{order_id}/payment/status"},
        )
    return {"status": "ok", "payment_ref": str(result.payment_ref)}

Что критично:

  • PaymentResult.queued(...) — отдельный вариант, не PaymentResult.success(...). Контроллер маппит его в 202 Accepted.
  • Task в БД переживает рестарт сервиса. Scheduler позже дёрнет Sber повторно.
  • Клиент знает, что результат не финальный, и периодически опрашивает статус. Подробно — в Async и polling.

Контракт fallback в Python

R-RES-FB-2: в отличие от Java, в Python нет отдельного fallbackMethod с магической сигнатурой. Fallback — это явная ветка except в том же методе адаптера.

async def register(self, order: Order) -> PaymentResult:
    async with self._sem:
        try:
            async with self._breaker:
                async with asyncio.timeout(self._settings.total):
                    resp = await self._client.post("/register", json=to_sber_request(order))
                    resp.raise_for_status()
                    return PaymentResult.success(to_domain(resp.json()))
        except OpenCircuitException as exc:
            # CB открыт — система явно лежит, сразу в task-queue
            task_id = await self._task_queue.enqueue(to_register_task(order))
            return PaymentResult.queued(task_id)
        except (TimeoutError, httpx.HTTPError) as exc:
            # Транзиентный сбой — пробросить вверх, не маскируем
            raise PaymentPortError.system_unavailable("sber") from exc

Варианты по точности:

  • except OpenCircuitException — только когда CB открыт (пургаторий / aiobreaker). Самый точечный: знаем, что система явно лежит.
  • except (TimeoutError, httpx.HTTPError) — транзиентные ошибки сети. Часто правильнее пробросить вверх, чем маскировать.
  • Широкий except Exception — только если есть явная причина ловить всё. По умолчанию — не надо.

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

Fallback с None / Money(amount=0) для money

R-RES-FB-X1: возврат «нулевого» значения за money-операцию = бизнес-баг.

# ПЛОХО — возврат Money(0) при отказе
async def get_balance(self, account_id: AccountId) -> Money:
    try:
        async with self._breaker:
            resp = await self._client.get(f"/accounts/{account_id.value}/balance")
            return Money(amount=resp.json()["amount"], currency="RUB")
    except Exception:
        return Money(amount=0, currency="RUB")  # ← клиент видит «баланс = 0»

Что не так:

  • Customer видит «баланс = 0» вместо реальной суммы. Принимает решения на ложных данных.
  • UI пытается отклонить платёж за «нехватку средств» — реально на счету 50 000 ₽.

Корректно: Optional[Money] с явным None (UI покажет «временно недоступно»), либо исключение → HTTP 503.

async def get_balance(self, account_id: AccountId) -> Optional[Money]:
    try:
        async with self._breaker:
            resp = await self._client.get(f"/accounts/{account_id.value}/balance")
            return Money(amount=resp.json()["amount"], currency="RUB")
    except OpenCircuitException as exc:
        log.warning("balance unavailable", extra={"account_id": account_id.value})
        return None   # UI покажет «Баланс временно недоступен»

Тихий fallback с success

R-RES-FB-X2: fallback, который проглатывает ошибку и возвращает «как будто всё ОК».

# ПЛОХО — залогировали и забыли
async def send_notification(self, cmd: NotificationCommand) -> None:
    try:
        await self._client.post("/notifications", json=to_request(cmd))
    except Exception as exc:
        log.warning("notification failed", extra={"error": str(exc)})
        # ← caller думает, что нотификация ушла

Что не так: вызывающий код считает, что нотификация ушла. Через сутки — жалоба «не получили SMS». Расследование начинается с нуля.

Корректно: явный возврат NotificationResult.queued(task_id) или NotificationResult.failed(reason), либо raise.

async def send_notification(self, cmd: NotificationCommand) -> NotificationResult:
    try:
        async with self._breaker:
            resp = await self._client.post("/notifications", json=to_request(cmd))
            resp.raise_for_status()
            return NotificationResult.sent()
    except (OpenCircuitException, TimeoutError, httpx.HTTPError) as exc:
        task_id = await self._task_queue.enqueue(to_notification_task(cmd))
        return NotificationResult.queued(task_id)

Каскадный fallback в другой провайдер без CB

R-RES-FB-X3: fallback, делающий outbound в другую систему — это новый outbound, который должен быть обёрнут своим CB.

# ПЛОХО — fallback в резервный провайдер без CB
async def register(self, order: Order) -> PaymentResult:
    async with self._sber_sem:
        try:
            async with self._sber_breaker:
                return await self._call_sber(order)
        except OpenCircuitException:
            # Sber лёг — идём в Yoomoney. Если Yoomoney тоже лежит — нет защиты
            return await self._yoomoney_client.post("/register", json=to_yoomoney(order))

Сценарий: Sber лёг, CB открыт, идём в Yoomoney. Yoomoney тоже лежит (например, общая сетевая проблема). Каждый запрос висит на Yoomoney total_timeout. Cascading failure.

Корректно — каждый провайдер через свой адаптер со своим CB:

# adapters/out/sber/sber_adapter.py — только Sber, свой CB
# adapters/out/yoomoney/yoomoney_adapter.py — только Yoomoney, свой CB

# use_cases/register_payment_handler.py
async def handle(self, cmd: RegisterPaymentCommand) -> PaymentResult:
    try:
        return await self._payment_port.register(order)   # SberAdapter со своим CB
    except PaymentPortError.SystemUnavailable:
        try:
            return await self._backup_port.register(order)  # YoomoneyAdapter со своим CB
        except PaymentPortError.SystemUnavailable as exc:
            task_id = await self._task_queue.enqueue(to_register_task(order))
            return PaymentResult.queued(task_id)

YoomoneyAdapter — отдельный @class со своим AsyncCircuitBreaker(name="yoomoney"). При обоих лежащих — третий уровень fallback в task-queue.

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

АнтипаттернПравилоЧто взамен
Fallback с None / Money(amount=0) для moneyR-RES-FB-X1Optional / exception / task-queue
Тихий fallback (залогировали и забыли)R-RES-FB-X2Явный возврат queued / failed или raise
Каскадный fallback в другой провайдер без CBR-RES-FB-X3Каждый провайдер — свой AsyncCircuitBreaker
Fallback success для write при отказеR-RES-FB-1queued + 202 Accepted + task-queue
Широкий except Exception без разбора типаR-RES-FB-2Отдельные ветки для OpenCircuitException и транзиентных ошибок

Куда дальше

  • Resilience → раздел 7. Fallback — нормативные R-RES-FB-*.
  • Async и polling — task-queue с 202 Accepted.
  • Circuit Breaker — когда OpenCircuitException триггерит fallback.
  • Bulkhead — asyncio.Semaphore при исчерпании тоже требует fallback-стратегии.
  • Retry — tenacity.RetryError как сигнал для перехода в fallback.
  • Observability — метрики CB-state, логирование state-transition.