Skip to Content
ConventionsGraceful degradation

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. Эта страница отвечает на вопрос «что на уровне контракта сервиса пользователю», не «как это реализовано».

Содержание

Принципы

  1. Fail-open vs fail-closed — осознанный выбор per-операция, не per-сервис. Нельзя сказать «сервис fail-open» — каждая Redis-операция, каждый downstream-вызов, каждый fallback принимает решение отдельно. Дефолт — в таблице ниже.
  2. Mandatory vs optional данные. Для composition’ов (см. ../patterns/api-composition#partial-failure) mandatory определяет, можно ли ответить 200 с частичными данными или нужно 503.
  3. Контракт выдержан. 503 с Retry-After лучше, чем 200 с {"data": null} без пометки. Клиент должен различать «данных нет» и «сервис лежит».
  4. Нет тихих дублей / потерь. Fail-open на dedup-слое допустим только при DB-level идемпотентности handler’а (см. ../patterns/idempotent-consumer#fail-open-vs-fail-closed).
  5. Каждая деградация — наблюдаемая. Счётчик + alert, не «мы перешли в degraded, но никто не знает».

Матрица сценариев

«Dflt» — дефолтное поведение; «Override» — когда осознанно меняется per-endpoint.

СбойКласс операцииDfltOverrideСигнал клиенту
Redis downCache-aside readfail-open → БДstale-LRU fallback (read-heavy)200, latency растёт
Rate-limitfail-openfail-closed: /auth/*, /payments/*200 (или 503 при override)
Consumer dedupfail-closedfail-open только с DB-level UPSERT + CB-bypass метрикой— (фон, видно в DLQ/alerts)
Idempotency-key (POST)fail-closed503 + Retry-After
Distributed lockfail-closed503
Kafka downPublish через outboxbacklog копится200, событие уйдёт позже
Consumer readconsumer stuck— (фон, alert на lag)
Forwarderretry до recovery200 для HTTP; downstream видит lag
Postgres primary downread hot-path503 / failover на read-replica (write-path всё равно 503)stale-cache fallback503 + Retry-After
write503503 + Retry-After
migration runnerpod не стартует/readyz = 503, pod не принимает трафик
Postgres replica lag > Nread from replicafallback на primaryотдавать stale с X-Data-Stale: true200
follower-replica для CQRS projectionprojection freshness SLO горит— (фон, alert)
Gateway недоступен/v1/*клиент 0 запросов получаетклиент видит connect error от Gateway
/internal/*не затронуто
JWT refreshклиент логаутит после expiry401 от Gateway при восстановлении
Один downstream сервис downcomposition (optional поле)partial.missing[]200 + partial block
composition (mandatory)503503 + 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-режиме, обязан сигнализировать клиенту. Без этого пользователь не отличит «данных нет» от «сервис деградирует», а мониторинг не видит деградацию на уровне ответа.

Способы сигнализации (в порядке приоритета):

  1. HTTP-статус — для полных отказов: 503 Service Unavailable + Retry-After: <seconds>. Никогда 500 на внешние деградации (500 = «баг в нашем коде»).
  2. partial block в теле — для composition’ов с частичными данными (см. ../patterns/api-composition#контракт-partial-failure-в-response).
  3. Headers:
    • X-Data-Stale: true — ответ из cache/replica, который может отставать от primary.
    • X-Data-Lag-Seconds: <N> — опционально, сколько секунд lag.
    • Cache-Control: no-store на всех partial / stale ответах — чтобы CDN и browser cache не закрепляли деградированные данные.
  4. Лог 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.

Связанные разделы

Last updated on