Опирается на правила:
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 read —
list_productsпри отказе CDN возвращает копию из Redis с пометкойstale.- Default value —
get_recommendationsвозвращает[], когда отсутствие данных — норма с точки зрения бизнеса.- Async-mode write —
register_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) для money | R-RES-FB-X1 | Optional / exception / task-queue |
| Тихий fallback (залогировали и забыли) | R-RES-FB-X2 | Явный возврат queued / failed или raise |
| Каскадный fallback в другой провайдер без CB | R-RES-FB-X3 | Каждый провайдер — свой AsyncCircuitBreaker |
Fallback success для write при отказе | R-RES-FB-1 | queued + 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.