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

> Как мы построили систему resilience для edge functions: circuit breaker, retry с jitter, fail-open rate limiting. Реальный код из продакшена.
> Author: Roman Belov · Published: 2026-02-23 · Source: https://futurecraft.pro/ru/blog/circuit-breaker-deno-edge-functions/

Пользователь нажимает «Найти кафе поблизости». 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) |
|---|---|---|
| Latency | 0 ms | 1-5 ms |
| Shared | Только внутри одного изолята | Между всеми изолятами |
| Надёжность | Теряется при cold start | Персистентное |
| Сложность | Минимальная | Требует Redis-клиента |

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

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

### Реализация

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

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

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

```typescript
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`, который оборачивает операцию:

```typescript
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()`:

```typescript
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 полностью работоспособен.

```typescript
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), таймауты и сетевые проблемы.

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

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

```typescript
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.

### Формула

```typescript
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` мы ввели контракт: ошибка сама говорит, можно ли её повторить.

```typescript
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** — для платежей и аутентификации вроде [JWT-цепочки Appwrite → Supabase](/ru/blog/dual-auth-appwrite-supabase/). Минимум попыток, короткие задержки, jitter отключён (детерминированное поведение для аудита). Retry только на 503 и 504.

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

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

```typescript
// 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-запроса

```typescript
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`:

```typescript
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` блок очищает таймер. Без этого при успешной операции таймер продолжит жить в памяти изолята, создавая утечку.

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

| Тип | Значение | Почему |
|-----|----------|--------|
| FAST | 3 с | Геокодинг, обращение к кэшу |
| DATABASE | 5 с | Запросы к Supabase Postgres |
| EXTERNAL_API | 10 с | Foursquare, Google, Tinkoff |
| LLM | 60 с | Генерация текста, AI-чат |

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

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

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

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

```typescript
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 уже открылся (другие запросы тоже фейлились) — мгновенный отказ.

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

```typescript
// Без 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:

```typescript
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-ов:

```typescript
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-клиент использует все три паттерна:

```typescript
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. Позже мы упаковали этот модуль в [stdlib для Supabase Edge Functions](/ru/blog/supabase-edge-toolkit-stdlib/).

## Итого

Каскадные отказы в 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. Ноль зависимостей. Вместо двенадцати минут деградации — минута открытого circuit и чистый fail fast.

---

*Нужна помощь с архитектурой отказоустойчивости для edge functions? Я помогаю стартапам внедрять AI-решения и строить продукты — [belov.works](https://belov.works).*
