惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Cisco Talos Blog
Cisco Talos Blog
V
V2EX
C
Check Point Blog
GbyAI
GbyAI
D
Docker
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
B
Blog RSS Feed
H
Hackread – Cybersecurity News, Data Breaches, AI and More
N
Netflix TechBlog - Medium
T
Troy Hunt's Blog
博客园 - Franky
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Microsoft Security Blog
Microsoft Security Blog
P
Privacy & Cybersecurity Law Blog
WordPress大学
WordPress大学
The Cloudflare Blog
S
SegmentFault 最新的问题
Latest news
Latest news
Microsoft Azure Blog
Microsoft Azure Blog
P
Proofpoint News Feed
I
InfoQ
博客园 - 【当耐特】
NISL@THU
NISL@THU
A
About on SuperTechFans
T
Tailwind CSS Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
The Hacker News
The Hacker News
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Scott Helme
Scott Helme
雷峰网
雷峰网
C
CXSECURITY Database RSS Feed - CXSecurity.com
Security Latest
Security Latest
V
Vulnerabilities – Threatpost
Security Archives - TechRepublic
Security Archives - TechRepublic
A
Arctic Wolf
Hacker News: Ask HN
Hacker News: Ask HN
N
News and Events Feed by Topic
IT之家
IT之家
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
aimingoo的专栏
aimingoo的专栏
T
Threat Research - Cisco Blogs
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
阮一峰的网络日志
阮一峰的网络日志
SecWiki News
SecWiki News
大猫的无限游戏
大猫的无限游戏
S
Security Affairs
The Register - Security
The Register - Security
www.infosecurity-magazine.com
www.infosecurity-magazine.com
L
LINUX DO - 热门话题
T
Tor Project blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Idempotency keys: 5 граблей, которые мы поймали на проде
Владимир Синявский · 2026-05-27 · via Все публикации подряд на Хабре

Сложный

11 мин

18K

Пятница, 23:47. PagerDuty: “Платёж AmEx, провайдер вернул 5xx три раза подряд, билеты не зарезервированы.” Открываю логи – действительно 3 ответа провайдера с 5xx, ни одной успешной транзакции по нашей базе. Закрываю как временный сбой на стороне провайдера, пишу короткую сводку в дежурный чат и иду досматривать. Через 40 минут второй алерт – уже от ночной поддержки: клиент прислал скрин выписки, 3 списания подряд за одну бронь. У клиента рейс через 6 часов, ему нужна действующая бронь и подтверждение, что он завтра нормально улетит, а не тикет в поддержку.

Мы делали b2b-платформу для деловых поездок: бронь авиа, отели, трансфер, страховка, в финале – оплата корпоративной картой через платежный шлюз. С этой ночи началась история, которая закончилась переписыванием всего платёжного слоя нашего booking-сервиса. По дороге мы поймали 5 граблей, о которых хочу рассказать.

Idempotency key – это контракт: если запрос дойдёт до сервера дважды, операция выполнится один раз. Звучит как простая техническая договорённость. На проде контракт размазан между мобильным клиентом, нашим сервером, базой и внешним карточным провайдером – и каждый участник понимает слово “дважды” по-своему. Клиент считает дублем повторный запрос с тем же UUID. Провайдер – запрос с теми же реквизитами в течение нескольких секунд. База данных вообще не знает про ключи, только про строки в таблице. Сеть ненадёжна: запрос может дойти до провайдера, обработаться и вернуть таймаут на нашей стороне. Мы не знаем, что деньги уже списаны, и повторяем запрос. Двойное списание гарантировано.

Интуитивное решение – генерировать UUID на клиенте, класть в заголовок Idempotency-Key, сохранять ответ в Redis на 24 часа. Покрывает один сценарий из пяти. Остальные четыре выясняются, когда повторы уже летят и откатиться некуда. Я был уверен, что понимаю, как это работает – пока мы с командой не начали разбирать, что именно произошло с теми тремя транзакциями. Оказалось, у нас не было единого ответа даже на вопрос “кто должен генерировать ключ”.

Грабля 1: ключ создаётся клиентом

Первое, что показал постмортем – ключ генерился не там, где надо. В нашем мобильном PWA на Angular был http-интерсептор, который централизованно навешивал Idempotency-Key на каждый исходящий платёжный запрос. На бумаге решение правильное: централизованно, без копипасты по сервисам, разработчики гарантированно не забудут. На практике в коде это выглядело примерно так:

// http-интерсептор: централизованно навешивает Idempotency-Key
@Injectable()
export class IdempotencyInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<unknown>, next: HttpHandler) {
    if (!req.url.includes('/payments')) return next.handle(req);
    const idempotencyKey = crypto.randomUUID(); // BAD: новый UUID на каждом исходящем запросе
    return next.handle(req.clone({ setHeaders: { 'Idempotency-Key': idempotencyKey } }));
  }
}

Метод сервиса pay вызывался один раз на клик – тут всё корректно. А вот в подписке поверх его observable стоял rxjs retry({ count: 3, delay: 500 }) – защита от коротких сетевых сбоев, чтобы коротко-живущие 5xx не доходили до пользователя. На каждой retry-попытке observable переподписывался, http-запрос отправлялся заново, проходил через интерсептор – и crypto.randomUUID() в intercept() генерил новый ключ. Из логов мы потом нашли: тот самый инцидент дал 3 разных UUID в течение полутора секунд. С точки зрения пользователя – одно нажатие. С точки зрения нашей дедупликации – три события.

Лекарство простое, как только понимаешь проблему: ключ должен жить столько же, сколько намерение пользователя. Один ключ на одно нажатие “Оплатить”, а не один на одну отправку. Где именно генерировать – в обработчике клика и пробрасывать в pay() параметром, в локальном хранилище клиента, или на сервере как идемпотентный POST /payment-intents – вопрос архитектуры. Принципиально другое: ключ привязан к намерению пользователя, не к технической попытке. На весь период повторов ключ один, даже если попыток было десять.

Грабля 2: TTL ключа короче окна retry

С генерацией ключа на клиенте разобрались, перешли к серверу. Хранилище ключей идемпотентности было в Redis с ttl 24 часа. На дашборде эта цифра выглядела разумно: за сутки любой сценарий повторов покрыт, новые ключи живут долго, старые освобождают память.

Только service worker нашего PWA об этой цифре не знал. У него своя политика повторов: при потере связи запрос складывается в очередь background sync API, и service worker пытается допослать его, пока связь не вернётся – до недели или дольше, в зависимости от настроек браузера. Это норма: пользователь в самолёте, в метро, в зоне без покрытия – клиенту негде хранить, кроме как у себя.

У нас был случай с менеджером в командировке, который потерял связь, вернулся через 4 дня, открыл приложение – и service worker тихонько дослал застрявшие платежи из background-очереди. К этому моменту Redis-хранилище забыло про эти ключи на трое суток раньше. Сервер обработал запросы как новые. Ушли двойные операции, разбираться пришлось через провайдера.

Лекарство: TTL ключа покрывает максимальное окно повторов клиента плюс запас. Если политика повторов клиента 7 дней, ttl 30 дней – не “слишком долго”, а “правильно”. Память дешевле двойных списаний. Если массив ключей растёт неприемлемо – переноси ключи в надёжное хранилище (Postgres вместо Redis, или S3 плюс быстрый кэш), но не урезай ttl до уровня “хватит на один поход в метро”.

Грабля 3: идемпотентность по намерению vs по запросу

После исправления первой грабли у нас была чистая логика: 1 ключ на 1 нажатие “Оплатить”. Думали, что закрыли тему. Через 2 недели прилетел почти идентичный инцидент. Покопались – на этот раз дело было не в клиенте.

Пользователь нажал “Оплатить” на зависшем фронте. Спиннер крутился, ответа от сервера не было – он нажал ещё раз. Через минуту, всё ещё без ответа, нажал ещё раз. Это типичный паттерн: важная транзакция, медленная сеть, пользователь не уверен, что первое нажатие сработало. С точки зрения трёх http-запросов это были три абсолютно легальных вызова с тремя разными intent-id (мы поправили клиента, новый ключ создавался при каждом отдельном клике). По RFC – три разных намерения. По жизни – одно намерение, оформленное трижды.

Простейшая наивная реализация без какой-либо идемпотентности:

public async Task<PaymentResult> MakePaymentAsync(
    UserId userId, Money amount, BookingId bookingId)
{
    var payment = new Payment(userId, amount, bookingId);
    await db.Payments.AddAsync(payment);
    await db.SaveChangesAsync();

    var providerResult = await paymentProvider.ChargeAsync(payment);
    payment.Apply(providerResult);
    await db.SaveChangesAsync();

    return payment.Result;
}

Три клика – три отдельных платежа в БД, три похода в провайдера, три списания. Чтобы дедуплицировать, инстинктивно хочется навесить дедуп по содержимому запроса – собрать ключ из его полей и сохранить ответ:

public async Task<PaymentResult> MakePaymentAsync(
    UserId userId, Money amount, BookingId bookingId, DateTime requestTimestamp)
{
    var key = Sha256Hash(userId, amount, bookingId, requestTimestamp); // BAD: timestamp ломает идею
    if (await store.TryGetAsync(key) is { } cached)
        return cached;

    var result = await DoChargeAsync(userId, amount, bookingId);
    await store.SetAsync(key, result, TimeSpan.FromHours(24));
    return result;
}

Беда в том, что три клика приходят с тремя разными requestTimestamp – значит и три разных хеша. Дедуп не сработал. Уберите requestTimestamp – будет другая беда: первый платёж провалился soft decline’ом от банка (перегружен эмитент, временный fraud-флаг, проблема на сети ACQ), пользователь через час пробует ещё раз. Тот же userId, amount, bookingId, currency, тот же хеш. Хранилище вернёт закэшированный fail на новое легитимное намерение – пока ключ не истечёт, оплатить эту бронь нельзя.

Классический компромисс со временем в ключе. С requestTimestamp – защита от серии быстрых нажатий не работает. Без requestTimestamp – серия обработана, но честный повтор через час залипает на старом отказе. И там, и там – часть запросов отрабатывает неверно.

Решение, к которому мы пришли: ключ собирается из бизнес-семантики операции с явным окном намерения, а попадание в хранилище идёт атомарно – чтобы два одновременных повтора не проскочили проверку наперегонки.

public async Task<PaymentResult> MakePaymentAsync(
    UserId userId, Money amount, BookingId bookingId)
{
    var intentKey = BuildIntentKey(userId, bookingId, amount);

    // Атомарно: резерв ключа со статусом InProgress, либо получение существующего слота
    var slot = await store.AcquireOrGetAsync(intentKey, TimeSpan.FromDays(30));
    if (slot.IsCompleted) return slot.CachedResult;
    if (slot.IsInProgress) throw new ConflictException("operation already in flight");

    // slot.IsReserved – мы первые, кто застолбил этот ключ
    try
    {
        var payment = new Payment(userId, amount, bookingId);
        await db.Payments.AddAsync(payment);
        await db.SaveChangesAsync();

        var providerResult = await paymentProvider.ChargeAsync(payment);
        payment.Apply(providerResult);
        await db.SaveChangesAsync();

        await store.CompleteAsync(intentKey, payment.Result);
        return payment.Result;
    }
    catch
    {
        await store.ReleaseReservationAsync(intentKey);
        throw;
    }
}

static string BuildIntentKey(UserId u, BookingId b, Money m) =>
    $"pay:{u}:{b}:{m.Amount}:{m.Currency}:{Window5Min(DateTime.UtcNow):O}";

Ключ собирается из четырёх параметров и временного окна:

  • userId – привязка к субъекту

  • bookingId + amount + currency – содержание намерения

  • Window5Min(now) – окно, в котором повторное нажатие считается тем же намерением

Атомарный AcquireOrGetAsync под капотом – это SETNX с TTL в Redis или INSERT ... ON CONFLICT DO NOTHING RETURNING в Postgres. Возвращает один из трёх статусов: завершено (отдаём кэш), в работе (второй параллельный запрос получает 409 и инструкцию повторить через секунду), зарезервировано нами (делаем операцию). Обвязка вокруг бизнес-логики выглядит шумно – в проде это уходит в декоратор/middleware над платёжным сервисом, чтобы методы не разрастались boilerplate-ом.

Через 2 часа после первого платежа за ту же бронь – это уже новое намерение, новый ключ, новая операция. В 5-минутном окне после первого – повтор. Окно 5 минут – компромисс: достаточно, чтобы вместить серию повторов от мобильного клиента и серию быстрых нажатий в зависший UI; недостаточно, чтобы случайно склеить настоящие повторы. Реальный пользователь, делающий 3 осмысленных оплаты одной и той же брони в течение 5 минут – редкая патология, заслуживающая ручного разбирательства, а не автодедупа.

Урок этой грабли – пожалуй, самый важный в посте. Идемпотентность – свойство бизнес-операции, а не http-запроса. RFC задаёт механику заголовка Idempotency-Key, а вот семантику – что считать одной операцией, а что разными – задаёт ваша бизнес-логика. Дедуп на трансляции http-заголовка работает в одном сценарии из пяти; дедуп на бизнес-намерении накрывает повторы, серии нажатий, переотправку из сети и пользовательскую неуверенность одним способом.

Грабля 4: внешний неидемпотентный вызов

Поправили клиента, переделали хранилище на intent-key с атомарным резервом. Внутри нашей системы идемпотентность теперь устроена нормально. И снова прилетел инцидент с двойным списанием – уже третий, к этому моменту мы перестали удивляться.

На этот раз сценарий сложнее. Пользователь нажал 1 раз. Наш intent-key один. Хранилище сказало “такого ещё не было, резервирую за тобой”. Вызвали paymentProvider.ChargeAsync(payment). Платёжный провайдер провёл операцию, списал деньги, отправил ответ. По дороге балансировщик на стороне провайдера отвалился, и наш сервер получил таймаут. Мы пометили операцию как требующую повтора, фоновый процесс через минуту повторил вызов. Провайдер обработал второй вызов как новый – он не понимал наш intent-key. Списались деньги второй раз.

Граница нашей идемпотентности заканчивалась на границе системы. Дальше за неё отвечает провайдер – и многие провайдеры этого не делают.

Лекарство в общем виде – двухфазная фиксация: сначала зарезервировать на стороне провайдера reference, потом по нему провести commit. Reference создаётся идемпотентно (провайдер гарантирует), commit идемпотентен по тому же reference (провайдер гарантирует), а связку reference↔intentKey хранит наша сторона:

// двухфазная фиксация на провайдере, не понимающем наш intentKey
var providerRef = await provider.AcquireReferenceAsync(intentKey);
await store.SaveProviderRefAsync(intentKey, providerRef);
var charge = await provider.CommitAsync(providerRef);
// AcquireReferenceAsync идемпотентен; CommitAsync идемпотентен по ref

Тут, кстати, спрятан ещё один тонкий момент: между Acquire и Save сервер может упасть. Reference у провайдера уже есть, мы про него ещё не знаем – orphaned reference. Закрывается reconciliation-процессом, который периодически сверяет наши записи с провайдерскими и подтягивает потерявшиеся связки. Это уже соседняя тема, в этом посте просто запишем, что такая проблема в принципе существует, out of scope.

Если провайдер поддерживает собственный Idempotency-Key (Stripe, Adyen, CloudPayments – да; некоторые внутренние банковские шлюзы – нет), вариант проще: транслируем наш intentKey в их заголовок, ответственность лежит на стороне провайдера.

Если провайдер не поддерживает ничего из этого – оборачиваем его в свой адаптер с собственным reference-store и реализуем двухфазную модель на свой страх и риск. Это работает, но добавляет новые точки отказа.

Урок: идемпотентность цепочки – это идемпотентность самого слабого звена. Граница системы – граница ответственности. На каждом стыке между сервисами проверяем, что обе стороны понимают одно и то же слово “повтор”.

Грабля 5: response replay

Последняя грабля – самая невидимая. Допустим, всё предыдущее сделано правильно: ключ привязан к намерению, хранилище с длинным ttl, провайдер обёрнут в двухфазку. Через неделю после успешной операции пользователь – или service worker из background-очереди, или браузер, восстановивший вкладку из фона – повторяет запрос с тем же intent-key. Что вернуть?

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

Проблема в том, что между первой операцией и повтором мир мог измениться. Реальный кейс: клиент оплатил бронь авиа, через два дня позвонил в поддержку и отменил – деньги вернули, бронь сняли. Ещё через неделю в его браузере отработал отложенный sync – service worker допослал тот же запрос из очереди (вкладка с зависшей оплатой долго оставалась открытой в фоне). Запрос приходит на сервер с тем же intent-key, хранилище отдаёт закэшированный “success”. Клиентское приложение показывает в истории: “оплата активна, бронь подтверждена”. В нашей же базе этой брони уже нет. Час разборок с поддержкой, пока юзер не понял, что видит призрак из прошлого. Технически идемпотентность не нарушена – вернули тот же ответ, что и в первый раз. но семантически – соврали о текущем состоянии.

Решение в общем виде: явно отделять “это replay” от “это первая попытка”. Вот 2 варианта, как это сделать:

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

Можно отдавать 200 OK со ссылкой на актуальный статус операции: /payments/{id}. Клиент идёт по ссылке, видит текущее состояние, принимает решение. Чуть дороже по запросам, семантически честнее: мы говорим “операция была, иди посмотри что с ней сейчас”.

В итоге response replay – часть контракта идемпотентности, не побочка. Если о нём не думать заранее, в проде он проявится тихо – через UX-баги, не через 500-е ошибки.

Точки отказа на одной картинке

Точки отказа идемпотентности – где может сломаться каждая из 5 граблей по ходу потока.

Точки отказа идемпотентности – где может сломаться каждая из 5 граблей по ходу потока.

sequenceDiagram
    autonumber
    participant C as Клиент (mobile)
    participant S as Сервер
    participant P as Карточный провайдер

    Note over C: Грабля 1 – ключ внутри метода<br/>→ 3 повтора, 3 разных ключа
    C->>S: POST /payments {amount, idempotencyKey}
    Note over S: Грабля 3 – ключ из тела, не из намерения<br/>3 разных http-key → 3 операции
    S->>S: store.AcquireOrGet(key)
    Note over S: Грабля 2 – TTL 24h, retry клиента до недели<br/>через 3 дня ключ истёк
    S->>P: charge(payment)
    Note over P: Грабля 4 – провайдер не понимает наш ключ<br/>ответ потерян → повтор → двойное списание
    P-->>S: result
    S->>S: store.Complete(key, result)
    S-->>C: 200 {result}
    Note over S,C: Грабля 5 – на повторе вернуть закэш. успех<br/>или статус-ссылку с актуальным состоянием?

Чем кончилось

Платёжный слой в итоге переписали довольно радикально. UUID на клиенте выкинули, intent-key собирали уже на сервере. Связали наш intent-key со ссылкой на операцию у провайдера через двухфазную фиксацию. Закэшированный успех на повтор заменили статус-ссылкой. TTL подняли до 30 дней, хранилище перенесли в надёжную БД. Пара эпизодов после релиза всё-таки была – на тестовом окружении забыли поправить флаг отказного режима, потом нашли. Но инциденты такого класса больше не повторялись.

Если перечитать все 5 граблей подряд, видно одно: это 5 частных случаев одной ошибки. Думали про дедупликацию http-запроса, а надо было – про идентификацию намерения пользователя. На уровне транспорта ловятся технические повторы (сети, флапы прокси, перезаливка из локальной очереди клиента) – а человеческое “я нажал три раза, потому что зависло” транспорт не видит, это уже уровень бизнес-операции. Pat Helland в “Idempotence is Not a Medical Condition” формулирует это как разделение слоёв: транспорт не гарантирует единственность доставки, потому что сеть может дублировать пакет в любой момент; приложение может гарантировать единственность эффекта – именно там и должна жить идемпотентность. Кто прибил её к http-заголовку, защищается от не той категории ошибок.


Что почитать

  • Pat Helland – “Idempotence is Not a Medical Condition” (ACM Queue, 2012). Фундаментальный аргумент против привязки идемпотентности к транспортному уровню. Короткая, читается за вечер.

  • Brandur Leach – “Designing robust and predictable APIs with idempotency” (Stripe blog, 2017). Каноничная инженерная реализация в продакшене: какие гарантии, как организован store, как обрабатывать concurrent requests с одним ключом.

  • IETF draft “The Idempotency http Header Field” (Yasskin, Nottingham). Куда движется стандарт. Знать полезно, верить как контракту – рано: статус draft.