Elido
7 мин чтенияИнженерия

API сокращателя URL: лимиты запросов, повторные попытки, идемпотентность

Как вызывать API сокращателя URL в продакшне: token-bucket лимиты, какие статус-коды требуют повтора с backoff, и ключи идемпотентности, защищающие от дублей.

Marius Voß
DevRel · edge infra
Token bucket, регулирующий запросы к API; цикл повтора с backoff; ключ идемпотентности, устраняющий дублирующий вызов create - в фирменных цветах Elido

Три эндпоинта, заголовок авторизации, JSON-тело. API сокращателя URL - одна из самых простых интеграций в любом бэклоге, и quickstart позволяет получить рабочую короткую ссылку за несколько минут. Что quickstart пропускает - так это всё, что происходит, когда интеграция работает под нагрузкой: rate limiter начинает отклонять запросы, в середине батча прилетает временный 503, очередь задач доставляет одно и то же сообщение дважды. Допустите ошибки здесь - и получите дублирующие ссылки, потерянную работу и шторм из 429, который делает ситуацию ещё хуже.

Эта статья - компаньон по продакшн-закалке к API quickstart. Она охватывает три механики, отделяющие демо от надёжной интеграции: лимиты запросов и способы регулировать темп, какие ошибки повторять и как выполнять backoff, и ключи идемпотентности, которые не дают повторной попытке создать вторую ссылку. Примеры используют API Elido, но паттерны одинаковы для любого грамотно построенного API сокращателя. Если вы относитесь к коротким ссылкам как к инфраструктуре, управляемой кодом, более широкое обоснование этого подхода - в статье короткие ссылки как Terraform.

Лимиты запросов: token bucket и три заголовка#

Elido регулирует API с помощью token bucket, привязанного к воркспейсу. Опубликованные базовые лимиты составляют 10 запросов в секунду для Free, 100 для Pro, 500 для Business и согласованный потолок для Enterprise. Pro располагает burst-ёмкостью в 200 запросов - то есть полный bucket позволяет отправить 200 запросов одновременно, после чего темп возвращается к базовым 100 в секунду. Большинство задач по созданию ссылок укладываются в burst и вообще не ощущают лимита.

Вам не нужно гадать, где вы находитесь. Каждый ответ содержит три заголовка:

  • X-RateLimit-Limit - текущий потолок в секунду.
  • X-RateLimit-Remaining - оставшиеся токены в текущем окне.
  • X-RateLimit-Reset - Unix-метка времени, когда bucket пополнится.

Хорошо написанный клиент читает X-RateLimit-Remaining и снижает темп до того, как дойдёт до нуля, а не мчится напролом, спотыкается о стену из 429 и реагирует постфактум. Проактивное регулирование обеспечивает ровный throughput; реактивный повтор после каждого отказа тратит round-trip впустую и, если все клиенты повторяют одновременно, порождает thundering herd.

Token bucket, пополняющийся с темпом воркспейса, пока запросы расходуют токены; показаны три заголовка ответа с лимитами; 429 возвращается, когда bucket пуст

Когда вам реально нужно создать тысячи ссылок, не зацикливайте эндпоинт единичного создания. POST /v1/links/bulk принимает до 1000 ссылок в одном запросе и считается за единицу против лимита. Один bulk-вызов перемещает тысячу ссылок ценой одного токена; тысяча одиночных вызовов сжигают тысячу токенов и почти весь ваш burst. Именно bulk-путь позволяет импорту из Google Sheets перемещать ссылки целой кампании без срабатывания лимитера.

429 Too Many Requests - статус, который RFC 6585 зарезервировал именно для этого случая - возвращается со значением retry_after, указывающим, сколько секунд нужно подождать. Соблюдайте его. Это число - точная подсказка от лимитера, когда появится следующий токен, и она точнее любого значения, которое вычислит ваш backoff.

Повторные попытки: какие коды и как выполнять backoff#

Не каждую ошибку стоит повторять, и повтор неправильной ошибки - это путь от маленького сбоя к аварии. Разделите ответы на две группы.

Повторяйте эти, потому что они временные: 429 (слишком быстро), и 500, 502, 503, 504 (серверная или шлюзовая ошибка, которая может исчезнуть сама). Не повторяйте эти, потому что тот же запрос завершится так же: 400 (payload невалиден), 401 (токен отсутствует или неверен), 403 (у токена нет нужного скоупа), 404 (ресурс не найден или не принадлежит вам), 409 (конфликт slug или редактирование с устаревшей версией). Первая группа - «подождать и попробовать снова». Вторая - «исправить код или входные данные». Повтор 400 в тугом цикле превращает баг в DoS-атаку на себя.

Для повторяемых кодов ключевой алгоритм - экспоненциальный backoff с jitter. Чистый экспоненциальный backoff - удвоение задержки на каждой попытке - всё равно синхронизирует клиентов, потому что все клиенты, упавшие в один момент, повторяют попытки тоже в одни и те же моменты. Добавление случайности разбрасывает их. Материал AWS о экспоненциальном backoff и jitter - каноническая ссылка, показывающая, почему jitter-версия резко снижает конкуренцию. Компактная версия на TypeScript:

const RETRYABLE = new Set([429, 500, 502, 503, 504]);

async function withRetry<T>(
  call: () => Promise<Response>,
  max = 5,
): Promise<Response> {
  let attempt = 0;
  while (true) {
    const res = await call();
    if (res.ok || !RETRYABLE.has(res.status) || attempt >= max) return res;

    // Honor server guidance first; otherwise back off exponentially with full jitter.
    const retryAfter = Number(res.headers.get("retry-after"));
    const base =
      Number.isFinite(retryAfter) && retryAfter > 0
        ? retryAfter * 1000
        : Math.min(1000 * 2 ** attempt, 20_000);
    const wait = Math.random() * base; // full jitter
    await new Promise((r) => setTimeout(r, wait));
    attempt++;
  }
}

Три вещи делают это безопасным, а не опасным. Ограничение числа попыток: стойкая ошибка завершается явно, а не крутится бесконечно. Соблюдение Retry-After, когда его присылает сервер, с откатом на вычисляемый backoff только при его отсутствии. И jitter, чтобы группа воркеров, восстанавливающихся после одного сбоя, не бросилась в атаку в одну ногу. Официальные SDK реализуют ту же политику из коробки - @elido/sdk, elido-python и Go-клиент повторяют ровно пять временных кодов с jitter-backoff - и это главная причина предпочесть SDK самодельному HTTP-клиенту.

Есть одно правило, связывающее повторы со следующим разделом: повтор create безопасен только если create идемпотентен. Иначе каждая попытка рискует создать вторую ссылку.

Идемпотентность: как не создать дублирующие ссылки#

Классический сбой выглядит так. Воркер создаёт короткую ссылку, ссылка создана, но 200 не доходит обратно - соединение обрывается на обратном пути. Воркер видит таймаут, считает это провалом и повторяет. Теперь у вас две ссылки для одной кампании. В масштабе дашборд заполняется /foo, /foo-1, /foo-2, и дубли искажают все отчёты ниже по стеку.

Ключи идемпотентности закрывают этот пробел. Отправьте заголовок Idempotency-Key в мутирующем запросе - любую строку до 255 символов - и сервер сохранит ответ под этим ключом. Предъявите тот же ключ снова - получите исходный ответ обратно, с тем же статус-кодом и телом, без повторного выполнения операции. Паттерн тот же, что Stripe документирует для идемпотентных запросов, и это стандартный способ сделать ненадёжную сеть безопасной для записи.

Деталь, от которой зависит всё, - откуда берётся ключ. Не генерируйте случайный ключ на каждую попытку - это бессмысленно, потому что каждый повтор тогда выглядит как новая операция. Формируйте ключ из стабильного бизнес-идентификатора, чтобы одно и то же логическое действие всегда давало один и тот же ключ:

const link = await elido.links.create(
  { destinationUrl: order.landingUrl },
  { idempotencyKey: `order-${order.id}-link` },
);

Теперь повтор той же задачи снова несёт order-12345-link, попадает в сохранённый ответ и возвращает уже существующую ссылку. Ровно одна ссылка на заказ, сколько бы раз очередь ни доставила сообщение. Именно это позволяет безопасно комбинировать приведённый выше backoff-цикл с созданием ссылок: повтор и ключ идемпотентности - две половины одной гарантии.

Очередь задач с гарантией at-least-once, отправляющая два create-запроса с одним и тем же ключом идемпотентности; сервер сохраняет первый ответ и возвращает его для второго запроса, так что создаётся ровно одна ссылка

Две границы, которые нужно помнить. Ключ привязан к воркспейсу: один и тот же ключ в двух разных воркспейсах создаёт две ссылки - это правильно для мультитенантного API, но удивляет команды, считающие ключи глобальными. И кэш не вечен - в Elido он хранится 24 часа с ключом по паре (workspace, key). Повтор в пределах окна выполняет дедупликацию; повтор через три дня, от зависшей задачи, которая наконец дренировала очередь, создаст новую ссылку. Для многодневных батчей не полагайтесь только на ключ. Сохраняйте ID ссылки, возвращённый при первом успехе, и проверяйте его перед повторным вызовом. IETF стандартизирует этот заголовок в черновике Idempotency-Key, и оговорка о 24-часовом окне там тоже упоминается.

Если вы подключаете API-интеграцию сегодня и хотите, чтобы она переживала собственные повторы, начните с бесплатного воркспейса, сгенерируйте сервисный токен и поставьте ключ идемпотентности на самый первый create - лучше, чем добавлять его потом, когда дубли уже появились.

Всё вместе#

Продакшн-готовый вызов create - это три механики в стеке. Регулируйте темп по заголовкам лимита, чтобы 429 был редкостью. Оберните вызов в jitter-backoff, который повторяет только временные коды и соблюдает Retry-After. Несите ключ идемпотентности, основанный на бизнес-ID, чтобы повтор был безопасным. С официальным SDK первые два дают вам бесплатно, а ключ вы поставляете сами:

import { Elido, ElidoRateLimitError } from "@elido/sdk";

const elido = new Elido({ token: process.env.ELIDO_TOKEN! });

export async function shortenForOrder(order: Order) {
  try {
    return await elido.links.create(
      { destinationUrl: order.landingUrl, tags: [`order:${order.id}`] },
      { idempotencyKey: `order-${order.id}-link` },
    );
  } catch (err) {
    if (err instanceof ElidoRateLimitError) {
      // SDK already retried with backoff; we are still limited. Defer the job.
      throw new RetryableJobError(err.retryAfter);
    }
    throw err; // non-retryable: surface it
  }
}

Ничего экзотического. Это та же дисциплина, которой заслуживает любой API с интенсивной записью, применённая к ссылкам. Награда - интеграция, которая делает правильное под нагрузкой, а не тихо портит ваш инвентарь ссылок. Для read-стороны того же API - как вытягивать данные о кликах, не нагружая лимитер, - компромиссы разобраны в вебхуки против поллинга для трекинга кликов, а полная поверхность эндпоинтов - на странице API и SDK и в обзоре решений для разработчиков.

Другие материалы в блоге#

Попробуйте Elido

Вставьте URL - получите короткую ссылку

Без регистрации. Ссылка живёт 30 дней. Зарегистрируйтесь, чтобы оставить её навсегда.

Бесплатно, без регистрации · 2 в день

Попробуйте Elido

URL-сокращатель с хостингом в ЕС: собственные домены, глубокая аналитика, открытый API. Бесплатный тариф - без банковской карты.

Теги
url shortener api rate limits
api idempotency key
retry with exponential backoff
429 too many requests
link shortener api
idempotent requests

Читать дальше