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

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

\\n

Тезис: плохая сеть требует не одного большего timeout, а явного контракта состояния. Интерфейс должен отдельно хранить версию черновика, логическую операцию и конкретную попытку доставки. Отсутствие ответа означает только «результат не подтверждён этим клиентом». Это не доказательство, что сервер ничего не сделал.

\\n

Что именно ломается

\\n

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

\\n

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

\\n
type SubmitState = {\\n  phase: 'idle' | 'awaiting-ack' | 'unknown' | 'acknowledged';\\n  draftVersion: number;\\n  requestKey: string | null;\\n  attemptKey: string | null;\\n  acknowledgedVersion: number | 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 { ...state, phase: 'acknowledged', acknowledgedVersion: ack.draftVersion };\\n}
\\n

Код — учебный пример. Он не выполняет HTTP-запрос, не создаёт idempotency key на сервере и не заменяет интеграционный тест. Его задача — показать отрицательный путь: неподходящий ответ не меняет состояние. В рабочем приложении правила сопоставления должны совпадать с API-контрактом, а не жить только в компоненте.

\\n

Симптом → причина → проверка → действие

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

Почему timeout не решает проблему

\\n

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

\\n

Поэтому увеличивать timeout можно только после проверки причин. Длинное ожидание может ухудшить UX и увеличить число повторных кликов. Короткое ожидание может быстрее перевести форму в unknown, но это полезно только при сохранённом черновике и понятном маршруте восстановления. Transport-level ошибка и application-level отказ также различаются. Например, HTTP-ответ с ошибкой валидации сообщает, что сервер обработал запрос и отклонил данные. Обрыв соединения этого не сообщает.

\\n

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

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

Учебный сценарий

\\n

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

\\n

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

\\n

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

\\n

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

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

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

\\n

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

\\n

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

\\n

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

\\n

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

\\n

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

\\n

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

\\n

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

\\n

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

\\n

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

\\n

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

\\n

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

\\n" }