{ "index": 198, "slug": "editorial-2022-07-practice-poor-network", "title": "Плохая сеть: как не объявить неизвестный результат успехом", "excerpt": "Практический контракт для формы, которая сохраняет черновик, различает попытку и подтверждение и безопасно переживает потерю ответа.", "contentHtml": "
Пользователь нажимает «Сохранить». Кнопка перестаёт крутиться, экран показывает зелёное сообщение, а соединение в этот момент уже потеряло ответ. При следующем открытии формы текст пропадает. При повторном нажатии приложение может создать вторую операцию. Это не косметический дефект интерфейса. Человек теряет работу, а команда получает состояние, которое нельзя объяснить одним HTTP-кодом.
\nПохожий симптом возникает и без полного offline. Запрос ушёл, сервер мог его принять, но клиент не дождался ответа. Таймаут сообщает только о том, что клиент не получил результат вовремя. Он не доказывает, что предметное действие не произошло. Поэтому цена ошибки появляется на стыке транспорта и бизнес-состояния: приложение превращает «результат неизвестен» в «ошибка» или «успех».
\nИнтерфейс должен разделять четыре сущности: текущий черновик, логический запрос, отдельную попытку доставки и подтверждение операции. Черновик принадлежит пользователю. Логический запрос описывает одно намерение: сохранить версию 3. Попытка показывает, сколько раз клиент пробовал доставить это намерение. Подтверждение приходит от предметного API и указывает, какую версию оно приняло.
\nТакое разделение сохраняет отрицательный путь. Если ответ не пришёл, UI не очищает черновик и не показывает success. Он переходит в unknown-outcome, оставляет текст доступным и предлагает проверку или один осознанный повтор. Если позже приходит старый ответ, он не должен стереть новую версию. Поздний payload — это событие, которое нужно классифицировать, а не безусловно применить.
Для каждой отправки зафиксируйте draftVersion, requestKey и attemptKey. draftVersion меняется после редактирования. requestKey остаётся одним и тем же для логического сохранения. attemptKey меняется при разрешённом retry. Acknowledgement должен содержать ключ логического запроса и принятую версию. Обработчик сравнивает их с текущим состоянием до очистки черновика.
Ключ запроса не обязан называться именно так. В одном API это может быть operationId, в другом — идемпотency key. Важно назначение: повтор доставки не должен незаметно менять смысл операции. Серверный контракт должен решить, что происходит при повторном ключе. Клиент не может получить идемпотентность из одного только заголовка, если сервер его игнорирует.
function applyAcknowledgement(state, ack) {\n if (ack.requestKey !== state.requestKey) {\n return { ...state, event: 'stale-request' };\n }\n\n if (ack.acceptedVersion < state.draftVersion) {\n return { ...state, event: 'newer-draft-retained' };\n }\n\n if (state.phase === 'confirmed') {\n return { ...state, event: 'duplicate-acknowledgement' };\n }\n\n return {\n ...state,\n phase: 'confirmed',\n confirmedVersion: ack.acceptedVersion,\n event: 'confirmed',\n };\n}\nЭто учебный JavaScript-пример. Он не вызывает fetch, не имитирует задержку и не доказывает, что сервер принял запрос. Его задача — показать безопасный порядок проверки. Сначала сопоставляются идентификаторы. Потом сравниваются версии. Только после этого можно менять видимое состояние. В production нужно связать этот переход с реальным API, хранилищем и журналом событий.
Не начинайте с увеличения таймаута. Сначала назовите наблюдаемый факт. «Пользователь видит успех без подтверждения» полезнее, чем «сеть нестабильна». «Старый ответ очищает новый текст» указывает на гонку версий. «Повтор создаёт две записи» требует проверки серверного контракта, а не только блокировки кнопки.
\n| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| Зелёный success без ответа API | UI завершает операцию после окончания локального обработчика | Сопоставить место показа success с acknowledgement и ключами | Показывать ожидание до подтверждения; при отсутствии ответа — unknown outcome |
| После timeout черновик исчез | Очистка выполняется до подтверждения | Записать draft version до отправки и после ошибки транспорта | Оставлять persisted draft до принятия конкретной версии |
| Двойная запись после повторного клика | Retry создаёт новый логический запрос или сервер не распознаёт ключ | Сравнить request key у попыток и поведение API при повторе | Сохранить logical key; согласовать идемпотентность на сервере |
| Старый ответ стирает новый текст | Обработчик проверяет факт ответа, но не version | Дать первой попытке ответить после редактирования второй версии | Считать старый ack stale и сохранить новый draft |
| Offline блокирует отправку и удаляет текст | Состояние сети связано с очисткой формы | Проверить ветку без запуска transport | Оставить черновик; retry разрешать только после явного online-сигнала |
HTTP-ответ сам по себе не всегда равен предметному подтверждению. Статус показывает результат протокольного обмена, но прикладное действие может требовать идентификатора операции, принятой версии или чтения состояния после записи. Для простого черновика достаточно ответа с ключом запроса и версией. Для платежа, бронирования или выдачи права нужен более строгий контракт и отдельный путь проверки.
\nНе путайте индикатор navigator.onLine с подтверждением. Он может подсказать, стоит ли планировать новую попытку, но не сообщает, обработан ли уже отправленный запрос. Service worker тоже не является гарантией доставки. Он может помочь с жизненным циклом фоновой работы, однако приложение всё равно должно описать persistence, повтор и подтверждение.
Эта схема не решает конфликт двух вкладок, восстановление после удаления данных, безопасность локального черновика или согласование нескольких устройств. Она не делает неидемпотентный endpoint безопасным. Она также не определяет, что делать с чувствительным текстом после выхода пользователя. Эти решения требуют отдельной политики хранения, авторизации и серверного контракта.
\nБесконечный retry не является исправлением. Он может умножить операции, нагрузить API и скрыть неизвестный результат. Если сервер не принимает логический ключ, безопаснее оставить черновик и дать пользователю путь ручной проверки, чем обещать автоматическое восстановление. Если подтверждение пришло для старой версии, нельзя удалять новую работу только потому, что payload формально успешен.
\nИзменение готово, если на одном контролируемом сценарии можно показать четыре факта: отсутствие ответа не переводит UI в success; текст сохраняется после неизвестного результата; разрешённый retry сохраняет логический ключ; поздний acknowledgement старой версии не меняет новый черновик. Каждый факт должен быть виден в тесте, журнале переходов или воспроизводимом сценарии с названными условиями. Пока команда может доказать только «кнопка перестала крутиться», контракт не готов.
\nПроверка должна завершаться не обещанием доступности сети, а наблюдаемым состоянием. Укажите версию черновика, ключ запроса, ключ попытки, phase и причину перехода. Не записывайте в диагностический журнал сам чувствительный текст. Если API не возвращает нужные данные, сначала измените контракт или честно оставьте состояние unknown.
\n