{ "index": 196, "slug": "editorial-2022-07-field-poor-network", "title": "Плохая сеть: как сохранить черновик и не перепутать результат запроса", "excerpt": "При обрыве сети интерфейс не знает, дошла ли операция до сервера. Разделяем черновик, попытку и подтверждение, чтобы не потерять текст и не создать опасный повтор.", "contentHtml": "
Пользователь нажимает «Сохранить», но ответ не приходит. Кнопка остаётся активной, поэтому он нажимает ещё раз. Через минуту появляется ошибка, а после обновления страницы новый текст исчезает. Иногда сервер всё же принял первую попытку, и повтор создаёт вторую операцию. Цена ошибки — потерянный черновик, дубль действия и спор с пользователем, которому интерфейс показал неверный результат.
\\nТезис: плохая сеть требует не одного большего timeout, а явного контракта состояния. Интерфейс должен отдельно хранить версию черновика, логическую операцию и конкретную попытку доставки. Отсутствие ответа означает только «результат не подтверждён этим клиентом». Это не доказательство, что сервер ничего не сделал.
\\nОбычная форма часто сводит всё к двум флагам: isLoading и isSuccess. Такая модель скрывает важное различие. Пользовательский текст может быть сохранён локально, запрос может быть отправлен, ответ может потеряться, а новый текст может появиться до прихода старого ответа. Один boolean не связывает эти события.
Нужно разделить три объекта. draftVersion обозначает содержимое, которое человек сейчас видит. requestKey обозначает одну логическую операцию, например «сохранить этот черновик». attemptKey обозначает конкретную передачу этой операции. При retry логическая операция может остаться прежней, а попытка должна измениться. Ответ обязан содержать или позволять сопоставить версию и операцию. Иначе поздний ответ может закрыть форму или очистить уже новый текст.
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| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| После клика нет ответа | UI смешал ожидание и неизвестный результат | Проверить phase, requestKey, attemptKey и журнал ответа | Показать «результат неизвестен» и оставить черновик |
| Кнопка допускает второй клик | Нет ограничения повторной попытки | Сравнить число отправок с числом логических операций | Заблокировать обычный повтор до явного решения |
| Старый ответ очищает новый текст | Ответ проверяют только по времени прихода | Сопоставить draftVersion и requestKey перед commit | Игнорировать stale response |
| Ошибка говорит «не сохранено» | Transport error выдали за domain result | Установить, был ли application acknowledgement | Разделить «не подтверждено» и «отклонено» |
| Черновик пропадает после offline | Локальная копия создаётся после отправки | Проверить момент persistence и ошибку хранилища | Сохранять до отправки и показывать сбой persistence отдельно |
Timeout ограничивает время ожидания клиента. Он не отменяет уже принятую сервером операцию и не сообщает, обработал ли её worker. Если клиент получил timeout, у него есть факт об отсутствии ответа в заданное время. У него нет факта о результате предметной операции.
\\nПоэтому увеличивать timeout можно только после проверки причин. Длинное ожидание может ухудшить UX и увеличить число повторных кликов. Короткое ожидание может быстрее перевести форму в unknown, но это полезно только при сохранённом черновике и понятном маршруте восстановления. Transport-level ошибка и application-level отказ также различаются. Например, HTTP-ответ с ошибкой валидации сообщает, что сервер обработал запрос и отклонил данные. Обрыв соединения этого не сообщает.
HTTP-метод тоже не даёт полного решения. Идемпотентность метода — свойство семантики HTTP, но дубль предметной операции зависит от API. Если endpoint создаёт платёж, заказ или запись, серверу может понадобиться собственный ключ идемпотентности и политика хранения результата. Строка attempt-02 в клиентской модели не становится такой защитой автоматически.
Рассмотрим форму с текстом «Вернуть черновик без потери». До отправки приложение записывает локальную копию с версией 7. Затем создаёт логическую операцию request-7 и попытку attempt-1. Соединение обрывается после отправки. Клиент переводит форму в unknown, не удаляет локальную копию и показывает два действия: проверить результат или повторить по правилам API.
Если пользователь изменил текст, версия становится 8. Поздний ответ для версии 7 больше не может очистить экран: reducer проверяет версию перед commit. Это важнее самого порядка событий. В распределённой системе ответ старой попытки может прийти после нового ввода даже при нормальном соединении.
\\nЕсли API поддерживает безопасный повтор, клиент отправляет ту же логическую операцию с новой попыткой и тем же согласованным ключом операции. Если API не описывает такую гарантию, UI не должен обещать «повторить безопасно». Он может сохранить черновик, предложить открыть историю или передать проверку оператору. Отрицательный путь — не дефект текста кнопки. Это часть контракта.
\\nВ клиентском логе каждая отправка должна иметь request key, attempt key, draft version и phase. Логируйте переходы, а не только exception. Полезная последовательность выглядит так: draft-v7 → awaiting-ack → unknown → retry-attempt-2 → acknowledged-v7. Если вместо неё видна только строка «save failed», расследование снова начнётся с догадки.
В серверном логе нужен тот же контекст или явное правило корреляции. Проверьте, что обработчик отличает повтор того же ключа от новой операции. Убедитесь, что ответ содержит версию или другую проверяемую связь с payload. Если backend не возвращает такой контекст, frontend не сможет надёжно защититься от каждого stale response. Это ограничение нужно записать, а не скрывать дополнительным timeout.
\\nДля локального теста достаточно нескольких переходов. Ввод версии 1 сохраняет копию. Offline блокирует новую попытку. Отсутствие acknowledgement переводит форму в unknown. Retry не стирает копию. Ответ версии 1 после ввода версии 2 не меняет текст. Эти тесты проверяют state machine. Они не доказывают качество реальной сети, работу браузера или доступность сервера.
\\nСтатья не выбирает конкретный HTTP-метод, не проектирует очередь, не обещает фоновой доставки и не вводит серверную идемпотентность. Service worker, IndexedDB, фоновые sync-механизмы и локальное шифрование имеют собственные жизненные циклы и ошибки. Их нельзя добавить как синоним надёжности. Сначала нужен контракт результата, затем конкретная реализация persistence или retry.
\\nМодель также не решает конфликт двух вкладок, вход с другого устройства, истёкшую авторизацию, изменение схемы API и ручное исправление уже созданной операции. Если эти случаи возможны, состояние unknown должно вести к отдельному процессу проверки. Нельзя автоматически повторять перевод, заказ или платёж только потому, что UI не увидел ответ.
Если локальное сохранение не удалось, безопасный путь отличается от сетевого. Не показывайте «сохранено на устройстве». Оставьте текст в памяти только до понятного предупреждения, предложите копирование или остановите отправку. Если подтверждение сервера неизвестно, не называйте операцию отменённой. Сначала сохраните данные пользователя и покажите границу знания системы.
\\nФорма готова к следующему инженерному шагу, когда для одного сценария можно ответить на пять вопросов: какая версия текста отправлена, какая логическая операция выполняется, какая попытка дала ответ, что именно подтверждает сервер и что увидит человек при отсутствии подтверждения.
\\nПроверка считается пройденной, если тест с поздним ответом не меняет новый черновик, offline не удаляет сохранённую копию, неизвестный результат не становится успехом, а повтор использует согласованный с API механизм. Другой инженер должен воспроизвести эти переходы по логам и тесту без устного пояснения. Если хотя бы один ключ или отрицательный путь не назван, форму нельзя считать защищённой от плохой сети.
\\n