JourneyBay

Circuit Breaker в Deno Edge Functions: защита AI-пайплайна от каскадных отказов

Что такое паттерн circuit breaker?

Circuit breaker — это паттерн отказоустойчивости, который прекращает отправку запросов к упавшему сервису после превышения порога ошибок, давая системе восстановиться вместо нарастания нагрузки. Работает в трёх состояниях — CLOSED (норма), OPEN (блокировка вызовов), HALF-OPEN (проверка восстановления) — и применяется для предотвращения каскадных отказов в распределённых системах.

TL;DR

  • -12 минут простоя Foursquare убили AI-чат JourneyBay за 6 минут — классический каскадный отказ от retry storm
  • -Deno edge functions не имеют общего состояния: каждый изолят ретраит независимо, усиливая нагрузку на упавший API
  • -Circuit breaker на трёх состояниях (CLOSED/OPEN/HALF-OPEN) хранит стейт в памяти изолята — без Redis
  • -Exponential backoff + jitter предотвращают синхронизированные retry storm от параллельных изолятов
  • -Zero-dependency модуль устранил 95% каскадных инцидентов в продакшене

Пользователь нажимает «Найти кафе поблизости». AI-ассистент обращается к Foursquare за списком мест, к LLM за персонализированным описанием, к базе данных за предпочтениями. Три внешних вызова, три точки отказа.

Foursquare отвечает 503. Функция ждёт таймаута, повторяет запрос, снова ждёт. Параллельно идёт запрос маршрута, открывается AI-чат. Каждая edge function запускает свои retry-цепочки. За секунды одна ошибка внешнего API превращается в лавину зависших запросов и исчерпанные лимиты.

Мы поймали это на нагрузочном тестировании JourneyBay. Foursquare API отвалился на 12 минут. Без защиты retry storm за четыре минуты выбил rate-лимиты LLM-провайдера, а через шесть минут AI-чат перестал отвечать. Двенадцать минут простоя одного внешнего API каскадом положили всё, что от него не зависело.

Классический каскадный отказ. И классическое решение: resilience patterns. Только вот все руководства описывают их для Java (Resilience4j), Go или persistent Node.js-процессов. Для serverless TypeScript на Deno, где каждый запрос потенциально стартует в холодном изоляте, готовых рецептов нет.

Дальше — о трёх паттернах, которые мы адаптировали под Deno Edge Functions: circuit breaker, retry с экспоненциальным backoff и fail-open rate limiting. Компактный модуль без зависимостей, который закрыл 95% наших проблем с каскадными отказами.

Почему serverless ломается иначе

Deno Edge Functions устроены иначе: каждый запрос может попасть в свежий V8-изолят. Нет долгоживущего процесса, нет гарантий, что следующий запрос попадёт на тот же инстанс.

Три проблемы, которых нет в традиционных бэкендах.

Retry storm. Когда Java-сервер ретраит запрос, это один процесс, одна retry-цепочка. Когда 50 edge functions одновременно ретраят вызов к упавшему API, это 50 независимых цепочек. Каждая не знает о других. Каждая честно ждёт экспоненциальный backoff и снова шлёт запрос. Вместо восстановления API получает DDoS от собственного клиента.

Отсутствие shared state. Circuit breaker работает на знании: «за последние N запросов M упали». В монолите эта информация в памяти процесса. В serverless каждый изолят видит только свои запросы. Один изолят получил пять ошибок подряд и должен открыть circuit. Но следующий запрос приходит в другой изолят, который ещё ничего не знает.

Каскадный эффект. Edge function вызывает другую edge function через HTTP. Та вызывает третью. Если одна зависла, вся цепочка зависает. А таймаут по умолчанию у fetch в Deno не задан, то есть запрос может висеть до отмены самим runtime.

Готовые библиотеки вроде opossum создавались для persistent-процессов. Они хранят state в памяти и предполагают, что процесс живёт долго. В Deno-изоляте это не так. Нужна реализация, которая учитывает эфемерность среды.

Circuit Breaker: state machine в памяти изолята

Circuit breaker работает как автоматический выключатель в электрической сети. Если поток ошибок превышает порог, выключатель размыкается и перестаёт пропускать запросы. Через некоторое время он пробует пропустить один запрос, чтобы проверить, восстановился ли сервис.

Три состояния:

CLOSED ──(ошибки >= порог)──> OPEN ──(cooldown прошёл)──> HALF-OPEN
  ^                                                           |
  |                                                           |
  └──────────(успешный тестовый запрос)────────────────────────┘

HALF-OPEN ──(ошибка)──> OPEN   (снова размыкаемся)

CLOSED — штатная работа, запросы проходят. При каждой ошибке счётчик растёт. Успешный запрос сбрасывает счётчик в ноль.

OPEN — сервис недоступен, запросы отклоняются мгновенно. Функция не ждёт таймаута, не тратит ресурсы. Вместо этого сразу возвращает ошибку CircuitBreakerOpenError.

HALF-OPEN — пробный режим. Один запрос пропускается. Если успешен, circuit закрывается. Если нет, снова открывается.

Хранение состояния: компромисс

Ключевой вопрос: где хранить состояние circuit breaker, если изолят эфемерен?

Два варианта:

In-memory (Map)Внешнее хранилище (Redis)
Latency0 ms1-5 ms
SharedТолько внутри одного изолятаМежду всеми изолятами
НадёжностьТеряется при cold startПерсистентное
СложностьМинимальнаяТребует Redis-клиента

Мы выбрали in-memory. Причина: Deno-изоляты на Supabase переиспользуются между запросами, пока инстанс тёплый (warm instance). Пока изолят тёплый, state сохраняется между запросами. При cold start state теряется, но это приемлемо: если изолят перезапустился, вероятность того, что внешний API тоже восстановился, достаточно высока.

Для критичных сценариев (когда нужна координация между инстансами) мы используем Redis, но для circuit breaker оверхед на каждый запрос не оправдан.

Реализация

Состояние живёт в module-level Map:

const circuitRegistry = new Map<string, CircuitBreakerState>();

Каждый сервис получает свой circuit по имени. Два объекта CircuitBreaker.getOrCreate('foursquare') разделяют одно и то же состояние внутри изолята:

export class CircuitBreaker {
  private readonly name: string;
  private readonly config: CircuitBreakerConfig;

  static getOrCreate(
    name: string,
    config: CircuitBreakerConfig
  ): CircuitBreaker {
    if (!circuitRegistry.has(name)) {
      circuitRegistry.set(name, {
        state: 'closed',
        failureCount: 0,
        successCount: 0,
      });
    }
    return new CircuitBreaker(name, config);
  }
}

Ядро circuit breaker — метод call, который оборачивает операцию:

async call<T>(operation: () => Promise<T>): Promise<T> {
  this.checkStateTransition();

  const currentState = this.getState();

  if (currentState.state === 'open') {
    throw new CircuitBreakerOpenError(
      this.name,
      this.remainingTimeoutMs
    );
  }

  try {
    const result = await operation();
    this.onSuccess();
    return result;
  } catch (error) {
    this.onFailure(error);
    throw error;
  }
}

Когда circuit открыт, запрос отклоняется без вызова операции. Нет ожидания таймаута, нет повторных запросов к мёртвому API.

Lazy state transition

В Java-реализациях circuit breaker обычно использует таймер для перехода из OPEN в HALF-OPEN. В serverless таймер не нужен. Вместо этого проверка происходит лениво, при каждом вызове call():

private checkStateTransition(): void {
  const { state, openedAt } = this.getState();

  if (state === 'open' && openedAt) {
    const elapsed = Date.now() - openedAt;
    if (elapsed >= this.config.resetTimeoutMs) {
      this.transitionTo('half-open');
    }
  }
}

Если cooldown-период прошёл, circuit переходит в HALF-OPEN прямо при следующем вызове. Никаких setInterval, никаких утечек ресурсов.

4xx не трипают circuit

Частая ошибка: считать все ошибки провалами. Но 404 «Place not found» — это не сбой Foursquare. Это нормальный ответ на конкретный запрос. Если circuit откроется из-за 404, все запросы к Foursquare заблокируются, хотя API полностью работоспособен.

private shouldIgnoreError(error: unknown): boolean {
  const ignoredCodes = this.config.ignoredStatusCodes || [];

  if (error && typeof error === 'object') {
    const err = error as Record<string, unknown>;

    if (typeof err.statusCode === 'number'
        && ignoredCodes.includes(err.statusCode)) {
      return true;
    }

    if (typeof err.status === 'number'
        && ignoredCodes.includes(err.status)) {
      return true;
    }
  }

  return false;
}

Клиентские ошибки (400, 401, 403, 404) исключены из подсчёта. Circuit трипают только серверные ошибки (5xx), таймауты и сетевые проблемы.

Профили: один размер не подходит всем

Разные сервисы падают по-разному и требуют разного обращения. Мы создали преднастроенные профили:

static forExternalApi(name: string): CircuitBreaker {
  return CircuitBreaker.getOrCreate(name, CONFIGS.EXTERNAL_API);
}

static forLLM(name: string): CircuitBreaker {
  return CircuitBreaker.getOrCreate(name, CONFIGS.LLM);
}

static forPayment(name: string): CircuitBreaker {
  return CircuitBreaker.getOrCreate(name, CONFIGS.PAYMENT);
}

External API (Foursquare, Google Places)

Терпимый профиль. Эти API иногда отвечают 5xx на единичные запросы из-за балансировки нагрузки. Терпим несколько ошибок перед срабатыванием, быстро пробуем восстановить. Клиентские ошибки полностью игнорируются: 400, 401, 403, 404.

LLM (LightRAG, AI-чат)

Чувствительный профиль. LLM-сервисы восстанавливаются медленнее, поэтому cooldown длиннее. Порог срабатывания ниже: если LLM начал отдавать ошибки, лучше быстрее остановиться и сэкономить токены.

Payment (Tinkoff)

Консервативный профиль. Низкий порог, длинный cooldown. Но главное — retry-стратегия (об этом дальше). Платежи нельзя ретраить агрессивно: двойное списание хуже, чем отказ. Fail fast, попроси пользователя попробовать ещё раз.

Retry с jitter: как не устроить DDoS

Retry без ограничений — это DDoS на собственного провайдера. Retry с фиксированным интервалом — это скоординированный DDoS (все ретраят через одну секунду, через две, через четыре).

Решение: exponential backoff с jitter.

Формула

function calculateRetryDelay(
  attempt: number,
  config: RetryConfig
): number {
  const multiplier = config.backoffMultiplier ?? 2;

  // base * 2^(attempt-1): 1s, 2s, 4s, 8s...
  let delay = config.baseDelayMs * Math.pow(multiplier, attempt - 1);

  // Не больше максимума
  delay = Math.min(delay, config.maxDelayMs);

  // Jitter: случайное отклонение (×0.5..1.5)
  if (config.jitter) {
    const jitterFactor = 0.5 + Math.random(); // от 0.5 до 1.5
    delay = Math.round(delay * jitterFactor);
  }

  return delay;
}

Jitter-множитель от 0.5 до 1.5 — задержка может быть вдвое короче или в полтора раза длиннее оригинальной. Это «proportional jitter». AWS рекомендует full jitter (от 0 до максимума), но мы осознанно выбрали пропорциональный: при полном jitter задержка может схлопнуться до нуля, и тогда retry фактически происходит мгновенно.

isRetryable: ошибка решает сама

Не каждую ошибку стоит повторять. 400 Bad Request повторять бессмысленно, тело запроса не изменится. 429 Rate Limit повторять можно, если подождать.

Вместо гигантского switch/case мы ввели контракт: ошибка сама говорит, можно ли её повторить.

export class TimeoutError extends Error {
  get isRetryable(): boolean {
    return true; // Таймаут — временная проблема
  }
}

export class CircuitBreakerOpenError extends Error {
  get isRetryable(): boolean {
    return false; // Повторять бесполезно, circuit открыт
  }
}

Retry-модуль проверяет несколько уровней: HTTP-статус (429, 500, 502, 503, 504), строковый код ошибки ('TIMEOUT', 'RATE_LIMITED'), свойство isRetryable, имя класса ошибки и паттерны в сообщении. Если хоть один уровень говорит «можно повторить» — повторяем. Если ни один — пробрасываем ошибку сразу.

Стратегии по criticality

Одна retry-стратегия на все случаи — грубый инструмент. Платёж нельзя ретраить так же, как поиск мест.

Мы определили четыре профиля:

EXTERNAL_API — для Foursquare, Google Places. Умеренные параметры: несколько попыток, задержка от секунды, jitter включён. Коды для retry: 429, 500, 502, 503, 504.

LLM — для вызовов к LLM-провайдерам. Меньше попыток, длиннее начальная задержка (LLM-сервисы восстанавливаются медленно). Jitter включён.

CRITICAL — для платежей и аутентификации. Минимум попыток, короткие задержки, jitter отключён (детерминированное поведение для аудита). Retry только на 503 и 504.

AGGRESSIVE — для идемпотентных операций (индексация в RAG, обновление кэша). Много попыток, jitter включён. Безопасно ретраить, потому что повторный вызов не создаёт дубликатов.

Как клиент выбирает стратегию:

// Foursquare: умеренный retry, снижены попытки (платный API)
return withRetry(operation, {
  ...RETRY_CONFIGS.EXTERNAL_API,
  maxAttempts: 2,
});

// LightRAG insert: идемпотентная операция, можно ретраить агрессивно
return withRetry(operation, RETRY_CONFIGS.AGGRESSIVE);

// Tinkoff payment: fail fast, минимум ретраев
return withRetry(operation, RETRY_CONFIGS.CRITICAL);

Foursquare осознанно снижает число попыток по сравнению с дефолтом: каждый API-вызов стоит денег. А LightRAG insert использует агрессивный retry: вставка в базу знаний идемпотентна, повторная запись того же документа ничего не сломает.

Timeout: AbortController вместо Promise.race

Fetch в Deno не имеет встроенного таймаута. Без явного ограничения запрос может висеть до таймаута платформы (request idle timeout у Supabase — 150 секунд, wall-clock limit — до 400). Один зависший вызов блокирует всю функцию.

Две реализации для разных случаев.

fetchWithTimeout: правильная отмена HTTP-запроса

async function fetchWithTimeout(
  url: string | URL,
  options: RequestInit = {},
  timeoutMs: number = DEFAULT_TIMEOUTS.EXTERNAL_API
): Promise<Response> {
  const controller = new AbortController();
  const timeoutId = setTimeout(
    () => controller.abort(),
    timeoutMs
  );

  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal,
    });
    return response;
  } catch (error) {
    if (error instanceof Error && error.name === 'AbortError') {
      throw new TimeoutError('fetch', timeoutMs);
    }
    throw error;
  } finally {
    clearTimeout(timeoutId);
  }
}

AbortController не просто отменяет ожидание, он прерывает TCP-соединение. Promise.race для сравнения просто перестаёт ждать: fetch продолжает висеть в фоне, потребляя ресурсы изолята.

withTimeout: для non-fetch операций

Для операций, не использующих fetch (запросы к базе данных, внутренние вызовы), используем Promise.race:

async function withTimeout<T>(
  promise: Promise<T>,
  timeoutMs: number,
  operation = 'unknown'
): Promise<T> {
  let timeoutId: number | undefined;

  const timeoutPromise = new Promise<never>((_, reject) => {
    timeoutId = setTimeout(
      () => reject(new TimeoutError(operation, timeoutMs)),
      timeoutMs
    );
  });

  try {
    return await Promise.race([promise, timeoutPromise]);
  } finally {
    if (timeoutId !== undefined) {
      clearTimeout(timeoutId);
    }
  }
}

finally блок очищает таймер. Без этого при успешной операции таймер продолжит жить в памяти изолята, создавая утечку.

Таймауты по типам сервисов

ТипЗначениеПочему
FAST3 сГеокодинг, обращение к кэшу
DATABASE5 сЗапросы к Supabase Postgres
EXTERNAL_API10 сFoursquare, Google, Tinkoff
LLM60 сГенерация текста, AI-чат

LLM получает минуту: генерация длинного ответа занимает 10-30 секунд, а при нагрузке провайдер отвечает ещё медленнее. Зато для геокодинга три секунды достаточно: если Mapbox не ответил за три секунды, он скорее всего не ответит вообще.

TimeoutError помечен как isRetryable = true. После таймаута retry-модуль автоматически повторит запрос, если стратегия позволяет.

Три слоя вместе: resilientFetch

Отдельные паттерны работают хорошо, но вместе закрывают все сценарии. resilientFetch комбинирует три слоя в одном вызове:

async function resilientFetch(
  url: string | URL,
  init?: RequestInit,
  config?: Partial<ResilientFetchConfig>
): Promise<Response> {
  const serviceName = config?.serviceName ?? 'unknown';

  // Собрать circuit breaker и retry config
  const breaker = CircuitBreaker.getOrCreate(serviceName, cbConfig);

  const operation = async (): Promise<Response> => {
    const response = await fetchWithTimeout(url, init, timeoutMs);

    if (response.status >= 500) {
      const error = new Error(`HTTP ${response.status}`);
      (error as any).statusCode = response.status;
      throw error;
    }

    return response;
  };

  const withCircuitBreaker = () => breaker.call(operation);

  return withRetry(withCircuitBreaker, retryConfig);
}

Порядок слоёв критичен: retry снаружи, circuit breaker внутри, timeout на самом нижнем уровне.

Почему именно так:

  1. fetchWithTimeout ограничивает время одного HTTP-запроса
  2. circuitBreaker.call() оборачивает fetch: если circuit открыт, мгновенно бросает CircuitBreakerOpenError
  3. withRetry оборачивает circuit breaker: если операция упала с retryable-ошибкой, повторяет

Ключевой момент: CircuitBreakerOpenError.isRetryable = false. Когда circuit открыт, retry не пытается повторить запрос. Ошибка мгновенно всплывает до вызывающего кода. Без этой связки retry мог бы несколько раз вызвать открытый circuit breaker, каждый раз получая мгновенную ошибку — бессмысленная трата времени.

А TimeoutError.isRetryable = true. Если запрос завершился таймаутом (возможно, сервис перегружен), retry повторит через backoff. Но если при повторе circuit уже открылся (другие запросы тоже фейлились) — мгновенный отказ.

Один вызов вместо ручной обвязки из трёх слоёв:

// Без resilientFetch
const response = await withRetry(
  () => circuitBreaker.call(
    () => fetchWithTimeout(url, options, 10000)
  ),
  RETRY_CONFIGS.EXTERNAL_API
);

// С resilientFetch
const response = await resilientFetch(url, options, {
  serviceName: 'foursquare',
});

Rate Limiting: fail-open design

Circuit breaker защищает от каскадных отказов. Rate limiter защищает от перегрузки. Две разные проблемы, два разных инструмента.

Наш rate limiter работает на трёх уровнях:

  1. IP RPM — ограничение по IP для анонимных запросов
  2. User RPM — ограничение по requests/minute для аутентифицированных пользователей (значение из плана подписки)
  3. User TPM — ограничение по tokens/minute для AI-эндпоинтов

Лимиты для User RPM и TPM хранятся в базе данных (таблица plans) и зависят от подписки пользователя. IP-лимит фиксирован и одинаков для всех анонимных запросов.

Redis как хранилище

В отличие от circuit breaker, rate limiter должен быть shared: не имеет смысла ограничивать пользователя в рамках одного изолята, если он может отправить запросы в другой. Поэтому здесь Redis.

Ключи хранятся по паттерну:

rate_limit:ip:{ip}:rpm
rate_limit:user:{userId}:rpm
rate_limit:user:{userId}:tpm

Окно фиксированное (60 секунд), а не скользящее. Скользящее точнее, но требует хранения всех timestamp-ов. Для rate limiting достаточно fixed window с INCR + EXPIRE.

Fail-open: Redis лёг — запрос проходит

Самое важное архитектурное решение в rate limiter: что делать, когда Redis недоступен?

Два варианта:

  • Fail-closed: Redis лёг — блокируем все запросы. Безопасно, но пользователи видят ошибки, хотя сервис работает.
  • Fail-open: Redis лёг — пропускаем все запросы. Рискованно (можно проехать лимит), но сервис продолжает работать.

Мы выбрали fail-open:

async checkIpLimit(ipAddress: string): Promise<RateLimitCheckResult> {
  try {
    const { exceeded } = await this.redis.checkLimit(key, limit, window);
    return { allowed: !exceeded, /* ... */ };
  } catch (error) {
    console.error('[RateLimiter] IP limit check failed:', error);
    // Redis недоступен — пропускаем запрос
    return { allowed: true, /* ... */ };
  }
}

Логика: rate limit защищает от abuse, не от штатной нагрузки. Если Redis лёг на минуту, пользователи превысят лимит максимум на 60 секунд. Это неприятно, но лучше, чем полный стоп для всех. А блокировка легитимных пользователей из-за инфраструктурного сбоя — это уже не rate limiting, это denial of service на себя самих.

Fail-open применяется на каждом из трёх уровней. Если упала проверка IP — пропускаем. Если не прочитались лимиты из базы — пропускаем. Если Redis не ответил на INCR — пропускаем.

Sliding window для клиентских API

У внешних API (Foursquare, Google Places) свои rate limits. Мы не хотим упираться в них и получать 429. Вместо этого ограничиваем себя на клиенте.

Для этого в клиентах используется in-memory sliding window — массив timestamp-ов:

class RateLimiter {
  private timestamps: number[] = [];

  async checkAndWait(): Promise<void> {
    const now = Date.now();

    // Убрать запросы за пределами окна
    this.timestamps = this.timestamps.filter(
      ts => now - ts < this.windowMs
    );

    if (this.timestamps.length >= this.maxRequests) {
      const oldestTs = this.timestamps[0];
      const waitTime = this.windowMs - (now - oldestTs) + 100;

      if (waitTime > 0) {
        await new Promise(r => setTimeout(r, waitTime));
      }
    }

    this.timestamps.push(Date.now());
  }
}

Это не Redis — это in-memory лимитер, живущий внутри изолята. Он не заменяет серверный rate limiting, а дополняет: если клиент знает, что Foursquare даёт N запросов в минуту, лучше притормозить локально, чем получать 429 и тратить retry на повторы.

Как всё соединяется: пример клиента

Вот как Foursquare-клиент использует все три паттерна:

export class FoursquareClient {
  private readonly rateLimiter: RateLimiter;
  private readonly circuitBreaker: CircuitBreaker;

  constructor() {
    this.rateLimiter = new RateLimiter(MAX_REQUESTS_PER_MINUTE);
    this.circuitBreaker = CircuitBreaker.forExternalApi('foursquare');
  }

  private async request<T>(
    endpoint: string,
    params?: Record<string, string>
  ): Promise<T> {
    // 1. Локальный rate limit — не превысить лимит API
    await this.rateLimiter.checkAndWait();

    // 2. Circuit breaker — fail fast если API недоступен
    return this.circuitBreaker.call(async () => {

      // 3. Retry с backoff — повторить при transient ошибках
      return withRetry(async () => {

        // 4. Timeout — не ждать бесконечно
        const response = await fetchWithTimeout(
          url,
          { headers: { Authorization: `Bearer ${apiKey}` } },
          DEFAULT_TIMEOUTS.EXTERNAL_API
        );

        if (!response.ok) {
          this.handleError(response);
        }

        return response.json();
      }, {
        ...RETRY_CONFIGS.EXTERNAL_API,
        maxAttempts: 2,
      });
    });
  }
}

Последовательность при запросе:

  1. rateLimiter.checkAndWait() — если превышен лимит, подождать
  2. circuitBreaker.call() — если circuit открыт, мгновенная ошибка
  3. withRetry() — если запрос упал с retryable-ошибкой, повторить с backoff
  4. fetchWithTimeout() — если ответа нет за N секунд, таймаут

При ошибке handleError бросает типизированные исключения. FoursquareException с кодом 'RATE_LIMITED' или 'SERVER_ERROR' помечены как isRetryable = true. С кодом 'NOT_FOUND' или 'BAD_REQUEST' — не ретраятся. И, благодаря ignoredStatusCodes, не трипают circuit breaker.

Что мы узнали

In-memory state в изоляте работает лучше, чем ожидали. При стабильной нагрузке изолят остаётся тёплым, и circuit breaker накапливает состояние. При наплыве запросов Supabase может поднять несколько изолятов, каждый со своим state, но это допустимо: worst case — несколько лишних запросов к уже упавшему API до срабатывания circuit.

4xx в circuit breaker — самая частая ошибка при внедрении. Первая версия считала все ошибки. Circuit открывался, когда пользователи искали несуществующие места (404) или отправляли невалидные запросы (400). Пришлось разделить «сервис сломан» и «клиент отправил плохой запрос».

Jitter критичен при высокой concurrency. Без jitter мы видели спайки: все retry-и приходили одновременно, создавая мини-перегрузку каждые 2-4 секунды. С jitter нагрузка распределяется равномерно.

Fail-open — контринтуитивен, но правилен. Первый инстинкт: при проблемах заблокировать всё. Но в serverless «заблокировать» означает отказать всем пользователям. Fail-open допускает временное превышение лимитов, но сохраняет работоспособность.

Мониторинг transitions важнее мониторинга ошибок. Каждый переход circuit breaker логируется: closed → open, open → half-open, half-open → closed. По этим переходам строится timeline: когда сервис начал деградировать, как быстро восстановился, сколько раз дёрнулся.

Компактного модуля хватает. Весь resilience-модуль (circuit breaker + retry + timeout + типы) — четыре файла без единой внешней зависимости. Deno даёт нативный AbortController, нативный fetch, нативный setTimeout. Этого достаточно для полной реализации, которая работает в продакшене с 70+ edge functions.

Итого

Каскадные отказы в serverless — обычное дело. Один упавший API и десяток параллельных retry кладут всю систему. Circuit breaker, retry с jitter и fail-open rate limiting решают это на уровне архитектуры, а не героических дежурств.

Resilience patterns из мира Java работают и в Deno, если учесть эфемерность изолятов. In-memory state для circuit breaker. Lazy transitions вместо таймеров. Fail-open вместо fail-closed. isRetryable-контракт вместо хардкода статус-кодов.

После внедрения тот же сценарий на нагрузочном тесте: двенадцатиминутный простой Foursquare API — это минута открытого circuit и мгновенные отказы вместо зависших запросов. Вместо бесконечного спиннера — «Сервис временно недоступен». AI-чат продолжает работать, потому что LLM-circuit не связан с Foursquare-circuit. Остальные функции не замечают проблемы.

Четыре файла TypeScript. Ноль зависимостей. Вместо 30 минут деградации — минута открытого circuit и чистый fail fast.

FAQ

Сколько изолятов могут одновременно использовать одно состояние circuit breaker?

Ни одного — in-memory состояние ограничено одним Deno-изолятом. Supabase при нагрузке может запустить 5, 10 или 50 параллельных инстансов, и каждый стартует с чистым circuit breaker. На практике это означает, что упавший API получает короткий всплеск лишних запросов до того, как каждый новый изолят сам «протрипает» свой circuit — как правило, после 3–5 ошибок на изолят. Этот компромисс приемлем: альтернатива (Redis-запрос на каждый вызов с задержкой 1–5 мс) добавляет латентность ко всем вызовам, даже когда ничего не ломается.

Почему в профиле CRITICAL для платёжных операций отключён jitter?

Jitter вносит недетерминированность в таймлайн ретраев, что усложняет аудит и сверку. С включённым jitter невозможно точно предсказать момент второй попытки — в логах появляется «~2–4 секунды» вместо точного «2.0 секунды». Для платёжных систем журналы должны быть воспроизводимыми, а детерминированная последовательность ретраев (1 с, 2 с) проще соотносится с банковскими транзакционными логами. Отключение jitter — осознанный обмен равномерного распределения нагрузки на аудитопригодность.

В чём практическая разница между мониторингом переходов circuit breaker и мониторингом частоты ошибок?

Всплески error rate говорят о том, что что-то сломано прямо сейчас. Логи переходов дают таймлайн: когда началась деградация (closed → open), сколько времени занял recovery (open → half-open), стабилизировался ли сервис или продолжает дёргаться (half-open → open повторно). Во время Foursquare-инцидента в JourneyBay логи переходов показали открытие circuit за 47 секунд до того, как error rate пересёк порог алерта — давая 47-секундную фору, которую чистый мониторинг ошибок бы упустил.