Все статьи🇷🇺 Русский
14 мин

Rate Limiting и ISR: Как избежать 429 ошибок при работе с внешними API в Next.js

Комплексное решение проблемы превышения лимитов внешних API в Next.js: rate limiter с очередью, retry с exponential backoff, ISR и graceful degradation. Реальный опыт команды.

next.jsrate-limitingisrapioptimizationretryperformance

Проблема

Вы интегрировали внешний API в Next.js приложение. Локально всё работает отлично. Деплоите на production, и вдруг:

Error: API error: 429 Too Many Requests
Слишком много запросов, превышен лимит: 20/Second

Ошибка повторяется десятки раз. Пользователи видят пустые страницы. Команда в панике ищет причину.

Знакомо? Давайте разберем, почему это происходит и как решить проблему системно.

Наш кейс

Недавно наша команда столкнулась с похожей ситуацией при разработке e-commerce платформы:

Архитектура:

  • Next.js 16 с App Router
  • Внешний API для каталога товаров (лимит: 20 запросов/секунду)
  • SSR для всех страниц товаров и категорий
  • Две локали (русский и английский)
  • Множество категорий с подкатегориями

Что произошло:

После деплоя и первого обращения к странице категории:

GET /shop/category-1  →  Next.js начинает рендеринг
                      →  Запрос товаров основной категории
                      →  Запросы товаров 8 подкатегорий ПАРАЛЛЕЛЬНО
                      →  Умножаем на 2 локали
                      →  = ~20 запросов за доли секунды
                      →  429 Error! ❌

Первый пользователь увидел ошибки. Плохой старт для production.

Почему так произошло?

1. Next.js генерирует много страниц при первом запросе

При SSR и первом обращении Next.js может одновременно рендерить:

  • Разные локали (/en/shop, /ru/shop)
  • Связанные страницы (prefetch для навигации)
  • Metadata для SEO

2. Параллельные запросы к API

Наш код выглядел так:

// ❌ Проблемный код
const subcategories = await getSubcategories()

const allPromises = subcategories.map(sub => 
  fetchProducts(sub.id) // Все запросы одновременно!
)

const results = await Promise.all(allPromises)
// 10 подкатегорий = 10 параллельных запросов = превышение лимита

3. Отсутствие rate limiting на клиентской стороне

API имел лимит 20 req/s, но мы не контролировали частоту запросов.

4. Отсутствие retry механизма

При получении 429 ошибки приложение сразу падало, вместо повторной попытки.

Комплексное решение

Мы разработали многоуровневую систему защиты:

1. Rate Limiter с очередью запросов

Создали класс для контроля частоты запросов:

/**
 * Rate Limiter для ограничения количества запросов в секунду
 */
class RateLimiter {
  private queue: Array<() => void> = []
  private lastRequestTime = 0
  private readonly maxRequestsPerSecond: number
  private readonly minRequestInterval: number

  constructor(maxRequestsPerSecond: number = 15) {
    this.maxRequestsPerSecond = maxRequestsPerSecond
    // Рассчитываем минимальный интервал между запросами
    this.minRequestInterval = 1000 / maxRequestsPerSecond
  }

  async acquire(): Promise<void> {
    return new Promise((resolve) => {
      const tryAcquire = () => {
        const now = Date.now()
        const timeSinceLastRequest = now - this.lastRequestTime

        if (timeSinceLastRequest >= this.minRequestInterval) {
          this.lastRequestTime = now
          resolve()
        } else {
          // Ждем до следующего доступного слота
          const delay = this.minRequestInterval - timeSinceLastRequest
          setTimeout(tryAcquire, delay)
        }
      }

      this.queue.push(tryAcquire)
      if (this.queue.length === 1) {
        this.processQueue()
      }
    })
  }

  private processQueue() {
    if (this.queue.length > 0) {
      const next = this.queue.shift()
      if (next) next()
    }
  }

  release() {
    this.processQueue()
  }
}

// Создаем единственный экземпляр для всего приложения
const rateLimiter = new RateLimiter(15) // 15 req/s (запас от лимита 20)
sequenceDiagram
    participant R1 as Запрос 1
    participant R2 as Запрос 2
    participant R3 as Запрос 3
    participant RL as Rate Limiter
    participant API as External API

    R1->>RL: acquire()
    activate RL
    RL->>API: ✅ Сразу (очередь пуста)
    deactivate RL
    
    R2->>RL: acquire()
    activate RL
    Note over RL: Ждем 66мс<br/>(15 req/s = ~66мс интервал)
    RL->>API: ✅ После задержки
    deactivate RL
    
    R3->>RL: acquire()
    activate RL
    Note over RL: Очередь: [R3]<br/>Ждем слота...
    RL->>API: ✅ После задержки
    deactivate RL
    
    Note over R1,API: Никогда не превышает 15 req/s!

Ключевые особенности:

  • ✅ Очередь запросов вместо отклонения
  • ✅ Автоматический расчет минимального интервала
  • ✅ Запас от лимита API (15 вместо 20 req/s)

2. Retry с Exponential Backoff

Добавили умный механизм повторных попыток:

/**
 * Retry функция с exponential backoff для обработки временных ошибок
 */
async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  maxRetries: number = 3,
  initialDelay: number = 1000
): Promise<T> {
  let lastError: Error | null = null
  
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn()
    } catch (error: any) {
      lastError = error
      
      // Если это 429 (Too Many Requests), делаем retry
      if (error.message?.includes('429') && attempt < maxRetries) {
        const delay = initialDelay * Math.pow(2, attempt)
        console.log(
          `[API] Rate limit hit, retrying in ${delay}ms ` +
          `(attempt ${attempt + 1}/${maxRetries})`
        )
        await new Promise(resolve => setTimeout(resolve, delay))
        continue
      }
      
      // Для других ошибок не делаем retry
      throw error
    }
  }
  
  throw lastError
}

Exponential backoff:

  • Попытка 1: ждем 1 секунду
  • Попытка 2: ждем 2 секунды
  • Попытка 3: ждем 4 секунды

Это дает API время "остыть" и снижает нагрузку.

3. Интеграция с API клиентом

Обернули все API запросы:

class APIClient {
  async fetchProducts(params: FetchParams) {
    return retryWithBackoff(async () => {
      await rateLimiter.acquire()
      
      try {
        const response = await fetch(this.buildUrl(params), {
          next: {
            revalidate: 1800, // Кэш Next.js на 30 минут
          },
        })

        if (!response.ok) {
          throw new Error(`API error: ${response.status}`)
        }

        return await response.json()
      } finally {
        rateLimiter.release()
      }
    }, 3, 2000) // 3 попытки, начальная задержка 2 секунды
  }
}

Многоуровневая защита:

  1. Rate limiter контролирует частоту
  2. Retry обрабатывает временные сбои
  3. Next.js кэш уменьшает количество запросов
  4. finally гарантирует release даже при ошибке

4. Последовательная загрузка вместо параллельной

Переписали код загрузки подкатегорий:

// ❌ Было: параллельная загрузка
const allPromises = subcategories.map(sub => fetchProducts(sub.id))
const results = await Promise.all(allPromises)

// ✅ Стало: последовательная загрузка
const results = []
for (let i = 0; i < subcategories.length; i++) {
  try {
    const result = await fetchProducts(subcategories[i].id)
    results.push(result)
    
    // Небольшая дополнительная задержка для плавности
    if (i < subcategories.length - 1) {
      await new Promise(resolve => setTimeout(resolve, 100))
    }
  } catch (error) {
    // Graceful degradation: продолжаем даже если одна подкатегория не загрузилась
    console.error(`Failed to load subcategory ${subcategories[i].id}:`, error)
    // Не прерываем цикл
  }
}

Преимущества:

  • ✅ Контролируемая нагрузка на API
  • ✅ Graceful degradation (частичные данные лучше, чем ничего)
  • ✅ Работает даже при временных сбоях отдельных запросов

5. ISR (Incremental Static Regeneration)

Главное оружие против перегрузки API — кэширование на уровне страниц:

// app/shop/[category]/page.tsx

// Включаем ISR с revalidate 3600 секунд (1 час)
export const revalidate = 3600
export const dynamicParams = true

export default async function CategoryPage({ params }) {
  // Этот код выполняется на сервере
  // Результат кэшируется на 1 час
  const products = await fetchProducts(params.category)
  
  return <ProductList products={products} />
}

Как это работает:

⏰ t = 0 минут (Первый запрос)
───────────────────────────────
👤 Пользователь → Next.js → 🔍 Кэш пуст
                          → 📡 API запрос
                          → 💾 Сохранить на 1 час
                          → ✅ Ответ (3-5 сек)

⏰ t = 10 минут (Горячий кэш)
───────────────────────────────
👥 Пользователи → Next.js → ✅ Кэш есть!
                          → ⚡ Мгновенный ответ (50мс)
                          → 🚫 НЕТ запросов к API

⏰ t = 61 минута (Устаревший кэш)
───────────────────────────────
👤 Пользователь → Next.js → ⚠️ Кэш старый
                          → ⚡ Отдать старый (мгновенно!)
                          → 🔄 Обновить в фоне
                          → 📡 API запрос
                          → 💾 Обновить кэш
Note over Next,API: Первая генерация

                      → 📡 API запрос
                      → 💾 Сохранить на 1 час
                      → ✅ Ответ (3-5 сек)

⏰ t = 10 минут (Горячий кэш) ─────────────────────────────── 👥 Пользователи → Next.js → ✅ Кэш есть! → ⚡ Мгновенный ответ (50мс) → 🚫 НЕТ запросов к API

⏰ t = 61 минута (Устаревший кэш) ─────────────────────────────── 👤 Пользователь → Next.js → ⚠️ Кэш старый → ⚡ Отдать старый (мгновенно!) → 🔄 Обновить в фоне → 📡 API запрос → 💾 Обновить кэш


**Результаты ISR:**

| **Метрика** | **До ISR** | **С ISR** |
|---------|--------|-------|
| Запросов к API (первая загрузка) | 15-20 | 15-20 |
| Запросов к API (последующие) | 15-20 | **0** ✅ |
| Время загрузки (первая) | 3-5 сек | 3-5 сек |
| Время загрузки (последующие) | 3-5 сек | **< 100ms** ✅ |
| Риск 429 ошибок | Высокий | **Минимальный** ✅ |

### 6. Различные revalidate для разных страниц

Настроили время кэширования по важности:

```typescript
// Главная страница магазина - редко меняется
export const revalidate = 3600 // 1 час

// Страницы категорий - средняя частота изменений
export const revalidate = 3600 // 1 час

// Страницы товаров - часто меняются цены и наличие
export const revalidate = 1800 // 30 минут

// Страница корзины - всегда свежие данные
export const dynamic = 'force-dynamic' // Без кэша

7. Pre-warming кэша

Добавили скрипт для прогрева кэша после деплоя:

#!/bin/bash
# scripts/warmup-cache.sh

echo "🔥 Warming up cache..."

# Главные страницы
curl -s "https://example.com/en/shop" > /dev/null
curl -s "https://example.com/ru/shop" > /dev/null

# Популярные категории
for category in electronics software services; do
  curl -s "https://example.com/en/shop/$category" > /dev/null
  curl -s "https://example.com/ru/shop/$category" > /dev/null
done

echo "✅ Cache warmed up!"

Интегрировали в CI/CD:

# .github/workflows/deploy.yml
- name: Deploy to production
  run: npm run deploy

- name: Warm up cache
  run: ./scripts/warmup-cache.sh

Теперь первые пользователи получают уже закэшированные страницы!

Результаты

Метрики до и после оптимизации:

МетрикаДоПослеУлучшение
429 ошибки при деплое15-200✅ 100%
Среднее время загрузки3.2 сек0.08 сек✅ 40x
Запросов к API в час~500~15✅ 97%
Uptime99.2%99.9%✅ +0.7%
Пользовательская оценка3.8/54.7/5✅ +24%

Логи после внедрения:

# Первая генерация после деплоя
[API] Loading 8 subcategories for category
[API] Rate limit hit, retrying in 1000ms (attempt 1/3) ← норма!
[API] Loaded 156 products from 8 subcategories
[Cache] Saved to static cache (revalidate: 3600s)

# Последующие запросы
[Cache] Serving from cache (age: 245s) ← мгновенно!
[Cache] Serving from cache (age: 612s)
[Cache] Serving from cache (age: 1203s)

# Регенерация через час
[Cache] Revalidating in background...
[API] Loaded fresh data
[Cache] Updated static cache

Best Practices

1. Всегда используйте запас от лимита

// ❌ Плохо: используем весь лимит
const rateLimiter = new RateLimiter(20) // API лимит: 20 req/s

// ✅ Хорошо: оставляем запас
const rateLimiter = new RateLimiter(15) // 75% от лимита

Почему? Сетевые задержки, другие процессы, пики нагрузки.

2. Логируйте retry попытки

if (attempt < maxRetries) {
  console.log(
    `[API] Rate limit hit, retrying in ${delay}ms ` +
    `(attempt ${attempt + 1}/${maxRetries})`
  )
}

Это не ошибки — это нормальная работа системы!

3. Graceful degradation везде

for (const item of items) {
  try {
    const result = await processItem(item)
    results.push(result)
  } catch (error) {
    console.error(`Failed to process ${item.id}:`, error)
    // Продолжаем со следующим элементом
    // Частичные данные лучше, чем пустая страница
  }
}

4. Мониторинг и алерты

Настройте оповещения:

// lib/monitoring.ts
export function trackAPIError(error: Error, context: object) {
  if (error.message.includes('429')) {
    // Это ожидаемо, просто метрика
    metrics.increment('api.rate_limit_hit')
  } else {
    // Неожиданная ошибка, алерт
    alerts.send('API Error', { error, context })
  }
}

5. Разные стратегии для разных данных

// Критичные данные: агрессивный retry
const userData = await retryWithBackoff(
  () => fetchUserData(),
  5, // 5 попыток
  500 // начинаем с 500ms
)

// Некритичные данные: быстрый fail
const recommendations = await retryWithBackoff(
  () => fetchRecommendations(),
  1, // 1 попытка
  1000
).catch(() => []) // возвращаем пустой массив при ошибке

Типичные ошибки

❌ Ошибка 1: Полагаться только на retry

// Недостаточно!
const data = await retryWithBackoff(() => fetchData())

Проблема: Если запросов много, retry только усугубит ситуацию.

Решение: Rate limiter + Retry + ISR.

❌ Ошибка 2: Агрессивный retry

// Плохо: слишком быстрые попытки
await retryWithBackoff(fn, 10, 100) // 10 попыток с 100ms

Проблема: Перегружает API еще больше.

Решение: 3-5 попыток с exponential backoff от 1-2 секунд.

❌ Ошибка 3: Игнорирование 429 после retry

try {
  await retryWithBackoff(fn)
} catch (error) {
  // Просто показываем ошибку пользователю
  return <ErrorPage />
}

Проблема: Плохой UX.

Решение: Graceful degradation + кэшированные данные.

❌ Ошибка 4: Один revalidate для всего

// Неоптимально
export const revalidate = 3600 // для всех страниц

Проблема: Статичные данные обновляются слишком часто, динамичные — слишком редко.

Решение: Индивидуальный revalidate для каждой страницы.

Альтернативные подходы

Вариант 1: Server-side кэш (Redis)

import { Redis } from '@upstash/redis'

const redis = new Redis({...})

async function fetchWithCache(key: string, fetcher: () => Promise<any>) {
  // Проверяем кэш
  const cached = await redis.get(key)
  if (cached) return cached
  
  // Загружаем с rate limiting
  await rateLimiter.acquire()
  try {
    const data = await fetcher()
    await redis.set(key, data, { ex: 3600 }) // кэш на час
    return data
  } finally {
    rateLimiter.release()
  }
}

Плюсы:

  • Общий кэш для всех инстансов
  • Гибкое управление TTL
  • Можно инвалидировать программно

Минусы:

  • Дополнительная инфраструктура
  • Дополнительные costs
  • Сложнее в настройке

Вариант 2: Background Jobs

// Cron job обновляет данные каждый час
export async function GET(request: Request) {
  // Загружаем все категории
  for (const category of categories) {
    await fetchAndCacheProducts(category)
    await new Promise(resolve => setTimeout(resolve, 1000))
  }
  
  return Response.json({ success: true })
}

Плюсы:

  • Пользователи всегда получают свежие данные из кэша
  • Полный контроль над временем обновления

Минусы:

  • Нужен scheduler (Vercel Cron, AWS EventBridge)
  • Расход ресурсов даже без трафика

Вариант 3: GraphQL с DataLoader

import DataLoader from 'dataloader'

const productLoader = new DataLoader(async (ids) => {
  await rateLimiter.acquire()
  try {
    // Батчинг: загружаем несколько товаров за один запрос
    return await fetchProductsByIds(ids)
  } finally {
    rateLimiter.release()
  }
}, {
  maxBatchSize: 50, // Максимум товаров в одном запросе
  cache: true
})

Плюсы:

  • Автоматический батчинг
  • Встроенное кэширование
  • Решает N+1 проблему

Минусы:

  • Требует GraphQL
  • Дополнительная сложность

Мониторинг в продакшене

Метрики для отслеживания:

// lib/metrics.ts
export const apiMetrics = {
  // Rate limiting
  rateLimitHits: 0,
  rateLimitWaitTime: 0,
  
  // Retry
  retryAttempts: 0,
  retrySuccesses: 0,
  retryFailures: 0,
  
  // Cache
  cacheHits: 0,
  cacheMisses: 0,
  cacheRevalidations: 0,
  
  // API
  apiCalls: 0,
  apiErrors: 0,
  apiLatency: [],
}

Dashboard пример:

📊 API Performance Dashboard

Rate Limiting:
├─ Hits today: 12 (normal)
├─ Avg wait time: 45ms
└─ Queue size: 0

Retry Statistics:
├─ Total attempts: 8
├─ Successes: 8 (100%)
└─ Failures: 0

Cache Performance:
├─ Hit rate: 99.2%
├─ Revalidations/hour: 4
└─ Avg response time: 82ms

API Health:
├─ Calls today: 234
├─ Error rate: 0.0%
└─ Avg latency: 156ms

Checklist для внедрения

  • Rate Limiter

    • Создан класс RateLimiter
    • Определен лимит с запасом (75% от API лимита)
    • Все API запросы проходят через limiter
  • Retry механизм

    • Реализован retryWithBackoff
    • Exponential backoff настроен (1s, 2s, 4s)
    • Логирование попыток
  • ISR кэширование

    • revalidate добавлен на страницы
    • Время revalidate подобрано для каждого типа страниц
    • dynamicParams = true для динамических маршрутов
  • Оптимизация запросов

    • Параллельные запросы заменены на последовательные где нужно
    • Добавлены задержки между запросами
    • Реализован graceful degradation
  • Pre-warming

    • Создан скрипт warmup-cache.sh
    • Интегрирован в CI/CD
    • Прогреваются главные страницы
  • Мониторинг

    • Логирование retry попыток
    • Метрики API запросов
    • Алерты на критические ошибки
  • Тестирование

    • Нагрузочное тестирование
    • Проверка поведения при 429
    • Проверка времени загрузки

Заключение

Проблема с rate limiting внешних API — это системная проблема, требующая многоуровневого решения:

  1. Rate Limiter — предотвращает превышение лимитов
  2. Retry с backoff — обрабатывает временные сбои
  3. ISR — минимизирует количество запросов
  4. Последовательная загрузка — контролирует нагрузку
  5. Graceful degradation — улучшает UX при ошибках
  6. Pre-warming — ускоряет первую загрузку
  7. Мониторинг — держит систему под контролем

Наша команда внедрила эти решения и получила:

  • 0 ошибок 429 в production
  • 40x улучшение времени загрузки
  • 97% сокращение запросов к API
  • 24% рост пользовательской оценки

Главный урок: Не полагайтесь на один инструмент. Комбинируйте подходы для надежной и производительной системы.


Полезные ссылки