Rate Limiting и ISR: Как избежать 429 ошибок при работе с внешними API в Next.js
Комплексное решение проблемы превышения лимитов внешних API в Next.js: rate limiter с очередью, retry с exponential backoff, ISR и graceful degradation. Реальный опыт команды.
Проблема
Вы интегрировали внешний 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 секунды
}
}
Многоуровневая защита:
- Rate limiter контролирует частоту
- Retry обрабатывает временные сбои
- Next.js кэш уменьшает количество запросов
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-20 | 0 | ✅ 100% |
| Среднее время загрузки | 3.2 сек | 0.08 сек | ✅ 40x |
| Запросов к API в час | ~500 | ~15 | ✅ 97% |
| Uptime | 99.2% | 99.9% | ✅ +0.7% |
| Пользовательская оценка | 3.8/5 | 4.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 — это системная проблема, требующая многоуровневого решения:
- Rate Limiter — предотвращает превышение лимитов
- Retry с backoff — обрабатывает временные сбои
- ISR — минимизирует количество запросов
- Последовательная загрузка — контролирует нагрузку
- Graceful degradation — улучшает UX при ошибках
- Pre-warming — ускоряет первую загрузку
- Мониторинг — держит систему под контролем
Наша команда внедрила эти решения и получила:
- ✅ 0 ошибок 429 в production
- ✅ 40x улучшение времени загрузки
- ✅ 97% сокращение запросов к API
- ✅ 24% рост пользовательской оценки
Главный урок: Не полагайтесь на один инструмент. Комбинируйте подходы для надежной и производительной системы.