$ sudo teach|
    $ sudo teach|
    IT school
  • Telegram
  • Партнёрам
  • Все курсы
$ sudo teach IT
OOO "SALEPROFIT"Контакты и реквизитыIT-Park Logo

Школа

  • Блог
  • Проверить сертификат

Сотрудничество

  • Стать учителем
  • Партнёрская программа
  • О проекте

Право

  • Оферта
  • Политика конфиденциальности

© 2023–2026 $ sudo teach IT™. All Rights Reserved. Public user contributions licensed under CC BY-SA 4.0 license with attribution required
TelegramGitHubYouTube
ГлавнаяБлогИдемпотентность в бэкенде: как строить безопасные повторные запросы

Идемпотентность в бэкенде: как строить безопасные повторные запросы

$ sudo teach IT
·22 июля 2026 г.·10 мин·57
Идемпотентность в бэкенде: как строить безопасные повторные запросы

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

Содержание
Почему «повтор запроса» неизбеженИдемпотентность: что именно мы хотим гарантироватьСемантика идемпотентностиУровни идемпотентностиКлюч идемпотентности: форма, генерация и правилаКак выглядит ключКто генерирует ключВажное правило: какой запрос считать «тем же»Дедупликация: как именно «не выполнять дважды»Модель храненияБлокировки и уникальные ограниченияПример на SQL (псевдо-реалистичный)Паттерн «одна попытка выигрывает»: кодовый скелетСхема данных (упрощённо)Обработчик запросаДедупликация и ретраи: как не устроить «двойную боль»Разделяйте ошибки на классыСхемы ошибок: что возвращать при повтореНормализация запроса и хэш: предотвращаем ложные конфликтыСрок хранения ключей: компромисс между безопасностью и стоимостьюКонкурентность: две попытки одновременно и гонки записиРежим зависшего processingКогда идемпотентность особенно важна

Повторные запросы — одна из тех проблем, которые всплывают не потому, что «клиенты плохие», а потому что распределённые системы по природе своей не гарантируют доставку и выполнение строго один раз. Клиент может ретраить запрос из‑за таймаута, балансировщик — повторить попытку при отказе upstream, сеть — дублировать пакеты или задерживать подтверждения. В итоге бизнес-операции могут выполняться дважды: списания денег, создание заказов, начисления бонусов, отправка уведомлений.

Идемпотентность — практичный ответ на эту реальность. Она позволяет сделать так, чтобы один и тот же логический запрос, повторённый многократно, приводил к одному и тому же результату. Ниже разберём, как это проектировать в бэкенде: какие бывают ключи идемпотентности, как устроить дедупликацию, как корректно вернуть результат и какие схемы ошибок не ломают клиентскую логику.


Почему «повтор запроса» неизбежен

В идеальном мире каждый HTTP-запрос проходит один путь и один раз попадает в обработчик. В реальности же есть множество причин, по которым сервер может не «увидеть» успешность:

  • Таймауты: клиент не дождался ответа, но запрос на сервере мог успеть выполниться.
  • Разрывы соединения: ответ ушёл, но клиент не получил его из‑за ошибки сети.
  • Ретраи на балансировщике / API gateway: иногда попытки повторяются автоматически при некоторых классах ошибок.
  • Параллельные клиенты: пользователь может отправить действие дважды (двойной клик), а UI/мобильное приложение — повторить из‑за плохой обратной связи.

Суть: клиент может ретраить как из «правильных» соображений (обеспечить устойчивость), так и по «наивности». Ваш сервер должен быть устойчив к обоим сценариям, если бизнес-операции не допускают дублей.


Идемпотентность: что именно мы хотим гарантировать

Важно различать несколько похожих, но разных гарантий.

Семантика идемпотентности

Операция идемпотентна, если выполнение одного и того же логического действия несколько раз приводит к одинаковому конечному состоянию.

На практике для API это означает:

  • Клиент делает запрос с идемпотентным ключом.
  • Повтор с тем же ключом должен:
    • либо вернуть тот же результат (включая статус и тело),
    • либо обеспечить корректный детерминированный эффект, который соответствует «однократному выполнению».

Уровни идемпотентности

Есть два основных подхода:

  1. Математическая идемпотентность (редко доступна): например, операция «установить значение X в поле Y» уже идемпотентна по определению.
  2. Прикладная идемпотентность (самый распространённый): вы добавляете механизм дедупликации на стороне сервера.

Мы сосредоточимся на прикладной идемпотентности, потому что она решает реальный «дубль бизнеса».


Ключ идемпотентности: форма, генерация и правила

Как выглядит ключ

Чаще всего используют заголовок вида:

  • Idempotency-Key: <opaque-string>

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

Ключ должен быть:

  • Уникальным для логического действия клиента.
  • Стабильным на время ретраев.
  • Опробованным на переносимость (например, выдерживать длину/алфавит).

Обычно ключ делают случайным (UUIDv4) на клиенте. Если клиент может определить «логическое действие» детерминированно (например, orderId), можно использовать составной ключ. Однако случайные ключи проще и надёжнее: клиенту не нужно думать о корректности составления.

Кто генерирует ключ

Практически всегда генерация — на клиенте:

  • Клиент знает, что именно он пытается сделать.
  • Клиент может ретраить столько раз, сколько нужно, сохраняя один и тот же ключ.
  • Бэкенд не обязан «угадывать», что два запроса эквивалентны.

На серверной стороне вы лишь валидируете ключ и применяете дедупликацию.

Важное правило: какой запрос считать «тем же»

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

Типовые стратегии:

  1. Жёсткая: если тело отличается — возвращать 409 Conflict или 400 Bad Request.
  2. Мягкая: принимать первый вариант как истинный, а повтор с другим телом отклонять.
  3. Детерминированная: использовать хэш тела и включать его в запись. Тогда один и тот же ключ + тот же хэш = эквивалентный запрос.

Самый распространённый подход: сохранять содержимое (или его хэш) вместе с результатом, а на повтор — проверять соответствие.


Дедупликация: как именно «не выполнять дважды»

Модель хранения

На сервере нужен механизм, который свяжет идемпотентный ключ с результатом операции.

Базовый вариант — таблица:

  • idempotency_key
  • user_id (или tenant)
  • key
  • request_hash
  • status (например: processing, succeeded, failed)
  • response_payload (тело ответа или ссылка на него)
  • response_status (HTTP статус)
  • created_at, expires_at

Один из нюансов: вы хотите гарантировать, что при конкурентных запросах один обработчик «выиграет», а остальные получат либо уже готовый ответ, либо корректное сообщение о том, что обработка ещё идёт.

Блокировки и уникальные ограничения

Самый практичный паттерн — использовать уникальный индекс на (user_id, key):

  • При первом запросе создаёте запись.
  • При повторе получаете запись и возвращаете сохранённый результат.
  • При конкурентных запросах один сможет успешно вставить запись, остальные увидят, что она уже существует.

В SQL это часто реализуют через транзакции и INSERT ... ON CONFLICT.

Пример на SQL (псевдо-реалистичный)

code
-- Уникальность: ключ идемпотентности внутри контекста пользователя/тенанта
CREATE UNIQUE INDEX ux_idempotency_user_key
ON idempotency_requests (user_id, idempotency_key);

При обработке:

  1. Считаете request_hash (например, SHA-256 от нормализованного тела).
  2. Пытаетесь создать запись со статусом processing.
  3. Если создание прошло — выполняете операцию.
  4. Если создание не прошло (ключ уже есть) — возвращаете результат из записи.

Паттерн «одна попытка выигрывает»: кодовый скелет

Рассмотрим пример на Node.js/TypeScript (логика схематична, но рабочие идеи сохраняются). Допустим, у нас есть таблица idempotency_requests и функция db.

Схема данных (упрощённо)

code
CREATE TABLE idempotency_requests (
  idempotency_id BIGSERIAL PRIMARY KEY,
  user_id TEXT NOT NULL,
  idempotency_key TEXT NOT NULL,
  request_hash TEXT NOT NULL,
  status TEXT NOT NULL, -- processing|succeeded|failed
  response_status INT,
  response_body JSONB,
  error_code TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  expires_at TIMESTAMPTZ NOT NULL,
  UNIQUE (user_id, idempotency_key)
);

Обработчик запроса

code
import crypto from "crypto";

function sha256(input: string) {
  return crypto.createHash("sha256").update(input).digest("hex");
}

// Нормализуйте тело перед хэшем, иначе одинаковые запросы могут иметь разный порядок полей
function normalizeBody(body: any): string {
  return JSON.stringify(body, Object.keys(body).sort());
}

async function handleCreateOrder(req: any, userId: string) {
  const key = req.headers["idempotency-key"];
  if (!key) {
    // Для бизнес-операций, которые должны быть идемпотентными, ключ лучше требовать.
    // Если ключ отсутствует — решайте политику отдельно.
    throw new Error("Missing Idempotency-Key");
  }

  const requestHash = sha256(normalizeBody(req.body));
  const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 часа, как пример

  // Попытка "вставить как processing"
  const inserted = await db.idempotency_requests.insertOnConflictDoNothing({
    user_id: userId,
    idempotency_key: key,
    request_hash: requestHash,
    status: "processing",
    expires_at: expiresAt,
  });

  if (inserted) {
    // Мы победили гонку: реально выполняем бизнес-операцию
    try {
      const order = await domain.createOrder(req.body, userId);

      // Сохраняем ответ
      await db.idempotency_requests.updateByUniqueKey(
        { user_id: userId, idempotency_key: key },
        {
          status: "succeeded",
          response_status: 201,
          response_body: { orderId: order.id },
          error_code: null,
        }
      );

      return { status: 201, body: { orderId: order.id } };
    } catch (err: any) {
      // Сохраняем ошибку (важно: что именно вернём на повторе)
      await db.idempotency_requests.updateByUniqueKey(
        { user_id: userId, idempotency_key: key },
        {
          status: "failed",
          response_status: 500,
          response_body: { message: "Internal error" },
          error_code: "INTERNAL_ERROR",
        }
      );

      // Ретрай на клиенте должен либо повторно вернуть ту же ошибку,
      // либо (если ошибка временная) клиент может получить подсказку.
      throw err;
    }
  } else {
    // Ключ уже был использован: возвращаем ранее сохранённый результат
    const row = await db.idempotency_requests.findByUniqueKey({ user_id: userId, idempotency_key: key });

    if (!row) {
      // Непредвиденная ситуация: повтор без записи. Неплохо бы залогировать.
      throw new Error("Idempotency record not found");
    }

    // Проверка, что запрос действительно тот же
    if (row.request_hash !== requestHash) {
      // Клиент переиспользовал ключ, но изменил payload: конфликт семантики.
      return {
        status: 409,
        body: { message: "Idempotency-Key reused with different request body" },
      };
    }

    if (row.status === "processing") {
      // Варианты:
      // 1) Подождать (короткий sleep + повтор чтения)
      // 2) Вернуть 409/425 или 202 с понятным сообщением
      // Для API чаще выбирают 409 или 425 Too Early.
      return {
        status: 425,
        body: { message: "Request is being processed, retry later" },
      };
    }

    if (row.status === "succeeded") {
      return { status: row.response_status, body: row.response_body };
    }

    if (row.status === "failed") {
      // Возвращаем ту же ошибку, чтобы клиент был детерминирован
      return { status: row.response_status, body: row.response_body };
    }

    throw new Error("Unknown idempotency status");
  }
}

Ключевой момент: даже ошибка должна быть идемпотентной с точки зрения повтора. Клиент ретраит — он должен получать согласованный результат и не провоцировать вторичное выполнение бизнес-операции.


Дедупликация и ретраи: как не устроить «двойную боль»

Разделяйте ошибки на классы

Не все ошибки одинаковы.

  1. Временные (network glitch, 503, deadlock, rate limiting в определённых условиях)
    → клиент может ретраить. Но ваш бэкенд при идемпотентности должен избежать повторного выполнения. При этом можно считать допустимым, что результат останется тем же (например, всё равно не удалось).

  2. Постоянные (валидация, 400, 409 по бизнес-правилам)
    → ретрай обычно бесполезен, клиент должен получить ту же ошибку. Идемпотентность здесь важна, чтобы не получить двойной эффект.

  3. Неизвестные / системные (500)
    → особенно важно сохранять итог: если операция успела частично выполниться, вы хотите зафиксировать, что именно было сделано, либо откатить транзакцию.

Схемы ошибок: что возвращать при повторе

Есть несколько тонких мест:

  • Если первый запрос ещё обрабатывается (processing), повтор может прийти до фиксации.
    Возврат 425 Too Early (или 409 Conflict с понятным сообщением) обычно лучше, чем выполнение второй ветки.
  • Если первый запрос завершился ошибкой, повтор должен вернуть ту же ошибку (или эквивалент), иначе клиент получит недетерминированное поведение:
    • иногда он увидит 500,
    • иногда — 200,
    • иногда — другое.
  • Если ошибка была временной, но первый запрос успел записать состояние failed, клиент, ретрая с тем же ключом, не сможет добиться повторного выполнения, потому что вы зафиксировали результат ошибки. Это конфликт бизнес-намерений: клиент хотел повторить.

Как решить? Варианты:

  1. Не фиксировать временные ошибки в статус failed окончательно, а хранить processing дольше или отдельный статус retryable_failed.
  2. Разрешать клиенту менять ключ при ретраях для временных ошибок. Но это требует дисциплины на клиентах.
  3. Хранить детали ошибки и политику: например, failed для постоянных, но retryable для временных с возможностью повторного исполнения через таймер/окно.

На практике часто делают следующее:

  • временные ошибки фиксируются, но с маркировкой retryable,
  • повтор с тем же ключом в пределах «окна допустимых повторов» возвращает тот же статус без повторного эффекта,
  • а после истечения окна политика меняется (либо ключ надо обновить, либо сервер разрешает повторную попытку).

Главное — документировать поведение, чтобы клиент понимал, что делать.


Нормализация запроса и хэш: предотвращаем ложные конфликты

Проверка request_hash полезна, но вызывает проблемы:

  • JSON в разных клиентах может иметь разный порядок полей.
  • Поля могут содержать отличающиеся, но не влияющие на смысл метаданные.

Поэтому важно:

  1. Нормализовать JSON перед хэшем (например, сортировать ключи).
  2. Рассмотреть что именно входит в семантику:
    • если есть незначимые поля (например, clientTimestamp), их можно исключить из хэша;
    • если поля влияют на результат — их нужно включить.

Если вы включаете всё подряд, вы получите сценарии, когда клиент «тот же запрос» считает тем же, а сервер — отличным, и вернёт 409.


Срок хранения ключей: компромисс между безопасностью и стоимостью

Идемпотентность почти всегда требует хранения соответствия ключ → результат, иначе при повторе вы не сможете вернуть консистентный ответ.

Но хранить бессрочно дорого. Поэтому вводят expires_at:

  • типичная практика: от нескольких минут до суток.
  • зависит от SLA ретраев клиентов и вероятности, что ретрай придёт «позже» таймаута.

Подводный камень: если клиент ретраит позже, чем истек срок хранения, он получит повторную обработку как новый запрос — и бизнес может задублироваться.

Решения:

  • увеличивать TTL для критичных операций (платежи — особенно);
  • хранить в «горячем» хранилище дольше, а потом переносить в архив/сжатый вид;
  • документировать клиентам правила: при истечении ключа генерация должна быть новой.

Конкурентность: две попытки одновременно и гонки записи

Самые частые ошибки в реализации идемпотентности:

  • нет уникального ограничения → два воркера одновременно выполняют бизнес-операцию;
  • запись создаётся, но результат не сохраняется атомарно → повтор видит processing вечность;
  • статус обновляется «в два шага» без транзакции → клиент может поймать промежуточное состояние.

Рекомендации:

  • сделать уникальный индекс;
  • оборачивать запись/обновление в транзакцию или использовать атомарные операции;
  • тщательно думать о таймауте обработки: что делать, если воркер упал и запись осталась processing?

Режим зависшего processing

Если процесс упал после создания записи, запись может остаться в processing.

Варианты:

  • периодический воркер переводит processing в failed по тайм-ауту;
  • либо клиенты при 425 Too Early ретраят, но вы должны гарантировать, что зависшее состояние не длится бесконечно.

Когда идемпотентность особенно важна

Список операций, где дубль запроса часто критичен:

  • создание заказов/заявок с биллингом;
  • списание средств / выпуск транзакций;
  • начисления бонусов;
  • отправка писем/смс (в идеале — через outbox + идемпотентные события

Войдите, чтобы поставить лайк и оставить комментарий.

Автор

$ sudo teach IT

Продолжите обучение

Все курсы
Python – для начинающих!

Python – для начинающих!

С нуля до профессионального уровня. Подходит для всех. Учитесь каждый день и овладейте самым популярным языком программирования.

Перейти к курсу
Простыми словами о защищенном соединении

Простыми словами о защищенном соединении

Базовый курс для обычных людей: что такое защищённое соединение, как оно устроено, что оно умеет и чего не умеет, как выбрать сервис и настроить его на своих устройствах.

Перейти к курсу
Ботостроение Telegram

Ботостроение Telegram

Лёгкий, быстрый и доступный способ познакомиться с миром ботостроения в Telegram. Видео, конспекты, практика и помощь – всё у нас на курсе.

Перейти к курсу
Сервис, который не падает - как построить безопасную зашифрованную сеть

Сервис, который не падает - как построить безопасную зашифрованную сеть

Практический курс по безопасности сети: как собрать собственный сервис защищённого доступа - от пустого сервера до подписки с автовыбором - и, что важнее, как потом не уронить его своими руками.

Перейти к курсу

Другие статьи

Python для аналитики на практике: как строить признаки и проверять их воспроизводимость
python

Python для аналитики на практике: как строить признаки и проверять их воспроизводимость

Поймём, где чаще всего ломается воспроизводимость (версии данных, фильтры, дрейф) и как оформить пайплайн признаков так, чтобы результаты совпадали между запусками и окружениями.

17 сентября 2026 г.
1050
Как настроить локальную среду для Python-проекта: venv, pyproject.toml и воспроизводимые зависимости
настроить

Как настроить локальную среду для Python-проекта: venv, pyproject.toml и воспроизводимые зависимости

Разберём, как правильно собрать окружение для разработки и CI: структуры проекта, pyproject.toml, lock-файлы и типовые сценарии воспроизведения зависимостей на другой машине. Параллельно покажем, как избежать “работает у меня”.

1 августа 2026 г.
700
Создание Telegram бота в 2026 легко и просто! Полный курсы!
создание

Создание Telegram бота в 2026 легко и просто! Полный курсы!

19 июня 2026 г.
1431
Как выбрать, куда идти в IT: веб, боты, данные — без хаоса
выбрать

Как выбрать, куда идти в IT: веб, боты, данные — без хаоса

Разберём самые понятные направления для новичка (веб, боты, аналитика/данные) и чем они отличаются по шагам обучения. В конце вы получите простой план, как проверить, что вам подходит, и какой стартовый путь выбрать.

5 октября 2026 г.
580
Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть
путь

Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть

Разберём структуру репозитория, как упаковать проект в понятную историю (проблема → решение → запуск → метрики качества) и какие артефакты повышают доверие: скринкасты, архитектурная схема, чек-лист продакшн-готовности.

17 сентября 2026 г.
1090
Как понять, какой язык программирования учить первым: простой тест и выбор
понять

Как понять, какой язык программирования учить первым: простой тест и выбор

Помогу новичку выбрать первый язык без стресса: объясню варианты по целям (веб, боты, анализ данных), что учить в первую очередь и как не потратить месяцы впустую. В конце — краткий план старта на ближайшие недели.

2 октября 2026 г.
630

Комментарии

Станьте первым в обсуждении

Поделитесь мнением о статье или задайте вопрос автору

Содержание

Почему «повтор запроса» неизбеженИдемпотентность: что именно мы хотим гарантироватьСемантика идемпотентностиУровни идемпотентностиКлюч идемпотентности: форма, генерация и правилаКак выглядит ключКто генерирует ключВажное правило: какой запрос считать «тем же»Дедупликация: как именно «не выполнять дважды»Модель храненияБлокировки и уникальные ограниченияПример на SQL (псевдо-реалистичный)Паттерн «одна попытка выигрывает»: кодовый скелетСхема данных (упрощённо)Обработчик запросаДедупликация и ретраи: как не устроить «двойную боль»Разделяйте ошибки на классыСхемы ошибок: что возвращать при повтореНормализация запроса и хэш: предотвращаем ложные конфликтыСрок хранения ключей: компромисс между безопасностью и стоимостьюКонкурентность: две попытки одновременно и гонки записиРежим зависшего processingКогда идемпотентность особенно важна