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

How to connect an external API: keys, limits, retry, timeout, logging and fallback

Main chat

A chat for vibe coders: news, guides, live cases, marketplace, and finding executors.

$ cd section/ $ join vibe dev
How to connect an external API: keys, limits, retry, timeout, logging and fallback - обложка

Adding fetch('https://api.example.com/data') is a matter of five minutes. Making it work reliably in production is a completely different task. The external API will drop. Bring back 429. Hangs for 30 seconds without a response. Change the format of the answer without warning. Exhausts your daily limit of 3 nights due to a bug in the code.

This article is about everything you need to do between fetch and production: secure key storage, rate limits, retry with proper logic, timeouts, structured logging, and fallback when the API is unavailable.


Keys: how to store and how not to store

The rule is one: an API key should never be in a code or repository. It sounds obvious – but key leaks through git history occur all the time.

Environment variables - minimum level

bash
#.env (locally)
OPENAI API KEY=sk--
STRIPE SECRET KEY=sk live 
WEATHER API KEY=abc123

#.gitignore - definitely
.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 не задан')

Check for keys when you start the application, not when you first request it. It’s better to fall down with an obvious error than to give 500 users in a random time.

Key rotation

For production, keep two keys at the same time: active and backup. On rotation:

  1. Generate a new key in the provider panel
  2. Add it to the environment variables as a backup
  3. Switch to a new one
  4. After a few days, remove the old one

Some providers (Stripe, OpenAI) allow you to set the lifespan of the key – use this for wide-regulated keys.

Different keys for different environments

bash
#development
STRIPE SECRET KEY=sk test  # test key, limits and data separate

production
STRIPE SECRET KEY=sk live  # combat key

Never use production keys locally. One typo in the test - and real money is written off or real data is changed.


## Basic wrapper HTTP client

Instead of naked fetch or axios everywhere, create a single class wrapper that centralizes timeouts, headers, and logging.

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 - asynchronous)

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()

Timeouts: Why They Are Critical

Without a timeout, a single hung API request can keep your trader busy for minutes. With several parallel requests, this is the exhaustion of the connection pool and the fall of the entire service.

The timeouts of an HTTP request are actually several:

Тип Что ограничивает Рекомендуемое значение
Connect timeout Установка соединения с сервером 3–5 сек
Read timeout Ожидание первого байта ответа 5–30 сек (зависит от API)
Total timeout Весь запрос от начала до конца 30–60 сек
javascript
// axios: separate timeouts
const client = axios.create
timeout: 10 000 // total timeout in ms
// connect timeout via 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)

Different APIs have different timeouts. LLM query with long text generation – 60+ seconds is normal. Weather request - more than 5 seconds is suspicious.


## Rate limits: how not to get a ban

Rate limit is a limit on the number of requests per period. If you exceed it, the API will return 429 Too Many Requests. Limitation strategies depend on their type.

Types of limits

Тип Пример Стратегия
Запросов в секунду (RPS) 10 req/s Очередь с throttling
Запросов в минуту (RPM) 60 req/min Sliding window counter
Токенов в минуту (TPM) 100k tokens/min Подсчёт перед отправкой
Запросов в день 1000 req/day Счётчик + Redis

Compliance with limits through the queue (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 headings - read them

Most APIs return limit information in response headers:

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: Repeated queries wisely

Not all mistakes need to be retracted. Classification:

Статус Ретраить? Причина
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 Да Временная сетевая проблема

Exponential delay with jitter

Simple retry через 1 секунду does not work well with multiple clients – all retrace synchronously and create a new load wave. Jitter (random shift) solves that.

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 is the best library for 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)

Logistics: what to write and in what format

Chaotic console.log is useless in the analysis of the incident at 3 a.m. Structured logs – you can filter, aggregate, build allerts.

What to log for each request

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
// Example of a structured log:
{"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"}

Such logs are easily pared in any log aggregator (Loki, Datadog, CloudWatch).

Logging levels

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: What to do when the API is not available

The external API will drop. The question is not whether it will happen, but when. A good system degrades gracefully rather than falling with addiction.

Fallback strategies

Cache of the most recent successful response. The easiest and often sufficient option is to return the cached result marked “data may be outdated” if the API does not respond.

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 (automatic breaker) ** If the API repeatedly returns errors - stop trying for a while, do not waste resources on deliberately unsuccessful requests.

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

Spare provider. For critical functions, the second API is reserved.

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

Monitoring: How to know if something is broken

Logs help after the fact. Metrics are real-time.

Basic metrics to be collected

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

## The final wrapper: all together

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

## Checklist of external API connection

plaintext
Keys and security:
API keys only in environment variables, not in code
.env added to .gitignore
● Check the availability of keys at the start of the application
● Different keys for dev and production
● Keys do not fall into logs (sanitizeUrl)

Timeouts:
Installed connect timeout (3-5 seconds)
Installed read timeout for a specific API
● No requests without timeout

Rate limits:
Documentation on the limits of the provider
Implemented throttling if you need RPS control
● Read more about X-RateLimit-Remaining

Retry:
● Only 429, 5xx and network errors are reset.
● Exponential delay with jitter
● Retry-After from the answer headings

Logic:
● Each request is logged: method, URL, status, time
Keys and tokens do not fall into the logs
Slow queries (>2s) are logged as a warning

Fallback:
Cache of the last successful response implemented
Circuit Breaker for Critical APIs
● There is a backup provider for critical functions (if possible)
Graceful degradation: the service does not fall if the API is not available

## The result

Reliable work with an external API is not a single function, but a layer of several mechanisms: secure key storage, timeouts for each type of waiting, retry only for retroactive errors with exponential delay, structured logs for analyzing incidents and fallback on cache when service is not available.

All this can be implemented once as RobustApiClient and reused for any API in the project. Spent 2 hours on writing the wrapper pays off at the first incident, when the external service lies down, and your site continues to work on cache.


*Relevant for axios 1.x, httpx 0.27+, Node.js 22+, Python 3.12+. June 2026. *

$ cd ../ ← back to Integrations and APIs