8 lines
27 KiB
JSON
8 lines
27 KiB
JSON
{
|
||
"index": 47,
|
||
"slug": "editorial-2026-09-mechanism-frontend-backend-boundary",
|
||
"title": "Состояние экрана не угадывают по статусу: разделяем query, command и ошибку",
|
||
"excerpt": "Как провести границу между UI и API: отличить чтение модели от команды, разобрать 202, 204, 409, 422, 429 и 5xx и не показать пользователю состояние, которого сервер не подтвердил.",
|
||
"contentHtml": "<p>Кнопка показывает «Готово», но после обновления страницы заказ снова выглядит незавершённым. В другой версии того же дефекта любой отказ превращается в красное сообщение «Что-то пошло не так». Пользователь повторяет команду, оператор ищет причину по снимку интерфейса, а команда спорит, сломан ли браузер или API. Цена ошибки — дубликаты операций, потерянные изменения и неверное решение по инциденту.</p>\n<p>Причина обычно не в самом HTTP-вызове. UI смешивает три разных смысла: чтение представления ресурса, отправку команды и объяснение отказа. Надёжная граница оставляет право менять модель экрана только за проверенным payload. Query читает данные. Command просит изменить состояние. Ответ об ошибке сообщает, почему переход не состоялся и какой следующий шаг допустим. Статус помогает выбрать ветку, но не заменяет проверку тела и контракта.</p>\n<h2>Главный вопрос: что именно подтверждено</h2>\n<p>До написания обработчика нужно закончить фразу «после этого ответа сервер подтвердил…». Для GET это может быть представление заказа на момент чтения. Для POST — факт принятия команды, созданный ресурс или новое представление. Для PUT — результат замены при соблюдении условий. Для фоновой операции — только постановка в обработку. Если команда не вернула представление, локальный флаг <code>isSuccess</code> не становится новой доменной моделью.</p>\n<p>У ответа есть как минимум четыре независимые части: HTTP-статус, заголовки, тип содержимого и тело. Успешный статус не гарантирует, что тело подходит экрану. Отсутствие тела не говорит, нужно ли очищать форму, показывать старую модель или делать query заново. Error response тоже не следует превращать в произвольную строку: UI должен получить стабильный код и локализовать его в своём слое.</p>\n<figure><img src='/assets/editorial/2026/frontend-backend-boundary-2026-contract-responsibility-matrix.svg' alt='Схема разделяет query и command и показывает, как статус, заголовки и тело ответа разрешают или запрещают переход UI' loading='lazy' /><figcaption>Схема показывает границу ответственности: транспортный ответ сначала проверяет адаптер, затем UI выбирает разрешённое действие. Сам рисунок не описывает конкретный API и не заменяет его контракт.</figcaption></figure>\n<h2>Что означает каждый ответ</h2>\n<p><strong>200 OK.</strong> В ответе обычно есть представление, но клиент всё равно проверяет <code>Content-Type</code> и форму JSON. Только после runtime-проверки данные можно записать в store. Если API договорилось о пустом 200, это должно быть явно описано; иначе пустое тело — не повод угадывать состояние.</p>\n<p><strong>202 Accepted.</strong> Запрос принят для обработки, но обработка ещё не завершена. Даже успешный 202 не означает, что заказ уже изменён и его можно рисовать как изменённый. Контракт должен дать идентификатор операции, ссылку на её статус или правило повторного чтения. Без этого UI показывает «запрос принят», а не «результат готов».</p>\n<p><strong>204 No Content.</strong> Запрос успешно выполнен, но в ответе нет дополнительного содержимого. JSON-парсер здесь запускать нельзя. После 204 приложение может оставить известную модель, инвалидировать её или выполнить query — выбор зависит от операции и должен быть записан в контракте. HTTP сам по себе не выбирает поведение экрана.</p>\n<p><strong>409 Conflict.</strong> Запрос не завершён из-за конфликта с текущим состоянием ресурса. Типичный случай — клиент отправил старую версию заказа после того, как его изменил другой участник. Безопасное действие — перечитать ресурс и дать пользователю осознанно выбрать дальнейший шаг. Автоматическая повторная отправка того же payload сохраняет конфликт и может скрыть чужое изменение.</p>\n<p><strong>422 Unprocessable Content.</strong> Сервер понял тип содержимого и синтаксис запроса, но не смог выполнить содержащуюся инструкцию. Для формы это обычно прикладная ошибка: значение допустимо по JSON-схеме, но нарушает правило предметной области. Ошибка должна попасть в модель полей или в понятный код правила, а не в общий сетевой toast.</p>\n<p><strong>429 Too Many Requests.</strong> Сервер ограничил частоту запросов. Ответ может содержать <code>Retry-After</code>, причём это либо количество секунд, либо HTTP-дата. Клиент не должен запускать бесконечный retry: он ограничивает число попыток, учитывает паузу и сохраняет введённые данные. Для команды с неизвестным исходом повтор разрешён только при отдельном договоре об идемпотентности.</p>\n<p><strong>5xx.</strong> Ошибка сервера или посредника не доказывает, что команда не была выполнена. После timeout, 502 или 504 результат POST может быть неизвестен. Повтор возможен для операции, которая идемпотентна по контракту и имеет бюджет попыток. 501 или 505 не следует автоматически считать временным сбоем. В сомнении UI показывает ожидание или предлагает узнать результат, а не создаёт вторую команду.</p>\n<h2>Контракт должен описывать переход, а не только статус</h2>\n<p>Удобный контракт для каждой операции отвечает на пять вопросов: какой метод и ресурс участвуют, какой payload считается валидным, какая модель приходит при каждом поддержанном успехе, какие коды ошибки может обработать UI и как узнать итог после потери ответа. В OpenAPI это выражается операцией с перечисленными <code>responses</code>, схемами содержимого и описанием заголовков. Документ не делает runtime-проверку, но не оставляет варианты ответа невидимыми для команды.</p>\n<p>Для редактирования заказа полезно отделить версию от полей формы. Query возвращает <code>order</code> и <code>version</code>. Command отправляет изменённые поля вместе с условием, например <code>If-Match</code> или числовой версией. Backend сравнивает условие с текущим ресурсом. Если оно устарело, он возвращает конфликт; frontend не затирает store ответом, который относится к старой версии.</p>\n<p>Problem Details даёт переносимую форму ошибки: <code>type</code>, <code>title</code>, <code>status</code>, <code>detail</code> и расширения. Главным идентификатором проблемы служит <code>type</code>, а <code>status</code> внутри JSON носит справочный характер и не должен переопределять реальный HTTP-статус. Поле <code>detail</code> предназначено для объяснения конкретного случая; его нельзя без фильтра показывать пользователю и нельзя использовать как стабильный ключ локализации.</p>\n<h2>Воспроизводимый адаптер ответа</h2>\n<p>Ниже — небольшой TypeScript-подобный адаптер без сети. В нём специально оставлены функции <code>isOrder</code>, <code>isProblem</code> и <code>isValidationProblem</code>: их нужно реализовать схемой конкретного API. Пример проверяет тип содержимого, различает принятую и завершённую команду и закрывает неизвестную ветку. Он не объявляет операцию успешной по одному числу.</p>\n<pre><code>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}</code></pre>\n<p>В настоящем коде чтение тела тоже должно учитывать невалидный JSON: ошибка парсинга — отдельная техническая ветка, а не повод отдать исключение в общий рендер. Аналогично, <code>application/problem+json</code> нужно проверять перед разбором Problem Details. Если сервер прислал HTML от прокси вместо ожидаемого JSON, адаптер сохраняет техническое событие с request id и не записывает ответ в доменную модель.</p>\n<h2>Один сценарий от клика до результата</h2>\n<p>Возьмём экран редактирования адреса заказа. При открытии query <code>GET /orders/42</code> возвращает адрес, доступные действия и версию ресурса. Store помечает модель как полученную в момент <code>t0</code>. Пользователь меняет индекс, а на сервере другой клиент сохраняет новый адрес. Наша форма всё ещё содержит старую версию.</p>\n<p>Команда <code>POST /orders/42/address</code> отправляет поля и условие версии. Backend возвращает 409 с типом <code>https://api.example.test/problems/order-version-conflict</code>. UI сохраняет введённое значение, перечитывает заказ и показывает различие между серверной и локальной версиями. Он не заменяет экран молча и не повторяет POST с теми же данными.</p>\n<p>Теперь сервер отвечает 202. UI показывает «изменение принято», блокирует повторную отправку и хранит <code>operationId</code>. Отдельный query статуса возвращает <code>pending</code>, затем <code>completed</code> с ссылкой на актуальный заказ или <code>failed</code> с Problem Details. Только после валидного представления заказа store переходит в состояние «готово». Если статус операции не описан контрактом, клиент не может безопасно изобрести его.</p>\n<p>Наконец, сеть обрывается после отправки POST. Это не то же самое, что 4xx: сервер мог сохранить изменение. Кнопка получает состояние «результат неизвестен», а клиент использует idempotency key или запрос статуса, если такой механизм предусмотрен. Если механизма нет, пользователю предлагают проверить заказ и принять осознанное решение о повторе. Это медленнее мгновенного retry, но не создаёт дубликат автоматически.</p>\n<h2>Диагностика: симптом → причина → проверка → действие</h2>\n<div class='table-scroll'><table><caption>Как найти ошибочную границу между UI и API</caption><thead><tr><th scope='col'>Симптом</th><th scope='col'>Причина</th><th scope='col'>Проверка</th><th scope='col'>Действие</th></tr></thead><tbody><tr><td>После 2xx экран новый, после reload данные старые</td><td>Принятие команды перепутано с готовой моделью</td><td>Сверить статус, тело и момент query</td><td>Развести accepted и render</td></tr><tr><td>После 202 сразу показывается «готово»</td><td>Асинхронная обработка скрыта от UI</td><td>Найти operation id и финальный статус</td><td>Показать pending и путь к результату</td></tr><tr><td>После 204 падает JSON-парсер</td><td>Один обработчик читает тело для всех ответов</td><td>Проверить ветку до чтения body</td><td>Инвалидировать или перечитать по контракту</td></tr><tr><td>409 превращается в сетевое сообщение</td><td>Все 4xx сведены к одной ветке</td><td>Проверить версию ресурса и problem type</td><td>Перечитать и разрешить конфликт</td></tr><tr><td>422 не подсвечивает поле</td><td>Адаптер читает только HTTP status</td><td>Проверить указатели полей и прикладной код</td><td>Собрать модель ошибок формы</td></tr><tr><td>После 429 идут запросы без остановки</td><td>Retry стал реакцией на любой отказ</td><td>Посчитать попытки и разобрать Retry-After</td><td>Ввести бюджет, паузу и отмену</td></tr><tr><td>После timeout пользователь создаёт второй заказ</td><td>Неизвестный исход назван отрицательным</td><td>Проверить idempotency key или status endpoint</td><td>Сначала узнать результат</td></tr><tr><td>Незнакомый JSON рисуется как экран</td><td>Runtime-проверка отсутствует</td><td>Проверить схему до записи в store</td><td>Остановить render и записать событие</td></tr></tbody></table></div>\n<h2>Порядок проверки в проекте</h2>\n<ol><li>Опишите наблюдаемый симптом и цену ошибочного перехода: дубликат, потеря ввода, устаревший экран или неверное сообщение.</li><li>Назовите операцию: query, command или получение результата фоновой операции.</li><li>Зафиксируйте метод, маршрут, статус, Content-Type, безопасный request id и версию ресурса.</li><li>Для каждого успеха укажите, что подтверждено: представление, создание, принятие в очередь или отсутствие содержимого.</li><li>Опишите схему тела до и после выполнения: модель, accepted-ответ и Problem Details.</li><li>Составьте таблицу для 200, 202, 204, 409, 422, 429, выбранных 5xx и неизвестного ответа.</li><li>Проверьте body до записи в screen state; отдельно протестируйте неверный JSON и неожиданный Content-Type.</li><li>Для конфликта проверьте условие версии: ETag/If-Match либо поле версии в payload.</li><li>Для timeout опишите, как узнать результат и почему повтор безопасен или запрещён.</li><li>Ограничьте retry бюджетом и паузой; обработайте отмену, закрытие вкладки и повторный клик.</li><li>Сопоставьте UI-действие со стабильным машинным кодом, а не со свободной строкой <code>detail</code>.</li><li>Добавьте контрактные и интеграционные тесты на каждый поддержанный переход, включая отрицательный.</li></ol>\n<h2>Ограничения применимости</h2>\n<p>Эта схема не назначает один обязательный статус каждой бизнес-операции. Один API использует 409 для конфликта версии, другой — 412 при нарушении <code>If-Match</code>; оба варианта требуют точной документации. 422 не является универсальной ошибкой валидации, а 429 не говорит, каким именно способом сервер считает лимит. Это решения конкретного API, а не вывод из названия статуса.</p>\n<p>HTTP не стандартизирует общий заголовок idempotency key. Если он нужен для POST, его формат, время хранения ключа, область уникальности и ответ при повторе должны быть частью прикладного контракта. Нельзя обещать безопасность повтора только потому, что клиент передал похожий заголовок. Для финансовой, складской или иной необратимой операции это проверяется тестом на потерю ответа и повтор.</p>\n<p>Проблема stale data также не исчезает от одного runtime-валидатора. Кэш, реплика, очередь и несколько вкладок требуют своих версий и правил согласования. ETag защищает только тот ресурс и тот сценарий, для которых сервер его проверяет. Если команда меняет несколько ресурсов атомарно, одной версии заказа может быть недостаточно.</p>\n<p>Наконец, UI не должен показывать пользователю внутренний URL типа ошибки, стек, request id вместо объяснения или текст от прокси. Стабильный код помогает маршрутизации, а локализованный текст и допустимое действие выбирает клиент. Диагностические данные остаются в защищённом журнале с нужными ограничениями доступа.</p>\n<h2>Критерий готовности</h2>\n<p>Граница готова, если для каждого поддержанного ответа можно назвать четыре вещи: какая модель разрешена, какое действие получает UI, какое действие запрещено и как это проверяется. Тест должен показать, что валидный 200 проходит в render, 202 остаётся accepted, 204 не читает JSON, 409 не вызывает бесконечный retry, 422 связывает ошибку с полем, 429 учитывает ограничение, а неизвестный ответ останавливает render.</p>\n<p>Для команды после timeout должен существовать отдельный путь узнать фактический результат. Если такого пути нет, состояние «неизвестно» должно быть видимым и обратимым: пользователь может проверить ресурс, отменить повтор или обратиться к оператору. Компонент получает право менять экран не тогда, когда promise завершился без исключения, а когда transport result прошёл проверку и соответствует договорённому переходу.</p>\n<h2>Проверяемые источники</h2><ul><li><a href='https://www.rfc-editor.org/rfc/rfc9110.html' target='_blank' rel='noopener noreferrer'>RFC 9110: HTTP Semantics</a> — нормативное описание представлений, методов, идемпотентности, заголовка Retry-After и статусов 202, 204, 409 и 422.</li><li><a href='https://www.rfc-editor.org/rfc/rfc6585.html' target='_blank' rel='noopener noreferrer'>RFC 6585: Additional HTTP Status Codes</a> — нормативное описание 429, его ограничения и допустимого Retry-After.</li><li><a href='https://www.rfc-editor.org/rfc/rfc9457.html' target='_blank' rel='noopener noreferrer'>RFC 9457: Problem Details for HTTP APIs</a> — нормативная форма машинно-читаемых деталей проблемы и смысл полей type/status.</li><li><a href='https://spec.openapis.org/oas/latest.html' target='_blank' rel='noopener noreferrer'>OpenAPI Specification</a> — официальная спецификация описания операций, схем содержимого и вариантов responses.</li></ul>"
|
||
}
|