Graceful degradation
Единая матрица: что делает сервис, когда одна из соседних зависимостей деградирует или отказывает. Без такой таблицы каждый handler решает сам — и результат на уровне системы непредсказуем: один endpoint возвращает 503, другой зависает, третий отдаёт пустой массив без пометки.
Ссылки на механику реализации: Redis — caching +
../how-to/handle-redis-outage; Kafka
— events; HTTP — ../patterns/retry-and-circuit-breaker
и ../patterns/api-composition#partial-failure;
DB и retention — db-pgx, data-retention.
Эта страница отвечает на вопрос «что на уровне контракта сервиса
пользователю», не «как это реализовано».
Содержание
- Принципы
- Матрица сценариев
- Redis down
- Kafka down
- Postgres primary down
- Postgres replica lag
- Gateway недоступен
- Один downstream сервис down
- Degradation signalling
- Что НЕ делать
- Связанные разделы
Принципы
- Fail-open vs fail-closed — осознанный выбор per-операция, не per-сервис. Нельзя сказать «сервис fail-open» — каждая Redis-операция, каждый downstream-вызов, каждый fallback принимает решение отдельно. Дефолт — в таблице ниже.
- Mandatory vs optional данные. Для composition’ов (см.
../patterns/api-composition#partial-failure) mandatory определяет, можно ли ответить 200 с частичными данными или нужно 503. - Контракт выдержан. 503 с
Retry-Afterлучше, чем 200 с{"data": null}без пометки. Клиент должен различать «данных нет» и «сервис лежит». - Нет тихих дублей / потерь. Fail-open на dedup-слое допустим
только при DB-level идемпотентности handler’а (см.
../patterns/idempotent-consumer#fail-open-vs-fail-closed). - Каждая деградация — наблюдаемая. Счётчик + alert, не «мы перешли в degraded, но никто не знает».
Матрица сценариев
«Dflt» — дефолтное поведение; «Override» — когда осознанно меняется per-endpoint.
| Сбой | Класс операции | Dflt | Override | Сигнал клиенту |
|---|---|---|---|---|
| Redis down | Cache-aside read | fail-open → БД | stale-LRU fallback (read-heavy) | 200, latency растёт |
| Rate-limit | fail-open | fail-closed: /auth/*, /payments/* | 200 (или 503 при override) | |
| Consumer dedup | fail-closed | fail-open только с DB-level UPSERT + CB-bypass метрикой | — (фон, видно в DLQ/alerts) | |
| Idempotency-key (POST) | fail-closed | — | 503 + Retry-After | |
| Distributed lock | fail-closed | — | 503 | |
| Kafka down | Publish через outbox | backlog копится | — | 200, событие уйдёт позже |
| Consumer read | consumer stuck | — | — (фон, alert на lag) | |
| Forwarder | retry до recovery | — | 200 для HTTP; downstream видит lag | |
| Postgres primary down | read hot-path | 503 / failover на read-replica (write-path всё равно 503) | stale-cache fallback | 503 + Retry-After |
| write | 503 | — | 503 + Retry-After | |
| migration runner | pod не стартует | — | /readyz = 503, pod не принимает трафик | |
| Postgres replica lag > N | read from replica | fallback на primary | отдавать stale с X-Data-Stale: true | 200 |
| follower-replica для CQRS projection | projection freshness SLO горит | — | — (фон, alert) | |
| Gateway недоступен | /v1/* | клиент 0 запросов получает | — | клиент видит connect error от Gateway |
/internal/* | не затронуто | — | — | |
| JWT refresh | клиент логаутит после expiry | — | 401 от Gateway при восстановлении | |
| Один downstream сервис down | composition (optional поле) | partial.missing[] | — | 200 + partial block |
| composition (mandatory) | 503 | — | 503 + Retry-After | |
| event handler (consumer) | retry → DLQ | — | — (фон, alert на DLQ) |
Полный разбор по каждому сценарию — ниже.
Redis down
Подробности — ../how-to/handle-redis-outage.
Сверху — краткое резюме контракта:
- Cache-aside. Все reader’ы автоматически идут в Postgres. Latency
деградирует в 3–20× (зависит от query), но функциональность есть.
Alert:
cache_hit_ratio < 0.3→ ticket, чтобы пойматьstampede-ситуацию и включить stale-LRU fallback, если он предусмотрен. - Rate-limit. Hot endpoint’ы (feed, search) — пропускают без
лимита (fail-open). Security-sensitive (
/auth/login,/auth/reset-password,/payments/*) — отвечают 503 сRetry-After: 5. - Consumer dedup. Handler’ы встают в retry-цикл → при
затяжном outage часть сообщений уходит в DLQ. Исключение —
handler’ы с DB-level идемпотентностью и явно включённым fail-open
через CB (см. правила
../patterns/idempotent-consumer). - Idempotency-key. 503 +
Retry-After. Не отвечаем 200 на retry, потому что не можем отличить повтор от первого запроса. - Distributed lock. 503. Альтернатива — advisory lock в
Postgres, не Redis (см.
../how-to/handle-redis-outage#distributed-lock).
Kafka down
«Down» здесь = broker недоступен ≥ 30 секунд. Кратковременные glitches (leader election) лечит retry Watermill-middleware и естественный replay outbox.
- Publisher (HTTP write). Бизнес-транзакция + запись в
outboxпроходят как обычно — это Postgres-only операция. Событие уйдёт в Kafka, когда forwarder сможет опубликовать. Клиент видит 200. - Outbox forwarder. Backlog копится, alert
OutboxBacklogGrowing/OutboxForwarderLagHighсрабатывают (см.slo-and-budget#outbox-forwarder). - Consumer. Handler’ы встают на
poll,kafka_consumer_lagрастёт. Alert:kafka_consumer_lag_messages > 1000. - Saga. In-flight саги не падают, но переходят в состояние
«ждёт ответа шага»; watchdog ловит по deadline (см.
../patterns/saga#deadline-и-timeout).
Контракт на пользователя: данные не теряются. Downstream-эффекты (уведомления, denormalized counters) появляются после восстановления.
Postgres primary down
Это worst-case. Сервис без своей БД обслуживать write-трафик не может.
- Write endpoint’ы.
503 + Retry-After: 10. Handler не должен возвращать 500 — это сигнал «наш сервис сломан», а реальная причина внешняя. - Read endpoint’ы. Зависит от конфигурации: если у сервиса есть
read-replica и чтение разрешено с неё — fallback на replica
(stale read). Отмечаем
X-Data-Stale: true(см. §Degradation signalling). Если replica недоступна/не используется — 503. - Stale cache. Если hit’нули в Redis — возвращаем (не отмечая специально: в момент cache’а данные были свежие). Это легитимный штатный режим для hot-path чтения.
/readyz. Становится 503 на всех pod’ах, Kubernetes снимает трафик. Это правильно: мы не хотим продолжать принимать трафик, когда БД недоступна.
Восстановление данных — через PITR/promote standby (см.
data-retention#backup-rpo-и-rto).
Postgres replica lag
Реплика отстаёт на N секунд (причины: длинная транзакция на primary, IO-throughput, network lag). Контракт сервиса зависит от того, как сервис использует replica.
- Сервис читает только с primary. Лаг реплики не влияет на пользователя; влияет только на backup / PITR window.
- Сервис использует replica для read-трафика. При
lag > 30s: либо переключаемся на primary (увеличивая нагрузку, но сохраняя свежесть), либо отдаём ответ с заголовкомX-Data-Stale: trueиX-Data-Lag-Seconds: <N>. Выбор per-endpoint, не глобальный. - CQRS projection, заполняемая через Kafka из другого сервиса.
Лаг публикуется как
projection_freshness_seconds; если он превышает SLO-таргет, projection-reader отмечает ответ как stale (см.../patterns/cqrs).
Gateway недоступен
Все /v1/* сервисы оказываются недостижимы извне. Backend-сервисы
продолжают работать:
/internal/*трафик между сервисами не затронут (не идёт через Gateway — см.../authentication-flow#internal-endpoints).- Outbox publish’и, consumer-handler’ы, saga — продолжают работать, backlog не растёт.
- JWT refresh клиентов фейлится (401/connect-error на mobile). После восстановления Gateway клиенты штатно refresh’нут и продолжат.
Контракт сервиса: ничего делать не надо. Диагностика и починка Gateway — в infra-репо, не в коде сервиса.
Alert на сервисе: up{service=~".+"} == 1 AND rate(http_requests_total[5m]) == 0
при обычно ненулевом трафике — «мы живы, но нам никто не стучит». Это
косвенный индикатор Gateway-проблемы, пишем ticket, не page.
Один downstream сервис down
Композитный сервис зависит от 2–4 downstream’ов (см.
../patterns/api-composition). Если
один из них недоступен:
- Optional downstream. Composer возвращает 200, поле =
null/[], имя вpartial.missing,partial.reason[field] = "unavailable"/"timeout"/"breaker_open". HTTP status — 200, не 206 (см. §Partial failure). - Mandatory downstream. 503 +
Retry-After. Handler НЕ пытается собрать «похожий» ответ из чего есть. - Circuit breaker open. Возвращается
ErrOpenStateмгновенно, без сетевого вызова — latency composition’а остаётся низкой (см.../patterns/retry-and-circuit-breaker#circuit-breaker).
Композитный сервис не пытается chain-retry’ить в downstream
вручную: за retry отвечает транспорт (см.
../patterns/retry-and-circuit-breaker#retry-для-http-клиента),
поверх неё composer только применяет partial policy.
Degradation signalling
Любой ответ, отдающийся в degraded-режиме, обязан сигнализировать клиенту. Без этого пользователь не отличит «данных нет» от «сервис деградирует», а мониторинг не видит деградацию на уровне ответа.
Способы сигнализации (в порядке приоритета):
- HTTP-статус — для полных отказов:
503 Service Unavailable+Retry-After: <seconds>. Никогда500на внешние деградации (500 = «баг в нашем коде»). partialblock в теле — для composition’ов с частичными данными (см.../patterns/api-composition#контракт-partial-failure-в-response).- Headers:
X-Data-Stale: true— ответ из cache/replica, который может отставать от primary.X-Data-Lag-Seconds: <N>— опционально, сколько секунд lag.Cache-Control: no-storeна всех partial / stale ответах — чтобы CDN и browser cache не закрепляли деградированные данные.
- Лог WARN — каждое срабатывание деградации логируется (с причиной), но один раз, не на каждое поле. Счётчик в Prometheus обязателен.
Клиент (mobile/web) обязан обрабатывать:
- 503 — retry с уважением
Retry-After, показать пользователю «временные неполадки». partial.missing— отрисовать плейсхолдеры вместо отсутствующих полей, не «белая дыра» в UI.X-Data-Stale— optional: либо скрыть индикатор «обновляется», либо показать «данные могут быть неактуальны».
Правила контракта фиксируются в API-reference endpoint’а сервиса и в mobile SDK.
Что НЕ делать
- Не возвращать 500 на внешние деградации. 500 = баг в нашем коде; 503 = одна из зависимостей не отвечает. Разные статусы — разные runbook’и у on-call.
- Не отвечать 200 с пустым
data: nullбезpartialблока. Клиент не отличит «нет данных» от «deps недоступны». - Не возвращать stub-значения (дефолтный рейтинг
0, пустая строка вместо имени). Пользователь увидит «официальную» цифру, которая на самом деле фейк. - Не делать бесконечные retry на деградированный downstream.
Retry-budget ≤ 5s (см.
../patterns/retry-and-circuit-breaker#budget-и-deadline), дальше — CB open, быстрый fallback. - Не скрывать деградацию от мониторинга. Счётчик
service_degraded_total{reason, op}обязателен на каждый сценарий деградации. - Не полагаться на Redis как primary store. Redis — ускоритель; write-state хранится в Postgres, иначе любая деградация Redis становится потерей данных.
- Не трогать fail-closed → fail-open без code-review owner’а. Одна строчка переключает контракт сервиса; такие правки — через явный PR с обоснованием, не hotfix.
Связанные разделы
../how-to/handle-redis-outage— пошаговые правила для Redis.../patterns/api-composition#partial-failure— partial-failure контракт.../patterns/retry-and-circuit-breaker— retry, CB, fallback на stale cache.../patterns/idempotent-consumer#fail-open-vs-fail-closed— dedup под Redis outage.slo-and-budget— SLO-таргеты для forwarder, consumer, freshness.data-retention#backup-rpo-и-rto— восстановление после Postgres primary down.../troubleshooting/failure-modes-matrix— разбор одновременных отказов.../troubleshooting/redis-unavailable— диагностика, когда Redis уже упал.