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

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

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

Marius Voß
DevRel · edge infra

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

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

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

Elido регулирует API с помощью token bucket, привязанного к рабочему пространству. Базовые скорости: 10 запросов в секунду на Free, 30 на Pro и 100 на Business, с burst-ёмкостью 20, 60 и 200. Например, на Pro полный bucket позволяет отправить 60 запросов сразу, прежде чем скорость вернётся к базовым 30 в секунду. Сверху действует отдельный bucket на API-ключ, так что один шумный ключ не съест всё пространство. Большинство задач по созданию ссылок укладываются в burst и вообще не ощущают лимита.

Гадать, когда сработал лимит, не нужно. Отклонённый запрос несёт три сигнала:

  • Retry-After - сколько секунд ждать перед повтором (сейчас 1).
  • X-RateLimit-Scope - какой bucket сработал: workspace или api_key.
  • JSON-тело - {"error":"workspace rate limit exceeded"}, называющее лимит.

Заголовков с остатком токенов нет, поэтому темп регулируйте на стороне клиента: держите параллелизм не выше базовой скорости вашего тарифа, а на первом 429 остановитесь и дождитесь Retry-After, а не мчитесь в стену отказов. Заблаговременный темп держит пропускную способность ровной; слепые повторы после каждого отказа тратят запросы и, если все клиенты повторяют одновременно, порождают thundering herd.

Token bucket, пополняющийся со скоростью рабочего пространства, пока запросы тратят токены, и ответ 429 с заголовком Retry-After, когда bucket пуст

Когда нужно создать много ссылок, не зацикливайте эндпоинт единичного создания. POST /v1/workspaces/{workspace_id}/links/bulk принимает до 100 ссылок в одном запросе. Один bulk-вызов переносит сотню ссылок за один обмен, тогда как сотня одиночных вызовов потратит сотню токенов и большую часть burst. Именно так импорт из 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, чтобы группа воркеров, восстанавливающихся после одного сбоя, не бросилась в атаку в одну ногу. Пока опубликован только Go SDK; SDK для TypeScript, Python и Ruby ещё не в реестрах пакетов, поэтому на этих языках эта небольшая обёртка над обычным HTTP - весь клиент, который вам нужен.

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

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

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

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

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

const res = await withRetry(() =>
  fetch(`https://api.elido.app/v1/workspaces/${WORKSPACE_ID}/links`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.ELIDO_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": `order-${order.id}-link`,
    },
    body: JSON.stringify({ destination_url: order.landingUrl }),
  }),
);
const link = await res.json();

Теперь повтор той же задачи снова несёт 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, чтобы повтор был безопасным. С обычным fetch обёртка выше даёт первые два, а ключ вы поставляете сами:

export async function shortenForOrder(order: Order) {
  const res = await withRetry(() =>
    fetch(`https://api.elido.app/v1/workspaces/${WORKSPACE_ID}/links`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.ELIDO_API_KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": `order-${order.id}-link`,
      },
      body: JSON.stringify({
        destination_url: order.landingUrl,
        tags: [`order:${order.id}`],
      }),
    }),
  );
  if (res.status === 429) {
    // withRetry gave up and we are still limited. Defer the job.
    throw new RetryableJobError(Number(res.headers.get("retry-after")) || 1);
  }
  if (!res.ok) throw new Error(`create failed: ${res.status}`); // non-retryable
  return res.json();
}

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

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

Частые вопросы

Каковы лимиты запросов к API сокращателя URL?

В Elido лимиты действуют на уровне рабочего пространства и зависят от тарифа, по схеме token bucket: 10 запросов в секунду на Free (burst 20), 30 на Pro (burst 60) и 100 на Business (burst 200). Сверху действует отдельный bucket на каждый API-ключ. Когда лимит срабатывает, API возвращает 429 с заголовками Retry-After и X-RateLimit-Scope, указывающим, какой bucket исчерпан, так что клиент знает, сколько ждать. Массовое создание принимает до 100 ссылок за запрос, поэтому bulk-эндпоинт - правильный инструмент для больших задач.

Какие ошибки API стоит повторять?

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

Что такое ключ идемпотентности и когда он нужен?

Ключ идемпотентности - это непрозрачная строка, которую вы отправляете в мутирующем запросе, чтобы сервер мог распознать повтор и вернуть исходный результат, не выполняя операцию дважды. Он нужен всегда, когда создание или обновление может быть повторено - а это всегда, потому что сети сбоят, а очереди задач гарантируют доставку хотя бы раз. Формируйте ключ из стабильного бизнес-идентификатора, например ID заказа, чтобы одна и та же логическая операция всегда несла один и тот же ключ.

Как избежать создания дублирующих коротких ссылок?

Отправляйте ключ идемпотентности, основанный на стабильном бизнес-идентификаторе, при каждом создании. Если вызов повторяется, сервер возвращает изначально созданную ссылку, а не создаёт новую. Для задач, у которых повтор может произойти через несколько дней - за пределами окна кэша идемпотентности - дополнительно сохраняйте ID ссылки, возвращённый при первом успехе, и проверяйте его перед повторным вызовом create.

Как долго действует ключ идемпотентности?

В Elido кэш идемпотентности хранится 24 часа с ключом по паре (рабочее пространство, key). Повтор в пределах этого окна вернёт сохранённый ответ; повтор после него будет воспринят как новый запрос и создаст новый ресурс. Для многодневных пакетных задач не полагайтесь только на ключ - сохраняйте ID результирующей ссылки при первом успехе.

Попробуйте Elido

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

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

Без регистрации. Зарегистрируйтесь бесплатно и получите 50 ссылок в месяц.

Попробуйте Elido

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

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

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