diff --git a/editorial/agent-rewrites/196.json b/editorial/agent-rewrites/196.json index c3baa7e..5862c0a 100644 --- a/editorial/agent-rewrites/196.json +++ b/editorial/agent-rewrites/196.json @@ -3,5 +3,5 @@ "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Пользователь нажимает «Сохранить» в форме, но ответ не приходит. Кнопка остаётся активной, поэтому он нажимает ещё раз. Через минуту появляется ошибка, а после обновления страницы новый текст исчезает. Иногда сервер всё же принял первую попытку, и повтор создал вторую операцию. Цена ошибки — потерянный черновик, дубль действия и спор с пользователем, которому интерфейс показал неверный результат.
\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