~/wiki / integratsii-i-api / external-api-keys-retry-timeout-logging-fallback

Как подключать внешний API: ключи, лимиты, retry, timeout, логирование и fallback

Основной чат

Чат для вайбкодеров: новости, гайды, поиск исполнителей, маркетплейс и разбор реальных кейсов.

$ cd раздел/ $ join vibe dev
Как подключать внешний API: ключи, лимиты, retry, timeout, логирование и fallback - обложка

Добавить fetch('https://api.example.com/data') — дело пяти минут. Сделать так, чтобы это работало надёжно в продакшене — совсем другая задача. Внешний API упадёт. Вернёт 429. Зависнет на 30 секунд без ответа. Поменяет формат ответа без предупреждения. Исчерпает ваш дневной лимит в 3 ночи из-за бага в коде.

Эта статья — про всё, что нужно сделать между fetch и продакшеном: безопасное хранение ключей, rate limits, retry с правильной логикой, таймауты, структурированное логирование и fallback когда API недоступен.


Ключи: как хранить и как не хранить

Правило одно: API-ключ никогда не должен оказаться в коде или в репозитории. Это звучит очевидно — но утечки ключей через git-историю происходят постоянно.

Переменные окружения — минимальный уровень

bash
# .env (локально)
OPENAI_API_KEY=sk-...
STRIPE_SECRET_KEY=sk_live_...
WEATHER_API_KEY=abc123

# .gitignore — обязательно
.env
.env.local
.env.production
javascript
// Node.js
require('dotenv').config();
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) throw new Error('OPENAI_API_KEY не задан');
python
# 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 держите два ключа одновременно: активный и резервный. При ротации:

  1. Генерируете новый ключ в панели провайдера
  2. Добавляете его в переменные окружения как резервный
  3. Переключаете на новый
  4. Через несколько дней удаляете старый

Некоторые провайдеры (Stripe, OpenAI) позволяют задавать срок жизни ключа — используйте это для ключей с широкими правами.

Разные ключи для разных окружений

bash
# development
STRIPE_SECRET_KEY=sk_test_...   # тестовый ключ, лимиты и данные отдельные

# production
STRIPE_SECRET_KEY=sk_live_...   # боевой ключ

Никогда не используйте production-ключи локально. Одна опечатка в тесте — и реальные деньги списаны или реальные данные изменены.


Базовая обёртка HTTP-клиента

Вместо голого fetch или axios везде — создайте один класс-обёртку, который централизует таймауты, заголовки и логирование.

Node.js

javascript
// 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;
javascript
// Использование
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 — асинхронный)

python
# 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 сек
javascript
// axios: раздельные таймауты
const client = axios.create({
    timeout: 10_000,  // общий timeout в ms
    // connect timeout — через httpsAgent
    httpsAgent: new https.Agent({ timeout: 3000 }),
});
python
# 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)

javascript
// 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 возвращают информацию о лимитах в заголовках ответа:

plaintext
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1749300060
Retry-After: 30
javascript
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 (случайный сдвиг) решает это.

javascript
// 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 };
javascript
// Использование
const { withRetry } = require('./lib/retry');

const data = await withRetry(
    () => apiClient.get('/weather', { q: 'Moscow' }),
    { maxAttempts: 3, baseDelay: 1000 }
);

Python: tenacity — лучшая библиотека для retry

bash
pip install tenacity
python
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 ночи. Структурированные логи — можно фильтровать, агрегировать, строить алерты.

Что логировать по каждому запросу

javascript
// 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;
    }
}
plaintext
// Пример структурированного лога:
{"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).

Уровни логирования

javascript
// 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 не ответил — вернуть закэшированный результат с пометкой «данные могут быть устаревшими».

javascript
// 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 раз за разом возвращает ошибки — прекращаем попытки на время, не тратим ресурсы на заведомо неудачные запросы.

javascript
// 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 как резерв.

javascript
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 };
        }
    }
}

Мониторинг: как знать, что что-то сломалось

Логи помогают после факта. Метрики — в реальном времени.

Базовые метрики, которые нужно собирать

javascript
// 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);

Итоговая обёртка: всё вместе

javascript
// 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

plaintext
Ключи и безопасность:
☐ 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.

$ cd ../ ← назад к Интеграции и API