Как подключать внешний API: ключи, лимиты, retry, timeout, логирование и fallback
Основной чат
Чат для вайбкодеров: новости, гайды, поиск исполнителей, маркетплейс и разбор реальных кейсов.
Добавить fetch('https://api.example.com/data') — дело пяти минут. Сделать так, чтобы это работало надёжно в продакшене — совсем другая задача. Внешний API упадёт. Вернёт 429. Зависнет на 30 секунд без ответа. Поменяет формат ответа без предупреждения. Исчерпает ваш дневной лимит в 3 ночи из-за бага в коде.
Эта статья — про всё, что нужно сделать между fetch и продакшеном: безопасное хранение ключей, rate limits, retry с правильной логикой, таймауты, структурированное логирование и fallback когда API недоступен.
Ключи: как хранить и как не хранить
Правило одно: API-ключ никогда не должен оказаться в коде или в репозитории. Это звучит очевидно — но утечки ключей через git-историю происходят постоянно.
Переменные окружения — минимальный уровень
# .env (локально)
OPENAI_API_KEY=sk-...
STRIPE_SECRET_KEY=sk_live_...
WEATHER_API_KEY=abc123
# .gitignore — обязательно
.env
.env.local
.env.production
// Node.js
require('dotenv').config();
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) throw new Error('OPENAI_API_KEY не задан');
# Python
import os
from dotenv import load_dotenv
load_dotenv()
api_key = os.getenv('OPENAI_API_KEY')
if not api_key:
raise ValueError('OPENAI_API_KEY не задан')
Проверяйте наличие ключей при старте приложения, а не в момент первого запроса. Лучше упасть при деплое с понятной ошибкой, чем отдавать 500 пользователям в рантайме.
Ротация ключей
Для production держите два ключа одновременно: активный и резервный. При ротации:
- Генерируете новый ключ в панели провайдера
- Добавляете его в переменные окружения как резервный
- Переключаете на новый
- Через несколько дней удаляете старый
Некоторые провайдеры (Stripe, OpenAI) позволяют задавать срок жизни ключа — используйте это для ключей с широкими правами.
Разные ключи для разных окружений
# development
STRIPE_SECRET_KEY=sk_test_... # тестовый ключ, лимиты и данные отдельные
# production
STRIPE_SECRET_KEY=sk_live_... # боевой ключ
Никогда не используйте production-ключи локально. Одна опечатка в тесте — и реальные деньги списаны или реальные данные изменены.
Базовая обёртка HTTP-клиента
Вместо голого fetch или axios везде — создайте один класс-обёртку, который централизует таймауты, заголовки и логирование.
Node.js
// lib/api-client.js
const axios = require('axios');
class ApiClient {
constructor({ baseURL, apiKey, timeout = 10_000, headers = {} }) {
this.client = axios.create({
baseURL,
timeout,
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
...headers,
},
});
// Логируем каждый запрос
this.client.interceptors.request.use((config) => {
config.metadata = { startTime: Date.now() };
console.log(`→ ${config.method?.toUpperCase()} ${config.url}`);
return config;
});
// Логируем каждый ответ
this.client.interceptors.response.use(
(response) => {
const duration = Date.now() - response.config.metadata.startTime;
console.log(`← ${response.status} ${response.config.url} (${duration}ms)`);
return response;
},
(error) => {
const duration = error.config?.metadata
? Date.now() - error.config.metadata.startTime
: null;
console.error(
`✗ ${error.response?.status || 'NETWORK'} ${error.config?.url}`,
duration ? `(${duration}ms)` : ''
);
return Promise.reject(error);
}
);
}
async get(path, params = {}) {
const response = await this.client.get(path, { params });
return response.data;
}
async post(path, body = {}) {
const response = await this.client.post(path, body);
return response.data;
}
}
module.exports = ApiClient;
// Использование
const ApiClient = require('./lib/api-client');
const weatherClient = new ApiClient({
baseURL: 'https://api.openweathermap.org/data/2.5',
apiKey: process.env.WEATHER_API_KEY,
timeout: 5_000,
});
const data = await weatherClient.get('/weather', { q: 'Moscow', units: 'metric' });
Python (httpx — асинхронный)
# lib/api_client.py
import httpx
import time
import logging
logger = logging.getLogger(__name__)
class ApiClient:
def __init__(self, base_url: str, api_key: str, timeout: float = 10.0):
self.base_url = base_url.rstrip('/')
self.timeout = timeout
self.headers = {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
}
async def get(self, path: str, params: dict = None) -> dict:
url = f'{self.base_url}{path}'
start = time.monotonic()
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(url, headers=self.headers, params=params)
duration = (time.monotonic() - start) * 1000
logger.info(f'GET {url} → {response.status_code} ({duration:.0f}ms)')
response.raise_for_status()
return response.json()
async def post(self, path: str, body: dict = None) -> dict:
url = f'{self.base_url}{path}'
start = time.monotonic()
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(url, headers=self.headers, json=body)
duration = (time.monotonic() - start) * 1000
logger.info(f'POST {url} → {response.status_code} ({duration:.0f}ms)')
response.raise_for_status()
return response.json()
Таймауты: почему они критичны
Без таймаута один зависший API-запрос может держать ваш воркер занятым минутами. При нескольких параллельных запросах — это исчерпание пула соединений и падение всего сервиса.
Таймаутов у HTTP-запроса на самом деле несколько:
| Тип | Что ограничивает | Рекомендуемое значение |
|---|---|---|
| Connect timeout | Установка соединения с сервером | 3–5 сек |
| Read timeout | Ожидание первого байта ответа | 5–30 сек (зависит от API) |
| Total timeout | Весь запрос от начала до конца | 30–60 сек |
// axios: раздельные таймауты
const client = axios.create({
timeout: 10_000, // общий timeout в ms
// connect timeout — через httpsAgent
httpsAgent: new https.Agent({ timeout: 3000 }),
});
# httpx: раздельные таймауты
timeout = httpx.Timeout(
connect=3.0, # соединение
read=10.0, # чтение ответа
write=5.0, # отправка тела
pool=2.0, # ожидание свободного соединения из пула
)
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.get(url)
Для разных API — разные таймауты. LLM-запрос с генерацией длинного текста — 60+ секунд нормально. Запрос погоды — больше 5 секунд уже подозрительно.
Rate limits: как не получить бан
Rate limit — ограничение на количество запросов за период. Если превысить — API вернёт 429 Too Many Requests. Стратегии работы с лимитами зависят от их типа.
Типы лимитов
| Тип | Пример | Стратегия |
|---|---|---|
| Запросов в секунду (RPS) | 10 req/s | Очередь с throttling |
| Запросов в минуту (RPM) | 60 req/min | Sliding window counter |
| Токенов в минуту (TPM) | 100k tokens/min | Подсчёт перед отправкой |
| Запросов в день | 1000 req/day | Счётчик + Redis |
Соблюдение лимитов через очередь (Node.js)
// lib/rate-limiter.js
class RateLimiter {
constructor({ maxRPS = 10 }) {
this.minInterval = 1000 / maxRPS; // минимум мс между запросами
this.lastRequest = 0;
this.queue = [];
this.processing = false;
}
async throttle(fn) {
return new Promise((resolve, reject) => {
this.queue.push({ fn, resolve, reject });
if (!this.processing) this._process();
});
}
async _process() {
this.processing = true;
while (this.queue.length > 0) {
const now = Date.now();
const wait = this.lastRequest + this.minInterval - now;
if (wait > 0) await new Promise(r => setTimeout(r, wait));
const { fn, resolve, reject } = this.queue.shift();
this.lastRequest = Date.now();
try {
resolve(await fn());
} catch (err) {
reject(err);
}
}
this.processing = false;
}
}
// Использование
const limiter = new RateLimiter({ maxRPS: 5 });
async function safeApiCall(params) {
return limiter.throttle(() => apiClient.get('/data', params));
}
Заголовки rate limit — читайте их
Большинство API возвращают информацию о лимитах в заголовках ответа:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1749300060
Retry-After: 30
this.client.interceptors.response.use((response) => {
const remaining = response.headers['x-ratelimit-remaining'];
const reset = response.headers['x-ratelimit-reset'];
if (remaining !== undefined && parseInt(remaining) < 5) {
const resetMs = (parseInt(reset) * 1000) - Date.now();
console.warn(`⚠ Rate limit почти исчерпан. Сброс через ${Math.ceil(resetMs / 1000)}s`);
}
return response;
});
Retry: повторные запросы с умом
Не все ошибки нужно ретраить. Классификация:
| Статус | Ретраить? | Причина |
|---|---|---|
429 Too Many Requests |
Да, с задержкой | Временное превышение лимита |
500 Internal Server Error |
Да, осторожно | Временная проблема на сервере |
502 Bad Gateway |
Да | Инфраструктурная проблема |
503 Service Unavailable |
Да | Сервер перегружен |
504 Gateway Timeout |
Да | Таймаут на стороне сервера |
400 Bad Request |
Нет | Ошибка в данных запроса |
401 Unauthorized |
Нет | Неверный ключ — ретрай не поможет |
403 Forbidden |
Нет | Нет прав — ретрай не поможет |
404 Not Found |
Нет | Ресурс не существует |
| Network error / timeout | Да | Временная сетевая проблема |
Экспоненциальная задержка с jitter
Простой retry через 1 секунду плохо работает при нескольких клиентах — все ретраят синхронно и создают новую волну нагрузки. Jitter (случайный сдвиг) решает это.
// lib/retry.js
async function withRetry(fn, options = {}) {
const {
maxAttempts = 3,
baseDelay = 1000, // мс
maxDelay = 30_000, // мс
factor = 2,
jitter = true,
retryOn = [429, 500, 502, 503, 504],
} = options;
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
const status = error.response?.status;
const isNetworkError = !error.response;
const shouldRetry = isNetworkError || retryOn.includes(status);
if (!shouldRetry || attempt === maxAttempts) {
throw error;
}
// Если сервер сказал когда ретраить — слушаем его
const retryAfter = error.response?.headers?.['retry-after'];
let delay;
if (retryAfter) {
delay = parseInt(retryAfter) * 1000;
} else {
// Экспоненциальная задержка: 1s, 2s, 4s, 8s...
delay = Math.min(baseDelay * Math.pow(factor, attempt - 1), maxDelay);
}
// Jitter: ±25% от задержки
if (jitter) {
delay = delay * (0.75 + Math.random() * 0.5);
}
console.log(`Попытка ${attempt}/${maxAttempts} не удалась (${status || 'network'}). Retry через ${Math.round(delay)}ms...`);
await new Promise(r => setTimeout(r, delay));
}
}
throw lastError;
}
module.exports = { withRetry };
// Использование
const { withRetry } = require('./lib/retry');
const data = await withRetry(
() => apiClient.get('/weather', { q: 'Moscow' }),
{ maxAttempts: 3, baseDelay: 1000 }
);
Python: tenacity — лучшая библиотека для retry
pip install tenacity
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
wait_random_exponential,
retry_if_exception,
before_sleep_log,
)
import logging
import httpx
logger = logging.getLogger(__name__)
def is_retryable(exc: Exception) -> bool:
if isinstance(exc, httpx.HTTPStatusError):
return exc.response.status_code in {429, 500, 502, 503, 504}
if isinstance(exc, (httpx.ConnectError, httpx.TimeoutException)):
return True
return False
@retry(
stop=stop_after_attempt(3),
wait=wait_random_exponential(multiplier=1, min=1, max=30),
retry=retry_if_exception(is_retryable),
before_sleep=before_sleep_log(logger, logging.WARNING),
)
async def fetch_with_retry(client: ApiClient, path: str, params: dict = None):
return await client.get(path, params)
Логирование: что писать и в каком формате
Хаотичные console.log бесполезны при разборе инцидента в 3 ночи. Структурированные логи — можно фильтровать, агрегировать, строить алерты.
Что логировать по каждому запросу
// lib/logger.js — структурированный JSON-лог
function logRequest({ method, url, status, duration, error, requestId }) {
const entry = {
ts: new Date().toISOString(),
type: 'api_request',
method,
url: sanitizeUrl(url), // убираем ключи из URL
status,
duration_ms: duration,
request_id: requestId,
...(error && {
error: error.message,
error_code: error.code,
}),
};
console.log(JSON.stringify(entry));
}
// Никогда не логируем: тела запросов с ключами, токены, пароли
function sanitizeUrl(url) {
try {
const u = new URL(url);
// Убираем параметры, которые могут содержать ключи
['api_key', 'apikey', 'token', 'secret', 'password'].forEach(p => {
if (u.searchParams.has(p)) u.searchParams.set(p, '[REDACTED]');
});
return u.toString();
} catch {
return url;
}
}
// Пример структурированного лога:
{"ts":"2026-06-07T10:23:45.123Z","type":"api_request","method":"GET","url":"https://api.openweathermap.org/data/2.5/weather?q=Moscow&units=metric","status":200,"duration_ms":312,"request_id":"req_abc123"}
{"ts":"2026-06-07T10:23:51.456Z","type":"api_request","method":"POST","url":"https://api.openai.com/v1/chat/completions","status":429,"duration_ms":89,"request_id":"req_def456","error":"Too Many Requests"}
Такие логи легко парсятся в любом log-агрегаторе (Loki, Datadog, CloudWatch).
Уровни логирования
// DEBUG: детали запроса — только в разработке
// INFO: успешные запросы с временем ответа
// WARN: retry, близкий к исчерпанию rate limit, медленный ответ (>2s)
// ERROR: все неуспешные запросы после исчерпания retry
const logLevel = process.env.LOG_LEVEL || 'info';
function log(level, data) {
const levels = { debug: 0, info: 1, warn: 2, error: 3 };
if (levels[level] >= levels[logLevel]) {
console[level === 'warn' ? 'warn' : level === 'error' ? 'error' : 'log'](
JSON.stringify({ level, ...data, ts: new Date().toISOString() })
);
}
}
Fallback: что делать когда API недоступен
Внешний API упадёт. Вопрос не в том, произойдёт ли это, а когда. Хорошая система деградирует gracefully, а не падает вместе с зависимостью.
Стратегии fallback
Кэш последнего успешного ответа. Самый простой и часто достаточный вариант: если API не ответил — вернуть закэшированный результат с пометкой «данные могут быть устаревшими».
// lib/cached-api.js
const cache = new Map(); // для продакшена — Redis
async function getWithFallback(key, fetchFn, ttlMs = 300_000) {
const cached = cache.get(key);
try {
const fresh = await withRetry(fetchFn, { maxAttempts: 2 });
cache.set(key, { data: fresh, ts: Date.now() });
return { data: fresh, stale: false };
} catch (error) {
if (cached) {
const ageMs = Date.now() - cached.ts;
console.warn(`API недоступен, возвращаем кэш (возраст: ${Math.round(ageMs / 1000)}s)`);
return { data: cached.data, stale: true };
}
throw error; // кэша нет — пробрасываем ошибку
}
}
// Использование
const { data, stale } = await getWithFallback(
'weather:moscow',
() => weatherClient.get('/weather', { q: 'Moscow' }),
5 * 60_000 // TTL 5 минут
);
if (stale) {
res.setHeader('X-Data-Stale', 'true');
}
res.json(data);
Circuit Breaker (автоматический выключатель). Если API раз за разом возвращает ошибки — прекращаем попытки на время, не тратим ресурсы на заведомо неудачные запросы.
// lib/circuit-breaker.js
class CircuitBreaker {
constructor({ failureThreshold = 5, recoveryTimeout = 60_000 }) {
this.failureThreshold = failureThreshold;
this.recoveryTimeout = recoveryTimeout;
this.failures = 0;
this.state = 'CLOSED'; // CLOSED → OPEN → HALF_OPEN
this.nextAttempt = null;
}
async call(fn) {
if (this.state === 'OPEN') {
if (Date.now() < this.nextAttempt) {
throw new Error('Circuit breaker OPEN: API временно недоступен');
}
this.state = 'HALF_OPEN';
}
try {
const result = await fn();
this._onSuccess();
return result;
} catch (error) {
this._onFailure();
throw error;
}
}
_onSuccess() {
this.failures = 0;
this.state = 'CLOSED';
}
_onFailure() {
this.failures++;
if (this.failures >= this.failureThreshold) {
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.recoveryTimeout;
console.error(`Circuit breaker OPEN. Следующая попытка через ${this.recoveryTimeout / 1000}s`);
}
}
}
// Использование
const breaker = new CircuitBreaker({ failureThreshold: 5, recoveryTimeout: 60_000 });
async function fetchWeather(city) {
return breaker.call(() =>
withRetry(() => weatherClient.get('/weather', { q: city }), { maxAttempts: 2 })
);
}
Запасной провайдер. Для критичных функций — второй API как резерв.
async function getGeolocation(ip) {
// Пробуем основного провайдера
try {
return await ipApiClient.get(`/${ip}/json`);
} catch (primaryError) {
console.warn('Основной геолокационный API недоступен, пробуем резервный');
try {
return await ipInfoClient.get(`/${ip}`);
} catch (fallbackError) {
// Оба упали — возвращаем минимально полезный ответ
return { country: 'unknown', city: 'unknown', ip };
}
}
}
Мониторинг: как знать, что что-то сломалось
Логи помогают после факта. Метрики — в реальном времени.
Базовые метрики, которые нужно собирать
// lib/metrics.js — простой in-memory счётчик (для Redis — аналогично)
const metrics = {
requests: {}, // { 'weather_api': { total: 100, errors: 5, p95_ms: 320 } }
durations: {}, // для перцентилей
};
function recordRequest({ apiName, status, durationMs }) {
if (!metrics.requests[apiName]) {
metrics.requests[apiName] = { total: 0, errors: 0, durations: [] };
}
const m = metrics.requests[apiName];
m.total++;
if (status >= 400 || !status) m.errors++;
m.durations.push(durationMs);
// Логируем медленные запросы как предупреждение
if (durationMs > 2000) {
console.warn(JSON.stringify({
level: 'warn',
type: 'slow_api_request',
api: apiName,
duration_ms: durationMs,
}));
}
}
function getErrorRate(apiName) {
const m = metrics.requests[apiName];
if (!m || m.total === 0) return 0;
return m.errors / m.total;
}
// Алерт если error rate > 10% за последние 5 минут
setInterval(() => {
for (const [api, m] of Object.entries(metrics.requests)) {
const errorRate = m.errors / m.total;
if (errorRate > 0.1) {
console.error(JSON.stringify({
level: 'error',
type: 'high_error_rate',
api,
error_rate: errorRate.toFixed(2),
total: m.total,
errors: m.errors,
}));
}
}
}, 5 * 60_000);
Итоговая обёртка: всё вместе
// lib/robust-api-client.js
const { withRetry } = require('./retry');
const { CircuitBreaker } = require('./circuit-breaker');
const { logRequest, sanitizeUrl } = require('./logger');
const { recordRequest } = require('./metrics');
class RobustApiClient {
constructor({ name, baseURL, apiKey, timeout = 10_000, maxRPS = 10 }) {
this.name = name;
this.breaker = new CircuitBreaker({ failureThreshold: 5 });
this.cache = new Map();
this.client = axios.create({
baseURL,
timeout,
headers: { 'Authorization': `Bearer ${apiKey}` },
});
}
async request({ method, path, params, body, cacheKey, cacheTtl }) {
const url = `${this.client.defaults.baseURL}${path}`;
const start = Date.now();
let status, error;
try {
const result = await this.breaker.call(() =>
withRetry(async () => {
const res = await this.client.request({ method, url: path, params, data: body });
return res.data;
}, { maxAttempts: 3 })
);
status = 200;
// Кэшируем при необходимости
if (cacheKey) {
this.cache.set(cacheKey, { data: result, ts: Date.now(), ttl: cacheTtl });
}
return result;
} catch (err) {
error = err;
status = err.response?.status;
// Fallback на кэш
if (cacheKey && this.cache.has(cacheKey)) {
const cached = this.cache.get(cacheKey);
console.warn(`Fallback на кэш для ${this.name}:${path}`);
return cached.data;
}
throw err;
} finally {
const duration = Date.now() - start;
logRequest({ method, url: sanitizeUrl(url), status, duration, error });
recordRequest({ apiName: this.name, status, durationMs: duration });
}
}
get(path, params, options = {}) {
return this.request({ method: 'GET', path, params, ...options });
}
post(path, body, options = {}) {
return this.request({ method: 'POST', path, body, ...options });
}
}
Чеклист подключения внешнего API
Ключи и безопасность:
☐ API-ключи только в переменных окружения, не в коде
☐ .env добавлен в .gitignore
☐ Проверка наличия ключей при старте приложения
☐ Разные ключи для dev и production
☐ Ключи не попадают в логи (sanitizeUrl)
Таймауты:
☐ Установлен connect timeout (3–5 сек)
☐ Установлен read timeout под конкретный API
☐ Нет запросов без таймаута
Rate limits:
☐ Изучена документация по лимитам провайдера
☐ Реализован throttling если нужен RPS-контроль
☐ Читаются заголовки X-RateLimit-Remaining
Retry:
☐ Ретраятся только 429, 5xx и network errors
☐ Используется экспоненциальная задержка с jitter
☐ Соблюдается Retry-After из заголовков ответа
Логирование:
☐ Каждый запрос логируется: метод, URL, статус, время
☐ Ключи и токены не попадают в логи
☐ Медленные запросы (>2s) логируются как warn
Fallback:
☐ Реализован кэш последнего успешного ответа
☐ Рассмотрен Circuit Breaker для критичных API
☐ Есть запасной провайдер для критичных функций (если возможно)
☐ Graceful degradation: сервис не падает при недоступности API
Итог
Надёжная работа с внешним API — это не одна функция, а слой из нескольких механизмов: безопасное хранение ключей, таймауты на каждый тип ожидания, retry только для ретраябельных ошибок с экспоненциальной задержкой, структурированные логи для разбора инцидентов и fallback на кэш при недоступности сервиса.
Всё это можно реализовать один раз в виде RobustApiClient и переиспользовать для любого API в проекте. Потраченные 2 часа на написание обёртки окупаются при первом же инциденте, когда внешний сервис ложится, а ваш сайт продолжает работать на кэше.
Актуально для axios 1.x, httpx 0.27+, Node.js 22+, Python 3.12+. Июнь 2026.