diff --git a/editorial/agent-rewrites/198.json b/editorial/agent-rewrites/198.json index 034c43f..54ea6d8 100644 --- a/editorial/agent-rewrites/198.json +++ b/editorial/agent-rewrites/198.json @@ -3,5 +3,5 @@ "slug": "editorial-2022-07-practice-poor-network", "title": "Плохая сеть: как не объявить неизвестный результат успехом", "excerpt": "Практический контракт для формы, которая сохраняет черновик, различает попытку и подтверждение и безопасно переживает потерю ответа.", - "contentHtml": "

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

\n

Похожий симптом возникает и без полного offline. Запрос ушёл, сервер мог его принять, но клиент не дождался ответа. Таймаут сообщает только о том, что клиент не получил результат вовремя. Он не доказывает, что предметное действие не произошло. Поэтому цена ошибки появляется на стыке транспорта и бизнес-состояния: приложение превращает «результат неизвестен» в «ошибка» или «успех».

\n

Тезис: успех подтверждает предметная операция, а не кнопка

\n

Интерфейс должен разделять четыре сущности: текущий черновик, логический запрос, отдельную попытку доставки и подтверждение операции. Черновик принадлежит пользователю. Логический запрос описывает одно намерение: сохранить версию 3. Попытка показывает, сколько раз клиент пробовал доставить это намерение. Подтверждение приходит от предметного API и указывает, какую версию оно приняло.

\n

Такое разделение сохраняет отрицательный путь. Если ответ не пришёл, UI не очищает черновик и не показывает success. Он переходит в unknown-outcome, оставляет текст доступным и предлагает проверку или один осознанный повтор. Если позже приходит старый ответ, он не должен стереть новую версию. Поздний payload — это событие, которое нужно классифицировать, а не безусловно применить.

\n

Механизм состояния

\n

Для каждой отправки зафиксируйте draftVersion, requestKey и attemptKey. draftVersion меняется после редактирования. requestKey остаётся одним и тем же для логического сохранения. attemptKey меняется при разрешённом retry. Acknowledgement должен содержать ключ логического запроса и принятую версию. Обработчик сравнивает их с текущим состоянием до очистки черновика.

\n

Ключ запроса не обязан называться именно так. В одном API это может быть operationId, в другом — идемпотency key. Важно назначение: повтор доставки не должен незаметно менять смысл операции. Серверный контракт должен решить, что происходит при повторном ключе. Клиент не может получить идемпотентность из одного только заголовка, если сервер его игнорирует.

\n
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
\"Схема
Схема показывает контракт retry: тот же логический запрос получает новую попытку, а acknowledgement проверяется по ключам и версии. Это иллюстрация состояний, не трасса реального сервиса.
\n

Как читать симптомы

\n

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

\n
Симптом → причина → проверка → действие
СимптомВероятная причинаПроверкаДействие
Зелёный success без ответа APIUI завершает операцию после окончания локального обработчикаСопоставить место показа success с acknowledgement и ключамиПоказывать ожидание до подтверждения; при отсутствии ответа — unknown outcome
После timeout черновик исчезОчистка выполняется до подтвержденияЗаписать draft version до отправки и после ошибки транспортаОставлять persisted draft до принятия конкретной версии
Двойная запись после повторного кликаRetry создаёт новый логический запрос или сервер не распознаёт ключСравнить request key у попыток и поведение API при повтореСохранить logical key; согласовать идемпотентность на сервере
Старый ответ стирает новый текстОбработчик проверяет факт ответа, но не versionДать первой попытке ответить после редактирования второй версииСчитать старый ack stale и сохранить новый draft
Offline блокирует отправку и удаляет текстСостояние сети связано с очисткой формыПроверить ветку без запуска transportОставить черновик; retry разрешать только после явного online-сигнала
\n

Порядок внедрения

\n
  1. Опишите один сценарий: пользователь сохраняет конкретную версию текста, а не абстрактную форму.
  2. Разделите в модели draft, request, attempt, acknowledgement и фазу UI. Не храните их в одном boolean.
  3. Назначьте стабильный логический ключ и новый ключ каждой попытке. Запишите, как API обрабатывает повтор.
  4. Добавьте ветку без acknowledgement. Она должна сохранить черновик и показать неизвестный результат.
  5. Поставьте проверку ключей и версии перед каждым commit, очисткой формы и переходом в success.
  6. Смоделируйте поздний ответ первой попытки после изменения черновика. Убедитесь, что новый текст остался.
  7. Проведите отдельный browser/network сценарий на контролируемом окружении. Запишите условия, а не выдавайте учебный пример за production-результат.
\n

Что считать подтверждением

\n

HTTP-ответ сам по себе не всегда равен предметному подтверждению. Статус показывает результат протокольного обмена, но прикладное действие может требовать идентификатора операции, принятой версии или чтения состояния после записи. Для простого черновика достаточно ответа с ключом запроса и версией. Для платежа, бронирования или выдачи права нужен более строгий контракт и отдельный путь проверки.

\n

Не путайте индикатор navigator.onLine с подтверждением. Он может подсказать, стоит ли планировать новую попытку, но не сообщает, обработан ли уже отправленный запрос. Service worker тоже не является гарантией доставки. Он может помочь с жизненным циклом фоновой работы, однако приложение всё равно должно описать persistence, повтор и подтверждение.

\n

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

\n

Эта схема не решает конфликт двух вкладок, восстановление после удаления данных, безопасность локального черновика или согласование нескольких устройств. Она не делает неидемпотентный endpoint безопасным. Она также не определяет, что делать с чувствительным текстом после выхода пользователя. Эти решения требуют отдельной политики хранения, авторизации и серверного контракта.

\n

Бесконечный retry не является исправлением. Он может умножить операции, нагрузить API и скрыть неизвестный результат. Если сервер не принимает логический ключ, безопаснее оставить черновик и дать пользователю путь ручной проверки, чем обещать автоматическое восстановление. Если подтверждение пришло для старой версии, нельзя удалять новую работу только потому, что payload формально успешен.

\n

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

\n

Изменение готово, если на одном контролируемом сценарии можно показать четыре факта: отсутствие ответа не переводит UI в success; текст сохраняется после неизвестного результата; разрешённый retry сохраняет логический ключ; поздний acknowledgement старой версии не меняет новый черновик. Каждый факт должен быть виден в тесте, журнале переходов или воспроизводимом сценарии с названными условиями. Пока команда может доказать только «кнопка перестала крутиться», контракт не готов.

\n

Проверка должна завершаться не обещанием доступности сети, а наблюдаемым состоянием. Укажите версию черновика, ключ запроса, ключ попытки, phase и причину перехода. Не записывайте в диагностический журнал сам чувствительный текст. Если API не возвращает нужные данные, сначала измените контракт или честно оставьте состояние unknown.

\n

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

\n" + "contentHtml": "

Пользователь нажимает «Сохранить». Кнопка перестаёт крутиться, экран показывает зелёное сообщение, а соединение в этот момент уже потеряло ответ. При следующем открытии формы текст пропадает. При повторном нажатии приложение может создать вторую операцию. Это не косметический дефект интерфейса: человек теряет работу, а команда получает состояние, которое нельзя объяснить одним HTTP-кодом.

\n

Похожий симптом возникает и без полного offline. Запрос ушёл, сервер мог его принять, но клиент не дождался ответа. Таймаут сообщает только о том, что клиент не получил результат вовремя. Он не доказывает, что предметное действие не произошло. Поэтому цена ошибки появляется на стыке транспорта и бизнес-состояния: приложение превращает «результат неизвестен» в «ошибка» или «успех».

\n

Сценарий: ответ потерян после сохранения

\n

Рассмотрим учебный сценарий, который можно воспроизвести на тестовом стенде. В форме уже есть версия 3 черновика. Пользователь нажимает «Сохранить», браузер отправляет запрос, а затем сеть обрывается между сервером и клиентом. Сервер мог записать версию, но экран этого не знает. Через несколько секунд пользователь меняет текст и получает версию 4.

\n

Первое предположение обычно звучит так: «раз ответа нет, сохранение не состоялось». Проверка должна разделить эти события. В журнале клиента фиксируем момент отправки, логический ключ и версию черновика; на сервере ищем тот же ключ. Если запись найдена, повтор запроса не должен создавать новую операцию. Если записи нет, пользователь получает осознанный retry. Пока проверка не выполнена, правильное состояние — unknown-outcome, а не success и не доказанная ошибка.

\n

Контракт: успех подтверждает предметная операция, а не кнопка

\n

Интерфейс должен разделять четыре сущности: текущий черновик, логический запрос, отдельную попытку доставки и подтверждение операции. Черновик принадлежит пользователю. Логический запрос описывает одно намерение: сохранить версию 3. Попытка показывает, сколько раз клиент пробовал доставить это намерение. Подтверждение приходит от предметного API и указывает, какую версию оно приняло.

\n

Такое разделение сохраняет отрицательный путь. Если ответ не пришёл, UI не очищает черновик и не показывает success. Он переходит в unknown-outcome, оставляет текст доступным и предлагает проверку или один осознанный повтор. Если позже приходит старый ответ, он не должен стереть новую версию. Поздний payload — это событие, которое нужно классифицировать, а не безусловно применить.

\n

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

\n

Механизм состояния

\n

Для каждой отправки зафиксируйте draftVersion, requestKey и attemptKey. draftVersion меняется после редактирования. requestKey остаётся одним и тем же для логического сохранения. attemptKey меняется при разрешённом retry. Acknowledgement должен содержать ключ логического запроса и принятую версию. Обработчик сравнивает их с текущим состоянием до очистки черновика.

\n

Ключ запроса не обязан называться именно так. В одном API это может быть operationId, в другом — поле или заголовок Idempotency-Key, если такой контракт действительно реализован сервером. Важно назначение: повтор доставки не должен незаметно менять смысл операции. Для диагностического журнала достаточно ключей, фаз и версий; сам чувствительный текст в него не записываем.

\n
function applyAcknowledgement(state, ack) {\n  if (ack.requestKey !== state.requestKey) {\n    return { ...state, event: 'stale-request' };\n  }\n\n  if (!Number.isInteger(ack.acceptedVersion)) {\n    return { ...state, event: 'invalid-acknowledgement' };\n  }\n\n  if (ack.acceptedVersion < state.draftVersion) {\n    return { ...state, event: 'newer-draft-retained' };\n  }\n\n  if (ack.acceptedVersion > state.draftVersion) {\n    return { ...state, event: 'server-version-not-local' };\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
\"Схема
Схема показывает контракт retry: тот же логический запрос получает новую попытку, а acknowledgement проверяется по ключам и версии. Это иллюстрация состояний, не трасса реального сервиса.
\n

Как читать симптомы

\n

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

\n
Симптом → причина → проверка → действие
СимптомВероятная причинаПроверкаДействие
Зелёный success без ответа APIUI завершает операцию после окончания локального обработчикаСопоставить место показа success с acknowledgement и ключамиПоказывать ожидание до подтверждения; при отсутствии ответа — unknown outcome
После timeout черновик исчезОчистка выполняется до подтвержденияЗаписать draft version до отправки и после ошибки транспортаОставлять сохранённый черновик до принятия конкретной версии
Двойная запись после повторного кликаRetry создаёт новый логический запрос или сервер не распознаёт ключСравнить request key у попыток и поведение API при повтореСохранить logical key; согласовать идемпотентность на сервере
Старый ответ стирает новый текстОбработчик проверяет факт ответа, но не versionДать первой попытке ответить после редактирования второй версииСчитать старый ack stale и сохранить новый draft
Offline блокирует отправку и удаляет текстСостояние сети связано с очисткой формыПроверить ветку без запуска transportОставить черновик; retry разрешать только после явного online-сигнала
\n

Порядок внедрения

\n
  1. Опишите один сценарий: пользователь сохраняет конкретную версию текста, а не абстрактную форму.
  2. Разделите в модели draft, request, attempt, acknowledgement и фазу UI. Не храните их в одном boolean.
  3. Назначьте стабильный логический ключ и новый ключ каждой попытке. Запишите, как API обрабатывает повтор.
  4. Добавьте ветку без acknowledgement. Она должна сохранить черновик и показать неизвестный результат.
  5. Поставьте проверку ключей и версии перед каждым commit, очисткой формы и переходом в success.
  6. Смоделируйте поздний ответ первой попытки после изменения черновика. Убедитесь, что новая версия осталась.
  7. Проведите отдельный browser/network сценарий на контролируемом окружении. Запишите условия, а не выдавайте учебный пример за production-результат.
\n

Что считать подтверждением

\n

HTTP-ответ сам по себе не всегда равен предметному подтверждению. Статус показывает результат протокольного обмена, но прикладное действие может требовать идентификатора операции, принятой версии или чтения состояния после записи. Для простого черновика достаточно ответа с ключом запроса и версией, если сервер гарантирует их связь с сохранением. Для платежа, бронирования или выдачи права нужен более строгий контракт и отдельный путь проверки.

\n

Не путайте индикатор navigator.onLine с подтверждением. Он может подсказать, стоит ли планировать новую попытку, но не сообщает, обработан ли уже отправленный запрос. Service worker тоже не является гарантией доставки: он может помочь с жизненным циклом фоновой работы, однако приложение всё равно должно описать persistence, повтор и подтверждение.

\n

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

\n

Эта схема не решает конфликт двух вкладок, восстановление после удаления данных, безопасность локального черновика или согласование нескольких устройств. Она не делает endpoint без идемпотентного контракта безопасным. Она также не определяет, что делать с чувствительным текстом после выхода пользователя. Эти решения требуют отдельной политики хранения, авторизации и серверного контракта.

\n

Бесконечный retry не является исправлением. Он может умножить операции, нагрузить API и скрыть неизвестный результат. Если сервер не принимает логический ключ, безопаснее оставить черновик и дать пользователю путь ручной проверки, чем обещать автоматическое восстановление. Если подтверждение пришло для старой версии, нельзя удалять новую работу только потому, что payload формально успешен.

\n

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

\n

Изменение готово, если на одном контролируемом сценарии можно показать четыре факта: отсутствие ответа не переводит UI в success; текст сохраняется после неизвестного результата; разрешённый retry сохраняет логический ключ; поздний acknowledgement старой версии не меняет новый черновик. Каждый факт должен быть виден в тесте, журнале переходов или воспроизводимом сценарии с названными условиями. Пока команда может доказать только «кнопка перестала крутиться», контракт не готов.

\n

Проверка должна завершаться не обещанием доступности сети, а наблюдаемым состоянием. Укажите версию черновика, ключ запроса, ключ попытки, phase и причину перехода. Не записывайте в диагностический журнал сам чувствительный текст. Если API не возвращает нужные данные, сначала измените контракт или честно оставьте состояние unknown.

\n

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

\n" }