{ "index": 196, "slug": "editorial-2022-07-field-poor-network", "title": "Плохая сеть: как сохранить черновик и не перепутать результат запроса", "excerpt": "При обрыве сети интерфейс не знает, дошла ли операция до сервера. Разделяем черновик, попытку и подтверждение, чтобы не потерять текст и не создать опасный повтор.", "contentHtml": "

Пользователь нажимает «Сохранить» в форме, но ответ не приходит. Кнопка остаётся активной, поэтому он нажимает ещё раз. Через минуту появляется ошибка, а после обновления страницы новый текст исчезает. Иногда сервер всё же принял первую попытку, и повтор создал вторую операцию. Цена ошибки — потерянный черновик, дубль действия и спор с пользователем, которому интерфейс показал неверный результат.

\n

Здесь недостаточно увеличить timeout. Нужен явный контракт состояния: отдельно хранить версию черновика, логическую операцию, попытки доставки и подтверждение сервера. Отсутствие ответа означает только «этот клиент не получил подтверждение». Это не доказательство, что сервер ничего не сделал.

\n

Разделяем данные и события

\n

Обычная форма часто сводит всё к двум флагам: isLoading и isSuccess. Такая модель скрывает порядок событий. Текст может быть сохранён локально, запрос может уйти, соединение может оборваться после отправки, а новый текст может появиться до старого ответа. Один boolean не связывает эти факты.

\n

Нужны как минимум четыре понятия. draftVersion обозначает содержимое, которое человек сейчас видит. requestKey обозначает одну логическую операцию, например «сохранить версию 7». attemptKey обозначает конкретную передачу этой операции. При retry логическая операция и её ключ остаются прежними, а попытка получает новый ключ. phase показывает, что система знает: она ждёт ответ, получила неизвестный результат, получила подтверждение или увидела отказ.

\n

Если пользователь изменил текст, версия становится 8. Поздний ответ для версии 7 не должен очищать форму. Для нового сохранения нужен отдельный логический контекст; старый ответ можно принять только после проверки версии и ключа операции.

\n
const 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\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Диагностика формы при неустойчивом соединении
СимптомПричинаПроверкаДействие
После клика нет ответаUI смешал ожидание и неизвестный результатПроверить phase, ключи и журнал ответаПоказать «результат неизвестен» и оставить черновик
Кнопка допускает второй кликНет ограничения повторной отправкиСравнить число попыток с числом логических операцийЗаблокировать обычный повтор до явного решения
Старый ответ очищает новый текстОтвет применяют только по времени приходаСопоставить draftVersion и requestKeyИгнорировать устаревший ответ
Ошибка говорит «не сохранено»Transport error выдали за domain resultУстановить, был ли получен application acknowledgementРазделить «не подтверждено» и «отклонено»
Черновик пропадает после offlineЛокальная копия создаётся после отправкиПроверить момент persistence и ошибку хранилищаСохранять до отправки и показывать сбой отдельно
\n

Почему timeout и статус HTTP не дают полного ответа

\n

Timeout ограничивает время ожидания клиента. Он не сообщает, успел ли сервер принять запрос и передать его обработчику. Если клиент прервал ожидание, у него есть факт об отсутствии ответа в заданное время, но нет факта о результате предметной операции. Поэтому нельзя превращать timeout в сообщение «операция отменена».

\n

В браузерном fetch() HTTP-ответ с кодом 4xx или 5xx сам по себе не равен сетевой ошибке: клиент получил объект Response. Признать такую операцию отклонённой можно только по контракту endpoint — например, по согласованному статусу и полю ошибки. Обрыв соединения даёт другую границу знания: response может не дойти, хотя запрос уже был принят.

\n

HTTP-метод тоже не даёт универсального разрешения на retry. Для идемпотентного метода повтор одинакового запроса должен иметь тот же предполагаемый эффект, но это не отменяет побочных записей в лог или истории. Для неидемпотентного метода клиенту нужен явно согласованный механизм: ключ операции, проверка результата, безопасный endpoint или ручная сверка. Строка attempt-02 в клиентской модели не становится защитой от дубля автоматически.

\n
\n\"Схема\n
Схема показывает состояния пользовательского интерфейса и точки проверки. Она не является сетевой трассировкой и не доказывает, что конкретный сервер принял запрос.
\n
\n

Учебный сценарий с поздним ответом

\n

Рассмотрим форму с текстом «Вернуть черновик без потери». До отправки приложение записывает локальную копию с версией 7. Затем создаёт request-7 и попытку attempt-1. Соединение обрывается после отправки. Клиент переводит форму в unknown, не удаляет копию и показывает два разных действия: проверить результат или повторить по правилам API.

\n

Если пользователь изменил текст, версия становится 8. Поздний ответ для версии 7 больше не может очистить экран: reducer сравнивает версию перед commit. Это важно не только при плохой сети. Ответ старой попытки может прийти после нового ввода и при обычном соединении.

\n

Допустимы четыре разных наблюдения. Подтверждение с совпавшими ключом и версией переводит форму в acknowledged. Ответ с ошибкой валидации переводит её в rejected, если это предусмотрено API. Ответ старой версии игнорируется. Отсутствие ответа оставляет unknown и ведёт к сверке через предусмотренный endpoint, историю операции или оператора. Последний маршрут нельзя выдумывать на уровне UI.

\n

Если API поддерживает безопасный повтор, клиент отправляет ту же логическую операцию с новой попыткой и тем же согласованным ключом. Если API не описывает такую гарантию, UI не должен обещать «повторить безопасно». Он может сохранить черновик, предложить проверить историю или передать сверку оператору. Неизвестный результат — часть контракта, а не неудачный текст кнопки.

\n

Порядок действий

\n
    \n
  1. Зафиксируйте наблюдение. Запишите, что видит пользователь: нет ответа, ошибка, дубль, исчезновение текста или поздний успех.
  2. \n
  3. Сохраните вход. Перед отправкой сохраните текущий draft и его версию. Ошибку локального хранилища обработайте отдельно от сетевой ошибки.
  4. \n
  5. Назовите операцию. Создайте ключ, связанный с предметным действием и версией данных. Не меняйте его при retry той же операции.
  6. \n
  7. Назовите попытку. Для каждой передачи создайте новый ключ и запишите время, версию, endpoint и итог наблюдения.
  8. \n
  9. Разделите фазы. Используйте как минимум ожидание подтверждения, неизвестный результат, подтверждение и отказ. Не называйте неизвестное состояние успехом или неуспехом.
  10. \n
  11. Защитите commit. Перед применением ответа сравните ключ операции и версию черновика. Старый ответ не должен менять новую форму.
  12. \n
  13. Проверьте retry. Уточните у API-владельца, допускает ли операция повтор, каким ключом он защищён и сколько попыток разрешено.
  14. \n
  15. Проверьте экран. Пройдите offline, медленное соединение, обрыв после отправки, поздний ответ и изменение текста во время ожидания.
  16. \n
  17. Сохраните отрицательный результат. Если подтверждение нельзя получить, оставьте черновик и объясните, что именно нужно проверить. Не удаляйте данные ради чистого UI.
  18. \n
\n

Что проверять в коде и логах

\n

В клиентском логе каждая отправка должна иметь request key, attempt key, draft version и phase. Логируйте переходы, а не только exception. Полезная последовательность выглядит так: draft-v7 → awaiting-ack → unknown → retry-attempt-2 → acknowledged-v7. Если вместо неё видна только строка «save failed», расследование снова начнётся с догадки.

\n

На сервере нужен тот же контекст или явное правило корреляции. Проверьте, что обработчик отличает повтор того же ключа от новой операции. Убедитесь, что response содержит версию или другую проверяемую связь с payload. Если backend не возвращает такой контекст, frontend не сможет надёжно защититься от каждого stale response. Это ограничение нужно записать, а не скрывать дополнительным timeout.

\n

Для локального теста составьте пять проверяемых переходов. Ввод версии 1 сохраняет копию. Offline блокирует новую попытку. Отсутствие acknowledgement переводит форму в unknown. Retry не стирает копию. Ответ версии 1 после ввода версии 2 не меняет текст. Отдельно проверьте, что подтверждение текущей версии меняет фазу, а HTTP-ошибка становится отказом только по контракту API. Эти тесты проверяют state machine, но не доказывают качество реальной сети или доступность сервера.

\n

Ограничения и безопасный отрицательный путь

\n

Статья не выбирает конкретный HTTP-метод, не проектирует очередь, не обещает фоновой доставки и не вводит серверную идемпотентность. Service worker, IndexedDB, фоновые sync-механизмы и локальное шифрование имеют собственные жизненные циклы и ошибки. Их нельзя добавить как синоним надёжности. Сначала нужен контракт результата, затем конкретная реализация persistence или retry.

\n

Модель также не решает конфликт двух вкладок, вход с другого устройства, истёкшую авторизацию, изменение схемы API и ручное исправление уже созданной операции. Если эти случаи возможны, состояние unknown должно вести к отдельному процессу проверки. Нельзя автоматически повторять перевод, заказ или платёж только потому, что UI не увидел ответ.

\n

Если локальное сохранение не удалось, безопасный путь отличается от сетевого. Не показывайте «сохранено на устройстве». Оставьте текст в памяти только до понятного предупреждения, предложите копирование или остановите отправку. Если подтверждение сервера неизвестно, не называйте операцию отменённой. Сначала сохраните данные пользователя и покажите границу знания системы.

\n

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

\n

Форма готова к следующему инженерному шагу, когда для одного сценария можно ответить на пять вопросов: какая версия текста отправлена, какая логическая операция выполняется, какая попытка дала ответ, что именно подтверждает сервер и что увидит человек при отсутствии подтверждения.

\n

Проверка считается пройденной, если тест с поздним ответом не меняет новый черновик, offline не удаляет сохранённую копию, неизвестный результат не становится успехом, а повтор использует согласованный с API механизм. Другой инженер должен воспроизвести эти переходы по логу, сетевому mock и тесту без устного пояснения. Если хотя бы один ключ или отрицательный путь не назван, форму нельзя считать защищённой от плохой сети.

\n

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

\n" }