Кнопка нажата, а ответа нет: как не потерять действие пользователя и не наделать дублей
- воскресенье, 20 сентября 2026 г. в 00:00:30

У меня планировщик для школьников. Заходят в него в основном с телефона: из школы, где вайфай один на четыре этажа, и из метро по дороге домой.
Ломается это так. Человек жмёт кружок «готово» — кружок не закрашивается. Ошибки нет, спиннера нет, вообще ничего. Он жмёт ещё раз, убирает телефон в карман, а вечером видит задачу невыполненной. Если не повезло — видит две одинаковые.
Речь не про офлайн-приложение: это отдельная большая история с синхронизацией и конфликтами, и я туда не лез. Речь про полсекунды без сети ровно в тот момент, когда запрос уже ушёл, а ответ ещё не вернулся. Если у вас есть веб, который открывают с телефона, и хотя бы одна кнопка, которая что-то меняет, — это про вас тоже.
Запрос ушёл, ответа нет. Для клиента это одно событие — «ошибка сети». На сервере в этот момент могло произойти три разные вещи:
запрос не дошёл, ничего не случилось;
запрос дошёл, работа сделана, а вот ответ потерялся по дороге обратно;
запрос дошёл и упал на середине.
Различить их клиент не может. У него есть таймаут и пустота.
Отсюда и вилка. Не повторять — терять действия людей в первом случае. Повторять — делать работу дважды во втором. В списке задач это две одинаковые строки, в магазине два заказа, в банке два перевода.
Обычно выбирают «не повторять»: дубли заметны и позорны, а пропажу человек спишет на себя. «Наверное, не нажал».
Мне такой размен не нравится. И чинится он не одним приёмом, а тремя — причём первый бесплатный и полезен сам по себе.
Прежде чем городить очереди, стоит посмотреть на собственные эндпоинты. У меня жил вот такой:
@router.post("/tasks/{task_id}/toggle") async def toggle_task(task_id: UUID, ...): if task.is_completed: task = await uncomplete_task(session, user.id, task_id) else: task = await complete_task(session, user.id, task_id) return _row_response(...)
Удобно же: кнопка одна, состояние сервер вычислит сам. Ровно до первого повтора.
Повторите toggle — и он отменит то, что только что сделал. Человек отметил задачу, запрос ушёл дважды (нажал два раза, промахнулся пальцем, или это сделала за него ваша будущая очередь) — и задача снова не выполнена. Со стороны это выглядит как издевательство.
С повторяющимися задачами веселее. У меня при закрытии такой задачи создаётся следующий экземпляр: закрыл «тренировку в понедельник» — появилась тренировка на следующий понедельник. Два toggle подряд дают закрыли → создали следующую → открыли обратно → снова закрыли → создали ещё одну. Две тренировки в календаре, и человек понятия не имеет, откуда.
Лечится тем, что клиент перестаёт говорить «переключи» и начинает говорить, чего он хочет:
@router.post("/tasks/{task_id}/state") async def set_task_state(task_id: UUID, completed: Annotated[str, Form()], ...): want_done = completed.lower() in ("1", "true", "on", "yes") task = await get_task(session, user.id, task_id) if task.is_completed != want_done: task = await (complete_task if want_done else uncomplete_task)( session, user.id, task_id ) return _row_response(...)
Кнопка в шаблоне теперь шлёт желаемое состояние, а не команду:
hx-post="/htmx/tasks/{{ task.id }}/state" hx-vals='{"completed": "{{ "false" if task.is_completed else "true" }}"}'
Разница в две строки. Проверка if task.is_completed != want_done тут не ради экономии похода в базу, а чтобы повтор не создал вторую «тренировку в понедельник».
У меня на это ушло минут двадцать вместе с правкой шаблонов, и я бы начинал с этого, даже если дальше вы ничего делать не станете. Ручки вида «сделай так» переживают повтор бесплатно. Ручки вида «переключи» или «добавь ещё один» — нет.
Но с созданием сущностей фокус не работает. «Добавь задачу „купить тетрадь“» — это не описание состояния, а действие, и две одинаковые задачи здесь совершенно законный результат: может, человек правда добавил две. Сервер сам по себе отличить повтор от намерения не в состоянии. Значит, ему надо помочь.
Идея старая и не моя: пусть клиент придумает запросу имя и повторяет его под тем же именем. Сервер делает работу один раз, а на повторы отдаёт сохранённый ответ. Так работает Idempotency-Key у Stripe, YooKassa и прочих платёжных API — там цена задвоения повыше, чем раздражение.
Хранилище под это — одна таблица:
class IdempotencyKey(Base): __tablename__ = "idempotency_keys" __table_args__ = (UniqueConstraint("user_id", "key", name="uq_idempotency_user_key"),) key: Mapped[str] = mapped_column(String(80), nullable=False) user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) method: Mapped[str] = mapped_column(String(10)) path: Mapped[str] = mapped_column(String(300)) completed_at: Mapped[datetime | None] status_code: Mapped[int | None] body: Mapped[str | None] created_at: Mapped[datetime]
Самое важное здесь не поля, а момент вставки: строка создаётся до того, как начнётся работа. Она и есть замок.
inserted = await session.execute( pg_insert(IdempotencyKey) .values(key=key, user_id=user_id, method=request.method, path=path) .on_conflict_do_nothing(index_elements=["user_id", "key"]) .returning(IdempotencyKey.id) ) row_id = inserted.scalar_one_or_none() await session.commit()
Если row_id пустой, строку создал кто-то другой — и дальше два расклада. Работа уже закончена (completed_at заполнен) — отдаём сохранённый ответ. Работа ещё идёт — отвечаем 409 и Retry-After. Попросить клиента зайти через пару секунд честнее, чем запустить вторую копию той же работы.
Замок тут — строка в таблице, а не блокировка. Транзакция коммитится сразу, до вызова обработчика, так что во время долгой работы ничего не залочено. За «кто первый» отвечает уникальный индекс (user_id, key), и пара там именно из двух полей: с одним только key я подобрал бы чужой ключ и получил чужой ответ — а в ответе HTML с чужими задачами.
Отдельная история — что делать, когда обработчик упал. Если ответ 5xx, работа, скорее всего, не сделана, и повтор должен быть настоящим повтором, а не вежливым «уже выполнено, вот вам пустота». Поэтому ключ отпускаем:
if response.status_code >= 500: await session.delete(row)
«Скорее всего» здесь честное слово, а не фигура речи. Строго говоря, 500 мог случиться уже после того, как задача создалась — и тогда повтор её задвоит. Стопроцентной гарантии без распределённых транзакций не бывает, так что выбор всегда между «иногда дубль после падения сервера» и «иногда молча потерянное действие». Я выбрал первое: дубль человек увидит и удалит, пропажу — нет.
Ещё один случай я сначала не предусмотрел: тот же ключ прилетает на другую ручку. Это не повтор, это ошибка клиента — кто-то переиспользовал ключ. Отвечаю 422, иначе такая ошибка всплывёт через полгода в виде загадочного бага.
Живут ключи сутки. Клиент повторяет неотправленное минуты, в плохом случае часы; через сутки ключ никому не нужен, а таблица растёт с каждым действием в приложении.
DELETE FROM idempotency_keys WHERE created_at < now() - interval '1 day'
Зависимостью FastAPI это не сделать: зависимость видит запрос до обработчика и не может подменить результат, а нам нужен готовый ответ — его же сохранять. Значит, middleware.
И вот тут я потерял время на ровном месте. Пишешь обычный @app.middleware("http"), обращаешься к request.session, чтобы понять, чей это запрос, — и получаешь AssertionError: SessionMiddleware must be installed. Который установлен. Вот же он, строкой выше.
В том-то и дело, что строкой выше. Starlette вставляет каждый следующий middleware снаружи предыдущего: добавленный позже оборачивает добавленных раньше. Мой SessionMiddleware шёл первым, значит все мои @app.middleware("http") оказались снаружи — и выполнялись до того, как сессия вообще появлялась в запросе.
Воспроизводится за минуту:
app.add_middleware(SessionMiddleware, secret_key="x") @app.middleware("http") async def mw(request, call_next): request.session # AssertionError return await call_next(request)
Лечится переносом регистрации на строку выше. Я оставил там комментарий на три строки — ровно для того, кто через полгода будет наводить порядок в main.py и переставит их обратно. Скорее всего, это буду я.
Сервер теперь готов принимать повторы. Осталось, чтобы кто-то их делал.
Этим занимается service worker — он видит все запросы страницы и может ответить на них сам. Логика словами: попробуй отправить; не вышло — положи в коробку и скажи странице, что мы запомнили.
e.respondWith( fetch(req).catch(async () => { const body = await copy.text(); await enqueue({ url: req.url, method: req.method, headers, body, at: Date.now() }); await tellPages({ type: 'outbox', pending: (await allQueued()).length }); if ('sync' in self.registration) { try { await self.registration.sync.register(SYNC_TAG); } catch (err) {} } return new Response('', { status: 202, headers: { 'X-Doday-Queued': '1', 'HX-Reswap': 'none' }, }); }) );
Про странный ответ 202 с заголовком HX-Reswap: none — это не украшение. У меня интерфейс на htmx: кнопка шлёт POST и подставляет пришедший HTML вместо строки задачи. В ответе «положили в очередь» никакого HTML нет, и первая версия честно стёрла строку с экрана. Человек нажал «готово», и задача исчезла. Совсем. HX-Reswap: none говорит htmx, что подставлять ничего не надо: строка остаётся на месте, а страница помечает её как неотправленную — приглушает и подписывает «сохранится, когда появится сеть».
Коробка — IndexedDB. Не потому что она приятнее, а потому что localStorage синхронный и в service worker недоступен в принципе. Копию запроса (req.clone()) надо снять до того, как он ушёл в fetch: тело читается один раз, и в обработчике ошибки читать будет уже нечего.
Порядок отправки важнее, чем кажется. Если сеть отвалилась посреди разбора очереди, надо остановиться, а не перескакивать к следующему: «создать задачу» должно уйти раньше, чем «отметить её выполненной». То же самое при 409 — первый такой же запрос ещё выполняется — и при 5xx. А вот 4xx это окончательный отказ: задачу, например, уже удалили с другого устройства. Такой запрос из очереди выбрасываем, повторять его бессмысленно.
Разбудить воркер, когда сеть вернётся, умеет Background Sync. Только Safari его не поддерживает — ни на маке, ни на айфоне, и своей позиции по спецификации WebKit до сих пор не публиковал. Поставить на iOS другой браузер не поможет: движок там всё равно тот же. Закладываться на этот механизм нельзя, поэтому страница дополнительно слушает online и толкает воркер сама:
window.addEventListener('online', function () { navigator.serviceWorker.ready.then(function (r) { r.active && r.active.postMessage({ type: 'flush' }); }); });
И последнее, без чего всё остальное теряет смысл: человеку надо сказать, что происходит. Молчаливая очередь ничем не лучше молчаливой потери — пользователь в обоих случаях не знает, сохранилось или нет. У меня это полоска внизу экрана: «Одно изменение ждёт сети». Исчезает, когда очередь ушла.
Кстати, сеть — не единственная причина, по которой запрос не доезжает. Свой собственный деплой выглядит для клиента ровно так же: пока приложение перезапускается, прокси отвечает 502. Это не «запрос отвергли», это «до приложения не дошло», и такие ответы тоже надо складывать в коробку:
if ([502, 503, 504].includes(r.status)) return queueIt();
Побочный эффект приятный: раскатка новой версии перестала съедать действия тех, кто в этот момент что-то нажал. Раньше я об этом просто не думал.
Самое обидное я нашёл, когда сел писать браузерный тест. Первая версия обработчика fetch перехватывала все GET-запросы подряд — включая unpkg.com и cdn.tailwindcss.com. Логика была невинная: сходи в сеть, не вышло — отдай из кеша, не вышло — отдай 503.
Пока CDN отвечает, всё прекрасно. А прогонял я тест в окружении, где внешние адреса недоступны. И получил страницу, на которой htmx не загрузился, потому что мой же воркер вернул на него 503. Кнопки перестали быть кнопками.
Сначала я, естественно, подумал на htmx: он же не загрузился, значит, с ним что-то не так. Причина была в трёх строках моего собственного кода, написанных за полчаса до этого.
if (url.origin !== self.location.origin) return;
Одна строка. Чужие домены пусть кеширует браузер, он это умеет лучше. А без неё любой сбой у стороннего CDN превращается в отказ вашего приложения. Причём искать вы его будете у себя — и будете правы.
Серверная часть проверяется скучно и прямолинейно — два одинаковых запроса с одним ключом, и смотрим, сколько задач получилось:
async def test_repeat_with_same_key_creates_one_task(logged_in_client, db_session): payload = {"text": "Купить тетрадь"} headers = {"Idempotency-Key": "key-abc-123"} first = await logged_in_client.post("/htmx/quickadd", data=payload, headers=headers) second = await logged_in_client.post("/htmx/quickadd", data=payload, headers=headers) assert second.text == first.text # ответ тот же, а не новый assert second.headers.get("Idempotency-Replayed") == "1" assert await _task_count(db_session, "Купить тетрадь") == 1
А вот очередь так не проверишь: она целиком живёт в браузере. Playwright умеет выключать сеть у контекста — это ровно то, что нужно:
await ctx.setOffline(true); await circle.click(); // жмём «готово» без сети await ctx.setOffline(false); await page.evaluate(() => window.dispatchEvent(new Event('online')));
Прогон выглядит так:
воркер управляет страницей: true — сеть выключена — [ответ] 202 /htmx/tasks/47583447-.../state значок очереди виден: true | Одно изменение ждёт сети в очереди: ["POST /htmx/tasks/47583447-.../state"] — сеть включена — осталось в очереди: 0
Последнюю проверку я делаю не на экране, а в базе — экран может врать, а is_completed нет:
title | is_completed ----------------------+-------------- Физика: задачи 12–15 | t
Вторым заходом тот же тест гоняется с подменённым ответом 502 — чтобы деплой тоже был покрыт, а не только выключенный вайфай.
Если все действия пользователя — чтение, очередь не нужна вообще. Список, который не открылся, человек откроет ещё раз, хранить нечего.
Если действие имеет смысл только сейчас — тоже не надо. Вход, оплата, код из СМС: запрос, доехавший через сорок минут, в лучшем случае бесполезен. У меня эти пути прямо исключены:
return !/^\/(auth|api\/billing|miniapp\/auth)/.test(url.pathname);
И это не офлайн-синхронизация, я с этого начинал. Тут нет разрешения конфликтов: поменяли задачу на двух устройствах — победит тот запрос, который дошёл позже. Для планировщика нормально, для заметок с совместным редактированием — уже нет, и там вы быстро придёте к CRDT, а это другой масштаб и другая цена.
Если из всего перечисленного делать что-то одно, делайте первое — выкиньте из API ручки вида «переключи». Это двадцать минут работы, и от двойного клика оно спасает независимо от того, доберётесь ли вы когда-нибудь до очереди в браузере.
Код проекта открыт: github.com/SwairIt/doday — там и middleware, и воркер целиком. Сам проект — getdoday.ru.
SwairIt