{ "index": 47, "slug": "editorial-2026-09-mechanism-frontend-backend-boundary", "title": "Состояние экрана не угадывают по статусу: разделяем query, command и ошибку", "excerpt": "Как провести границу между UI и API: отличить чтение модели от команды, разобрать 202, 204, 409, 422, 429 и 5xx и не показать пользователю состояние, которого сервер не подтвердил.", "contentHtml": "
Кнопка показывает «Готово», но после обновления страницы заказ снова выглядит незавершённым. В другой версии того же дефекта любой отказ превращается в красное сообщение «Что-то пошло не так». Пользователь повторяет команду, оператор ищет причину по снимку интерфейса, а команда спорит, сломан ли браузер или API. Цена ошибки — дубликаты операций, потерянные изменения и неверное решение по инциденту.
\nПричина обычно не в самом HTTP-вызове. UI смешивает три разных смысла: чтение представления ресурса, отправку команды и объяснение отказа. Надёжная граница оставляет право менять модель экрана только за проверенным payload. Query читает данные. Command просит изменить состояние. Ответ об ошибке сообщает, почему переход не состоялся и какой следующий шаг допустим. Статус помогает выбрать ветку, но не заменяет проверку тела и контракта.
\nДо написания обработчика нужно закончить фразу «после этого ответа сервер подтвердил…». Для GET это может быть представление заказа на момент чтения. Для POST — факт принятия команды, созданный ресурс или новое представление. Для PUT — результат замены при соблюдении условий. Для фоновой операции — только постановка в обработку. Если команда не вернула представление, локальный флаг isSuccess не становится новой доменной моделью.
У ответа есть как минимум четыре независимые части: HTTP-статус, заголовки, тип содержимого и тело. Успешный статус не гарантирует, что тело подходит экрану. Отсутствие тела не говорит, нужно ли очищать форму, показывать старую модель или делать query заново. Error response тоже не следует превращать в произвольную строку: UI должен получить стабильный код и локализовать его в своём слое.
\n200 OK. В ответе обычно есть представление, но клиент всё равно проверяет Content-Type и форму JSON. Только после runtime-проверки данные можно записать в store. Если API договорилось о пустом 200, это должно быть явно описано; иначе пустое тело — не повод угадывать состояние.
202 Accepted. Запрос принят для обработки, но обработка ещё не завершена. Даже успешный 202 не означает, что заказ уже изменён и его можно рисовать как изменённый. Контракт должен дать идентификатор операции, ссылку на её статус или правило повторного чтения. Без этого UI показывает «запрос принят», а не «результат готов».
\n204 No Content. Запрос успешно выполнен, но в ответе нет дополнительного содержимого. JSON-парсер здесь запускать нельзя. После 204 приложение может оставить известную модель, инвалидировать её или выполнить query — выбор зависит от операции и должен быть записан в контракте. HTTP сам по себе не выбирает поведение экрана.
\n409 Conflict. Запрос не завершён из-за конфликта с текущим состоянием ресурса. Типичный случай — клиент отправил старую версию заказа после того, как его изменил другой участник. Безопасное действие — перечитать ресурс и дать пользователю осознанно выбрать дальнейший шаг. Автоматическая повторная отправка того же payload сохраняет конфликт и может скрыть чужое изменение.
\n422 Unprocessable Content. Сервер понял тип содержимого и синтаксис запроса, но не смог выполнить содержащуюся инструкцию. Для формы это обычно прикладная ошибка: значение допустимо по JSON-схеме, но нарушает правило предметной области. Ошибка должна попасть в модель полей или в понятный код правила, а не в общий сетевой toast.
\n429 Too Many Requests. Сервер ограничил частоту запросов. Ответ может содержать Retry-After, причём это либо количество секунд, либо HTTP-дата. Клиент не должен запускать бесконечный retry: он ограничивает число попыток, учитывает паузу и сохраняет введённые данные. Для команды с неизвестным исходом повтор разрешён только при отдельном договоре об идемпотентности.
5xx. Ошибка сервера или посредника не доказывает, что команда не была выполнена. После timeout, 502 или 504 результат POST может быть неизвестен. Повтор возможен для операции, которая идемпотентна по контракту и имеет бюджет попыток. 501 или 505 не следует автоматически считать временным сбоем. В сомнении UI показывает ожидание или предлагает узнать результат, а не создаёт вторую команду.
\nУдобный контракт для каждой операции отвечает на пять вопросов: какой метод и ресурс участвуют, какой payload считается валидным, какая модель приходит при каждом поддержанном успехе, какие коды ошибки может обработать UI и как узнать итог после потери ответа. В OpenAPI это выражается операцией с перечисленными responses, схемами содержимого и описанием заголовков. Документ не делает runtime-проверку, но не оставляет варианты ответа невидимыми для команды.
Для редактирования заказа полезно отделить версию от полей формы. Query возвращает order и version. Command отправляет изменённые поля вместе с условием, например If-Match или числовой версией. Backend сравнивает условие с текущим ресурсом. Если оно устарело, он возвращает конфликт; frontend не затирает store ответом, который относится к старой версии.
Problem Details даёт переносимую форму ошибки: type, title, status, detail и расширения. Главным идентификатором проблемы служит type, а status внутри JSON носит справочный характер и не должен переопределять реальный HTTP-статус. Поле detail предназначено для объяснения конкретного случая; его нельзя без фильтра показывать пользователю и нельзя использовать как стабильный ключ локализации.
Ниже — небольшой TypeScript-подобный адаптер без сети. В нём специально оставлены функции isOrder, isProblem и isValidationProblem: их нужно реализовать схемой конкретного API. Пример проверяет тип содержимого, различает принятую и завершённую команду и закрывает неизвестную ветку. Он не объявляет операцию успешной по одному числу.
type Decision =\n | { kind: 'render'; order: Order }\n | { kind: 'accepted'; operationId: string }\n | { kind: 'read-again'; reason: string }\n | { kind: 'field-error'; fields: Record<string, string> }\n | { kind: 'retry-later'; waitSeconds?: number }\n | { kind: 'unknown'; reason: string };\n\nfunction retryDelay(value: string | null): number | undefined {\n if (!value) return undefined;\n if (/^\\d+$/.test(value)) return Number(value);\n const timestamp = Date.parse(value);\n return Number.isNaN(timestamp)\n ? undefined\n : Math.max(0, Math.ceil((timestamp - Date.now()) / 1000));\n}\n\nasync function decide(response: Response): Promise<Decision> {\n const type = response.headers.get('content-type') ?? '';\n const body = response.status === 204 ? null : await response.json();\n\n if (response.status === 200 && type.includes('application/json')\n && isOrder(body)) {\n return { kind: 'render', order: body };\n }\n\n if (response.status === 202 && isAccepted(body)) {\n return { kind: 'accepted', operationId: body.operationId };\n }\n\n if (response.status === 204) {\n return { kind: 'read-again', reason: 'no-representation' };\n }\n\n if (response.status === 409 && isProblem(body)) {\n return { kind: 'read-again', reason: body.type };\n }\n\n if (response.status === 422 && isValidationProblem(body)) {\n return { kind: 'field-error', fields: body.fields };\n }\n\n if (response.status === 429 || [502, 503, 504].includes(response.status)) {\n return {\n kind: 'retry-later',\n waitSeconds: retryDelay(response.headers.get('retry-after')),\n };\n }\n\n return { kind: 'unknown', reason: 'response-does-not-match-contract' };\n}\nВ настоящем коде чтение тела тоже должно учитывать невалидный JSON: ошибка парсинга — отдельная техническая ветка, а не повод отдать исключение в общий рендер. Аналогично, application/problem+json нужно проверять перед разбором Problem Details. Если сервер прислал HTML от прокси вместо ожидаемого JSON, адаптер сохраняет техническое событие с request id и не записывает ответ в доменную модель.
Возьмём экран редактирования адреса заказа. При открытии query GET /orders/42 возвращает адрес, доступные действия и версию ресурса. Store помечает модель как полученную в момент t0. Пользователь меняет индекс, а на сервере другой клиент сохраняет новый адрес. Наша форма всё ещё содержит старую версию.
Команда POST /orders/42/address отправляет поля и условие версии. Backend возвращает 409 с типом https://api.example.test/problems/order-version-conflict. UI сохраняет введённое значение, перечитывает заказ и показывает различие между серверной и локальной версиями. Он не заменяет экран молча и не повторяет POST с теми же данными.
Теперь сервер отвечает 202. UI показывает «изменение принято», блокирует повторную отправку и хранит operationId. Отдельный query статуса возвращает pending, затем completed с ссылкой на актуальный заказ или failed с Problem Details. Только после валидного представления заказа store переходит в состояние «готово». Если статус операции не описан контрактом, клиент не может безопасно изобрести его.
Наконец, сеть обрывается после отправки POST. Это не то же самое, что 4xx: сервер мог сохранить изменение. Кнопка получает состояние «результат неизвестен», а клиент использует idempotency key или запрос статуса, если такой механизм предусмотрен. Если механизма нет, пользователю предлагают проверить заказ и принять осознанное решение о повторе. Это медленнее мгновенного retry, но не создаёт дубликат автоматически.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| После 2xx экран новый, после reload данные старые | Принятие команды перепутано с готовой моделью | Сверить статус, тело и момент query | Развести accepted и render |
| После 202 сразу показывается «готово» | Асинхронная обработка скрыта от UI | Найти operation id и финальный статус | Показать pending и путь к результату |
| После 204 падает JSON-парсер | Один обработчик читает тело для всех ответов | Проверить ветку до чтения body | Инвалидировать или перечитать по контракту |
| 409 превращается в сетевое сообщение | Все 4xx сведены к одной ветке | Проверить версию ресурса и problem type | Перечитать и разрешить конфликт |
| 422 не подсвечивает поле | Адаптер читает только HTTP status | Проверить указатели полей и прикладной код | Собрать модель ошибок формы |
| После 429 идут запросы без остановки | Retry стал реакцией на любой отказ | Посчитать попытки и разобрать Retry-After | Ввести бюджет, паузу и отмену |
| После timeout пользователь создаёт второй заказ | Неизвестный исход назван отрицательным | Проверить idempotency key или status endpoint | Сначала узнать результат |
| Незнакомый JSON рисуется как экран | Runtime-проверка отсутствует | Проверить схему до записи в store | Остановить render и записать событие |
detail.Эта схема не назначает один обязательный статус каждой бизнес-операции. Один API использует 409 для конфликта версии, другой — 412 при нарушении If-Match; оба варианта требуют точной документации. 422 не является универсальной ошибкой валидации, а 429 не говорит, каким именно способом сервер считает лимит. Это решения конкретного API, а не вывод из названия статуса.
HTTP не стандартизирует общий заголовок idempotency key. Если он нужен для POST, его формат, время хранения ключа, область уникальности и ответ при повторе должны быть частью прикладного контракта. Нельзя обещать безопасность повтора только потому, что клиент передал похожий заголовок. Для финансовой, складской или иной необратимой операции это проверяется тестом на потерю ответа и повтор.
\nПроблема stale data также не исчезает от одного runtime-валидатора. Кэш, реплика, очередь и несколько вкладок требуют своих версий и правил согласования. ETag защищает только тот ресурс и тот сценарий, для которых сервер его проверяет. Если команда меняет несколько ресурсов атомарно, одной версии заказа может быть недостаточно.
\nНаконец, UI не должен показывать пользователю внутренний URL типа ошибки, стек, request id вместо объяснения или текст от прокси. Стабильный код помогает маршрутизации, а локализованный текст и допустимое действие выбирает клиент. Диагностические данные остаются в защищённом журнале с нужными ограничениями доступа.
\nГраница готова, если для каждого поддержанного ответа можно назвать четыре вещи: какая модель разрешена, какое действие получает UI, какое действие запрещено и как это проверяется. Тест должен показать, что валидный 200 проходит в render, 202 остаётся accepted, 204 не читает JSON, 409 не вызывает бесконечный retry, 422 связывает ошибку с полем, 429 учитывает ограничение, а неизвестный ответ останавливает render.
\nДля команды после timeout должен существовать отдельный путь узнать фактический результат. Если такого пути нет, состояние «неизвестно» должно быть видимым и обратимым: пользователь может проверить ресурс, отменить повтор или обратиться к оператору. Компонент получает право менять экран не тогда, когда promise завершился без исключения, а когда transport result прошёл проверку и соответствует договорённому переходу.
\n