Самая неприятная ошибка плохой сети выглядит аккуратно: кнопка перестала крутиться, форма закрылась, пользователь уверен, что всё сохранено. Через минуту приходит старый ответ, а в это время уже набран новый текст. Если обработчик не различает попытки, поздний 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 | редактор черновика | при каждом содержательном изменении | новый текст от очистки старой версии |
| requestKey | submit boundary | при новой версии, до первой попытки | смысл одной операции и серверную идемпотентность, если сервис её поддерживает |
| attemptKey | retry guard | при каждой разрешённой попытке | поздний или duplicate reply от неверного commit |
| submissionId | acknowledgement 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 с совпадающими ключами и версией.
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 становятся удобной, но ложной абстракцией.
Маршрут: симптом → причина → проверка → действие
- Симптом. Старая попытка отвечает после retry, дубль ответа повторно закрывает форму или новый текст исчезает после acknowledgement старой версии.
- Причина. UI хранит один «pending» флаг, не связывает reply с attempt key либо считает acknowledgement равным визуальному окончанию handler-а.
- Проверка ключей. В логике состояния найдите request key, attempt key и version. Сравните их в одном месте до каждого commit; не полагайтесь на порядок arrival.
- Проверка guard. Запустите fixture и убедитесь, что retry до unknown phase и retry в declared offline не создают попытку. Затем добавьте аналогичный unit test рядом с реальным reducer или store.
- Действие. Введите phase machine: idle, awaiting acknowledgement, unknown outcome, acknowledged, recovery required. Дайте каждому переходу owner и видимый текст.
- Откат. Если результат неизвестен, оставьте 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-методов и не заменяет прикладной ключ запроса или подтверждение предметной операции.