{ "index": 196, "slug": "editorial-2022-07-field-poor-network", "title": "Плохая сеть: как сохранить черновик и не перепутать результат запроса", "excerpt": "При обрыве сети интерфейс не знает, дошла ли операция до сервера. Разделяем черновик, попытку и подтверждение, чтобы не потерять текст и не создать опасный повтор.", "contentHtml": "
Пользователь нажимает «Сохранить» в форме, но ответ не приходит. Кнопка остаётся активной, поэтому он нажимает ещё раз. Через минуту появляется ошибка, а после обновления страницы новый текст исчезает. Иногда сервер всё же принял первую попытку, и повтор создал вторую операцию. Цена ошибки — потерянный черновик, дубль действия и спор с пользователем, которому интерфейс показал неверный результат.
\nЗдесь недостаточно увеличить timeout. Нужен явный контракт состояния: отдельно хранить версию черновика, логическую операцию, попытки доставки и подтверждение сервера. Отсутствие ответа означает только «этот клиент не получил подтверждение». Это не доказательство, что сервер ничего не сделал.
\nОбычная форма часто сводит всё к двум флагам: isLoading и isSuccess. Такая модель скрывает порядок событий. Текст может быть сохранён локально, запрос может уйти, соединение может оборваться после отправки, а новый текст может появиться до старого ответа. Один boolean не связывает эти факты.
Нужны как минимум четыре понятия. draftVersion обозначает содержимое, которое человек сейчас видит. requestKey обозначает одну логическую операцию, например «сохранить версию 7». attemptKey обозначает конкретную передачу этой операции. При retry логическая операция и её ключ остаются прежними, а попытка получает новый ключ. phase показывает, что система знает: она ждёт ответ, получила неизвестный результат, получила подтверждение или увидела отказ.
Если пользователь изменил текст, версия становится 8. Поздний ответ для версии 7 не должен очищать форму. Для нового сохранения нужен отдельный логический контекст; старый ответ можно принять только после проверки версии и ключа операции.
\nconst waiting = {\n phase: 'awaiting-ack',\n draftVersion: 7,\n requestKey: 'request-7',\n attemptKey: 'attempt-1',\n acknowledgedVersion: null,\n};\n\nfunction acceptAck(state, ack) {\n if (ack.requestKey !== state.requestKey) return state;\n if (ack.draftVersion !== state.draftVersion) return state;\n return {\n ...state,\n phase: 'acknowledged',\n acknowledgedVersion: ack.draftVersion,\n };\n}\n\nconst newerDraft = { ...waiting, draftVersion: 8 };\nconst staleAck = acceptAck(newerDraft, {\n requestKey: 'request-7',\n draftVersion: 7,\n});\n\nconsole.assert(staleAck.phase === 'awaiting-ack');\nЭто самостоятельная проверка отрицательного пути: acknowledgement старой версии не меняет состояние новой формы. В примере attemptKey нужен для журнала и диагностики. При повторе той же логической операции ответ от любой попытки можно сопоставить по requestKey и версии, если такой контракт задан API. В рабочем коде добавьте проверку формата ответа и тесты на смену фазы.
| Симптом | \nПричина | \nПроверка | \nДействие | \n
|---|---|---|---|
| После клика нет ответа | \nUI смешал ожидание и неизвестный результат | \nПроверить phase, ключи и журнал ответа | \nПоказать «результат неизвестен» и оставить черновик | \n
| Кнопка допускает второй клик | \nНет ограничения повторной отправки | \nСравнить число попыток с числом логических операций | \nЗаблокировать обычный повтор до явного решения | \n
| Старый ответ очищает новый текст | \nОтвет применяют только по времени прихода | \nСопоставить draftVersion и requestKey | \nИгнорировать устаревший ответ | \n
| Ошибка говорит «не сохранено» | \nTransport error выдали за domain result | \nУстановить, был ли получен application acknowledgement | \nРазделить «не подтверждено» и «отклонено» | \n
| Черновик пропадает после offline | \nЛокальная копия создаётся после отправки | \nПроверить момент persistence и ошибку хранилища | \nСохранять до отправки и показывать сбой отдельно | \n
Timeout ограничивает время ожидания клиента. Он не сообщает, успел ли сервер принять запрос и передать его обработчику. Если клиент прервал ожидание, у него есть факт об отсутствии ответа в заданное время, но нет факта о результате предметной операции. Поэтому нельзя превращать timeout в сообщение «операция отменена».
\nВ браузерном fetch() HTTP-ответ с кодом 4xx или 5xx сам по себе не равен сетевой ошибке: клиент получил объект Response. Признать такую операцию отклонённой можно только по контракту endpoint — например, по согласованному статусу и полю ошибки. Обрыв соединения даёт другую границу знания: response может не дойти, хотя запрос уже был принят.
HTTP-метод тоже не даёт универсального разрешения на retry. Для идемпотентного метода повтор одинакового запроса должен иметь тот же предполагаемый эффект, но это не отменяет побочных записей в лог или истории. Для неидемпотентного метода клиенту нужен явно согласованный механизм: ключ операции, проверка результата, безопасный endpoint или ручная сверка. Строка attempt-02 в клиентской модели не становится защитой от дубля автоматически.
Рассмотрим форму с текстом «Вернуть черновик без потери». До отправки приложение записывает локальную копию с версией 7. Затем создаёт request-7 и попытку attempt-1. Соединение обрывается после отправки. Клиент переводит форму в unknown, не удаляет копию и показывает два разных действия: проверить результат или повторить по правилам API.
Если пользователь изменил текст, версия становится 8. Поздний ответ для версии 7 больше не может очистить экран: reducer сравнивает версию перед commit. Это важно не только при плохой сети. Ответ старой попытки может прийти после нового ввода и при обычном соединении.
\nДопустимы четыре разных наблюдения. Подтверждение с совпавшими ключом и версией переводит форму в acknowledged. Ответ с ошибкой валидации переводит её в rejected, если это предусмотрено API. Ответ старой версии игнорируется. Отсутствие ответа оставляет unknown и ведёт к сверке через предусмотренный endpoint, историю операции или оператора. Последний маршрут нельзя выдумывать на уровне UI.
Если API поддерживает безопасный повтор, клиент отправляет ту же логическую операцию с новой попыткой и тем же согласованным ключом. Если API не описывает такую гарантию, UI не должен обещать «повторить безопасно». Он может сохранить черновик, предложить проверить историю или передать сверку оператору. Неизвестный результат — часть контракта, а не неудачный текст кнопки.
\nВ клиентском логе каждая отправка должна иметь request key, attempt key, draft version и phase. Логируйте переходы, а не только exception. Полезная последовательность выглядит так: draft-v7 → awaiting-ack → unknown → retry-attempt-2 → acknowledged-v7. Если вместо неё видна только строка «save failed», расследование снова начнётся с догадки.
На сервере нужен тот же контекст или явное правило корреляции. Проверьте, что обработчик отличает повтор того же ключа от новой операции. Убедитесь, что response содержит версию или другую проверяемую связь с payload. Если backend не возвращает такой контекст, frontend не сможет надёжно защититься от каждого stale response. Это ограничение нужно записать, а не скрывать дополнительным timeout.
\nДля локального теста составьте пять проверяемых переходов. Ввод версии 1 сохраняет копию. Offline блокирует новую попытку. Отсутствие acknowledgement переводит форму в unknown. Retry не стирает копию. Ответ версии 1 после ввода версии 2 не меняет текст. Отдельно проверьте, что подтверждение текущей версии меняет фазу, а HTTP-ошибка становится отказом только по контракту API. Эти тесты проверяют state machine, но не доказывают качество реальной сети или доступность сервера.
\nСтатья не выбирает конкретный HTTP-метод, не проектирует очередь, не обещает фоновой доставки и не вводит серверную идемпотентность. Service worker, IndexedDB, фоновые sync-механизмы и локальное шифрование имеют собственные жизненные циклы и ошибки. Их нельзя добавить как синоним надёжности. Сначала нужен контракт результата, затем конкретная реализация persistence или retry.
\nМодель также не решает конфликт двух вкладок, вход с другого устройства, истёкшую авторизацию, изменение схемы API и ручное исправление уже созданной операции. Если эти случаи возможны, состояние unknown должно вести к отдельному процессу проверки. Нельзя автоматически повторять перевод, заказ или платёж только потому, что UI не увидел ответ.
Если локальное сохранение не удалось, безопасный путь отличается от сетевого. Не показывайте «сохранено на устройстве». Оставьте текст в памяти только до понятного предупреждения, предложите копирование или остановите отправку. Если подтверждение сервера неизвестно, не называйте операцию отменённой. Сначала сохраните данные пользователя и покажите границу знания системы.
\nФорма готова к следующему инженерному шагу, когда для одного сценария можно ответить на пять вопросов: какая версия текста отправлена, какая логическая операция выполняется, какая попытка дала ответ, что именно подтверждает сервер и что увидит человек при отсутствии подтверждения.
\nПроверка считается пройденной, если тест с поздним ответом не меняет новый черновик, offline не удаляет сохранённую копию, неизвестный результат не становится успехом, а повтор использует согласованный с API механизм. Другой инженер должен воспроизвести эти переходы по логу, сетевому mock и тесту без устного пояснения. Если хотя бы один ключ или отрицательный путь не назван, форму нельзя считать защищённой от плохой сети.
\n