DarkRiDDeR14 мин

Плохая сеть: граница между попыткой, подтверждением и черновиком

FrontendАрхитектура

Самая неприятная ошибка плохой сети выглядит аккуратно: кнопка перестала крутиться, форма закрылась, пользователь уверен, что всё сохранено. Через минуту приходит старый ответ, а в это время уже набран новый текст. Если обработчик не различает попытки, поздний payload очищает новый черновик или повторно запускает переход. Цена — потеря работы и состояние, которое невозможно объяснить по одному скриншоту.

Причина обычно не в одном timeout. Экрану не принадлежит знание о том, была ли операция выполнена на другой стороне; ему принадлежит только локальная последовательность: создан черновик, запланирована попытка, принят или не принят acknowledgement. В этой статье механизм описан учебной моделью. В ней нет HTTP, реального server acknowledgement или таймера. Payload — объект JavaScript с пометкой teaching-model-payload-not-server-response, поэтому выводы относятся к contract, а не к сети.

Два ключа отвечают на два разных вопроса

Request key отвечает на вопрос «какую логическую операцию пытается завершить пользователь?». Attempt key отвечает на другой: «какой именно запуск этой операции сейчас ожидает ответ?». При unknown outcome повтор не должен молча создавать новую логическую операцию; ему нужен тот же request key и новый attempt key. Иначе серверная сторона не сможет сопоставить повтор с исходным намерением, а клиент — отличить поздний ответ первого запуска от актуального второго.

Разделение идентификаторов в учебном контракте
ПолеКто создаётКогда меняетсяЧто защищает
draft.versionредактор черновикапри каждом содержательном измененииновый текст от очистки старой версии
requestKeysubmit boundaryпри новой версии, до первой попыткисмысл одной операции и серверную идемпотентность, если сервис её поддерживает
attemptKeyretry guardпри каждой разрешённой попыткепоздний или duplicate reply от неверного commit
submissionIdacknowledgement payload моделипосле признанного подтверждениявидимую связь UI с подтверждённой операцией

Почему retry guard — это не просто disabled button

Кнопку можно временно заблокировать CSS-классом, но contract остаётся дырявым, если другой handler, повторный event или восстановление state могут создать ещё одну попытку. Guard должен находиться возле transition. В модели beginSubmission отказывает, если фаза уже awaiting-ack или unknown-outcome. retryUnknownOutcome проверяет именно unknown phase, объявленное online state и лимит одной повторной попытки. Это решение модели, не универсальная retry policy.

Лимит в одну попытку здесь выбран, чтобы было видно место решения. Продукту может потребоваться другой лимит, ручной confirm или вовсе запрет повтора. Важно не число, а доказуемость: для каждого retry можно назвать request key, attempt key, владелец правила и сообщение пользователю. Цикл «повторяем пока не получится» особенно опасен там, где человек уже не наблюдает экран и где операция имеет внешнее последствие.

Учебный код: unknown outcome не подменяется ошибкой

После первой попытки fixture вызывает markUnknownOutcome. Она не получает exception и не измеряет канал связи. Функция лишь проверяет, что её attempt key совпадает с текущей, меняет фазу на unknown-outcome и сохраняет note о черновике. Следующий retry создаёт attempt-02, но оставляет draft-v1. Такой порядок можно выполнить как обычный JavaScript и проверить без browser sandbox.

const model = createTeachingOfflineSubmit();
writeDraft(model, "Правка профиля");
const first = beginSubmission(model);
markUnknownOutcome(model, first.attemptKey);

// visibleState: "result-unknown"
// Это отсутствие teaching acknowledgement, а не утверждение о сервере.
const retry = retryUnknownOutcome(model);
// requestKey тот же; attemptKey новый; transport не выполняется.

Это специально бедный пример. Он не учит, как вызвать fetch(), не имитирует задержку, не обрабатывает network exception и не утверждает, что сервис обработал request key. Его ценность в другом: reviewer видит, что UI не имеет права перейти в success только из того, что локальный обработчик закончил работу. Переход в success допускает только acknowledgement с совпадающими ключами и версией.

Схема contract: draft version создаёт request key; первая попытка получает attempt-01; при unknown outcome retry использует прежний request key и attempt-02; acknowledgement коммитится только если совпадают оба ключа и accepted version. Старый ответ отмечен как stale и не меняет черновик.
Разделение request и attempt позволяет сделать поздний ответ наблюдаемым, но не опасным для текущего состояния.

Ack — это сообщение, а не зелёная анимация

Acknowledgement модели содержит submissionId, requestKey и acceptedVersion. Эти поля проверяются до commit. Если пришёл ответ от attempt-01, когда UI ждёт attempt-02, результат называется stale-or-invalid-acknowledgement и ничего не меняет. Если та же acknowledgement приходит второй раз после commit, она называется duplicate-acknowledgement. Это не ошибка транспорта и не HTTP-код: это способ не позволить одному и тому же UI transition случиться дважды.

Особенно важна accepted version. Пользователь может продолжить редактирование, пока первоначальная операция ожидает подтверждения. Тогда draft становится версией 2, а acknowledgement относится к версии 1. Очистить текущий текст было бы потерей данных. Fixture оставляет version 2 и показывает acknowledged-newer-draft-retained. Значит, экран одновременно знает две вещи: прежняя операция признана, но новая работа ещё не вошла в неё.

Связь с HTTP без ложного вывода

RFC 9110 использует точное понятие идемпотентности для HTTP-методов, но frontend не должен на этом основании выбирать любые повторы. Метод, тело запроса, предметная операция и поведение сервиса образуют один контракт. Например, даже если transport-level повтор допустим, экрану всё равно нужен способ показать пользователю, к какой версии относится подтверждение. Поэтому requestKey в статье не назван заголовком, endpoint-параметром или готовой схемой API: это роль, которую API и клиент должны согласовать.

Исторический Fetch Review Draft полезен здесь другой границей: он описывает платформенный алгоритм выборки, но не знает бизнес-смысл «сохранить черновик». Переход из transport-level события к domain acknowledgement проект должен сделать сам. Если этот переход не оформлен, любые названия вроде isSuccess становятся удобной, но ложной абстракцией.

Маршрут: симптом → причина → проверка → действие

  1. Симптом. Старая попытка отвечает после retry, дубль ответа повторно закрывает форму или новый текст исчезает после acknowledgement старой версии.
  2. Причина. UI хранит один «pending» флаг, не связывает reply с attempt key либо считает acknowledgement равным визуальному окончанию handler-а.
  3. Проверка ключей. В логике состояния найдите request key, attempt key и version. Сравните их в одном месте до каждого commit; не полагайтесь на порядок arrival.
  4. Проверка guard. Запустите fixture и убедитесь, что retry до unknown phase и retry в declared offline не создают попытку. Затем добавьте аналогичный unit test рядом с реальным reducer или store.
  5. Действие. Введите phase machine: idle, awaiting acknowledgement, unknown outcome, acknowledged, recovery required. Дайте каждому переходу owner и видимый текст.
  6. Откат. Если результат неизвестен, оставьте persisted draft. Не пытайтесь «откатить сервер» без отдельного доменного контракта; сначала дайте человеку безопасно сохранить собственный текст.

Граница service worker и фоновой работы

Service Workers CRD в июле 2022 года описывает событийный worker и прямо требует учитывать, что user agent может остановить его. Это важная причина не строить UI-обещание на предположении «фоновая часть всё завершит». Но из этого не следует запрет использовать service worker в продукте. Следует более узкое правило: если конкретный механизм берёт на себя retry или persistence, его жизненный цикл, очередь, права и результат acknowledgement должны быть видны в собственном contract и тестах.

Учебная модель не создаёт service worker специально. Иначе простой вопрос о владении draft был бы спрятан за API. Когда базовая state machine уже есть, команда может добавить следующий уровень: как реальное хранилище восстанавливает version, как worker передаёт факт доставки в открытый документ, что происходит при обновлении worker и когда пользователь видит «нужно проверить». Каждая из этих веток потребует нового проверяемого артефакта.

Ограничение и следующий шаг

Модель ограничена одним активным logical request и одним разрешённым retry. Она не решает несколько вкладок, auth refresh, очередь изменений, конфликт сервера, CRDT, encryption и настоящий idempotency storage. Она также не доказывает, что network state браузера соответствует строке online. Чем сложнее предметная операция, тем опаснее подменять эти открытые вопросы словом «offline sync».

Следующий шаг — провести contract review одной формы вместе с API-владельцем. Зафиксируйте: что является logical operation, где живёт request key, какой payload подтверждает версию, как называется unknown outcome, кто ограничивает retry и когда черновик можно удалить. После этой таблицы добавьте две проверки: stale acknowledgement не меняет state, acknowledgement старой версии не очищает новый draft. Это уже практическая защита от наиболее дорогой ошибки — необъяснимой потери пользовательского текста.

Историческая граница июля 2022

Источники фиксированы датированными URL: Fetch Review Draft 2021, Service Workers CRD от 12 июля 2022 года и RFC 9110 июня 2022 года. Статья не переносит назад более поздние API и не заявляет, что service worker или HTTP-клиент этой fixture были запущены. Названия phases и ключей — проектные значения, пригодные для обсуждения и unit-проверки.

Проверяемые источники

  • WHATWG Fetch Standard, Review Draft от 19 декабря 2021 года — датированный первичный снимок Fetch Standard. Он задаёт модель request, response и network error; он не превращает отсутствие application acknowledgement в доказательство, что серверное действие не произошло.
  • W3C Service Workers, Candidate Recommendation Draft от 12 июля 2022 года — неизменяемая версия периода: service worker событийный и может быть остановлен user agent. В этой партии service worker намеренно не создаётся; документ используется только как историческая граница платформы.
  • RFC 9110: HTTP Semantics, июнь 2022 года — нормативный RFC, доступный к июлю 2022 года. Он различает свойства HTTP-методов и не заменяет прикладной ключ запроса или подтверждение предметной операции.