{ "index": 48, "slug": "editorial-2026-09-practice-frontend-backend-boundary", "title": "Ответ 200 не описывает экран: как проверить модель на границе UI и API", "excerpt": "Успешный HTTP-ответ ещё не означает, что тело подходит экрану. Разбираем четыре проверки, отрицательные пути и безопасное поведение для 200, 202, 204 и ошибок API.", "contentHtml": "

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

\n

Цена ошибки выше исключения. Пользователь повторяет команду. Оператор ищет проблему в сети. Разработчик смотрит на типы, которые были верны во время сборки. На границе процесса уже лежит другой JSON. Если UI записал его в state без проверки, ошибка проявится только в редком сценарии и будет выглядеть как случайный дефект кнопки.

\n

Главный вопрос — не «успешен ли запрос», а «можно ли этот ответ безопасно превратить в модель конкретного экрана». Код статуса, заголовок формата, синтаксис JSON и поля модели отвечают на разные вопросы. Клиент должен пройти их последовательно и иметь явный отрицательный путь для каждого шага.

\n

Ответ и модель — разные утверждения

\n

HTTP 200 сообщает об успешной обработке запроса. Он не знает, какое состояние должен показать конкретный компонент и какие действия разрешены бизнес-правилом. Даже если в ответе есть JSON, это только синтаксически разобранное тело. JSON с полем {\"status\":\"done\",\"allowedActions\":\"edit\"} корректен как JSON, но может быть непригоден для экрана, который понимает только ready, pending и blocked.

\n

Успешный транспортный ответ и пригодная screen model — разные утверждения. Первое проверяется статусом и доступностью ответа. Второе требует знания контракта потребителя: обязательных полей, типов, закрытых словарей и правила для неизвестного значения. Поэтому типы TypeScript, скомпилированные вместе с клиентом, не заменяют проверку данных, пришедших по сети.

\n

Граница проходит в адаптере данных. Компонент получает проверенную модель или состояние ошибки с понятным кодом. Он не угадывает неизвестный статус, не подставляет пустой массив вместо сломанного allowedActions и не объявляет операцию завершённой только потому, что response.ok вернул true.

\n
\"Поток
Транспортный успех проходит несколько независимых проверок. Только после проверки модели данные становятся входом для рендера.
\n

Четыре проверки перед рендером

\n

Порядок проверки важен: каждая следующая операция предполагает результат предыдущей. Сначала нужно понять, есть ли смысл читать тело. Затем — в каком формате его читать. Только после успешного разбора можно проверять поля.

\n
Уровни границы ответа
УровеньВопросПроверкаОтрицательный путь
ТранспортЗапрос принят сервером?status, response.okклассифицировать HTTP-ошибку
Наличие телаЕсть ли representation?204, 202 и заголовки ответаперечитать ресурс или ждать статус
ФорматКак читать тело?Content-Typeне запускать JSON parser вслепую
МодельПодходит ли тело экрану?типы, обязательные поля и словарине передавать payload в render state
\n

Для 204 тело отсутствует по семантике HTTP. Вызов response.json() в таком пути не доказывает успех: он пытается разобрать пустой поток и обычно заканчивается ошибкой разбора. Ветку 204 нужно обработать раньше parser и вернуть результат вроде empty-success с действием refetch-screen, если экрану требуется актуальное состояние.

\n

Заголовок Content-Type: application/json тоже не доказывает форму данных. Он говорит, как интерпретировать representation, но не гарантирует поля status или allowedActions. Для ошибки действует отдельный media type application/problem+json: его стоит разбирать как problem details, а не пытаться превращать в обычную модель экрана.

\n

Минимальная модель экрана

\n

Не копируйте в UI всю доменную сущность. Выпишите поля, по которым компонент принимает решения, и отдельно зафиксируйте, что делать при нарушении. Ниже — учебный контракт экрана заказа; названия статусов и команд проектные, их нельзя переносить в API без согласования.

\n
Минимальная screen model для состояния заказа
ПолеДопустимое значениеРешение UIЧто проверяет тест
statusready | pending | blockedвыбрать состояние экрананеизвестное значение отклоняется
allowedActionsмассив известных командпоказать разрешённые кнопкистрока и неизвестная команда отклоняются
messageCodeнепустой код сообщениявыбрать локализованный текстпустое или нестроковое поле отклоняется
revisionнеотрицательное целоесравнить версию ресурсаустаревшее состояние не затирает новое
\n

Закрытый словарь статусов — это защита от тихого изменения API, а не запрет на эволюцию. Когда сервер вводит новый переход, меняются контракт, адаптер, визуальное состояние и тест. До этого момента безопаснее показать нейтральную ошибку и записать код нарушения, чем выбрать знакомый fallback.

\n

Авторизация остаётся на сервере. Наличие edit в JSON может управлять видимостью кнопки, но не выдаёт право на операцию. Перед командой сервер заново проверяет права, актуальность версии и допустимость перехода. Клиентская screen model — описание отображения, не источник истины для доступа.

\n

Воспроизводимый пример на JavaScript

\n

Следующий пример намеренно мал. Он показывает место границы, а не библиотеку валидации. Множества статусов и действий выбраны для демонстрации; в рабочем проекте их заменяют сгенерированный контракт, схема или ручной адаптер с такими же отрицательными путями.

\n
const statuses = new Set(['ready', 'pending', 'blocked']);\nconst actions = new Set(['retry', 'edit', 'cancel']);\n\nfunction validateScreenModel(value) {\n  const errors = [];\n  if (!value || typeof value !== 'object' || Array.isArray(value)) {\n    return { valid: false, errors: ['body-must-be-object'] };\n  }\n  if (!statuses.has(value.status)) errors.push('status-is-unknown');\n  if (!Array.isArray(value.allowedActions)) {\n    errors.push('allowedActions-must-be-array');\n  } else if (value.allowedActions.some((item) => !actions.has(item))) {\n    errors.push('allowedActions-contains-unknown-action');\n  }\n  if (typeof value.messageCode !== 'string' || value.messageCode.length === 0) {\n    errors.push('messageCode-must-be-non-empty');\n  }\n  if (!Number.isInteger(value.revision) || value.revision < 0) {\n    errors.push('revision-must-be-non-negative-integer');\n  }\n  return { valid: errors.length === 0, errors };\n}\n\nasync function readProblem(response) {\n  const type = response.headers.get('content-type') || '';\n  if (!type.includes('application/problem+json')) return null;\n  try {\n    const value = await response.json();\n    return value && typeof value === 'object'\n      ? { type: value.type, title: value.title, detail: value.detail }\n      : null;\n  } catch {\n    return null;\n  }\n}\n\nasync function readScreen(response) {\n  if (!response.ok) {\n    return { kind: 'http-error', status: response.status, problem: await readProblem(response) };\n  }\n  if (response.status === 204) {\n    return { kind: 'empty-success', next: 'refetch-screen' };\n  }\n  if (response.status === 202) {\n    return {\n      kind: 'accepted',\n      next: response.headers.get('location') ? 'poll-location' : 'query-status',\n    };\n  }\n  const mediaType = response.headers.get('content-type') || '';\n  if (!mediaType.includes('application/json')) return { kind: 'wrong-media-type' };\n  let body;\n  try {\n    body = await response.json();\n  } catch {\n    return { kind: 'invalid-json' };\n  }\n  const result = validateScreenModel(body);\n  return result.valid\n    ? { kind: 'screen-model-ready', model: body }\n    : { kind: 'invalid-screen-model', errors: result.errors };\n}
\n

Сначала проверяется response.ok, то есть диапазон успешных 2xx-ответов в Fetch API. Ошибка не смешивается с невалидной моделью: у неё свой kind и, если сервер прислал problem details, короткая диагностическая часть. Затем отдельно обрабатываются 204 и 202. В первом случае нет тела, во втором работа принята, но ещё не завершена.

\n

Только обычный успешный ответ с ожидаемым media type доходит до response.json(). После parser запускается проверка полей. Компонент может отобразить screen-model-ready, а для других результатов выбрать безопасный экран, обновить ресурс или показать прикладную ошибку. Полный payload в логи отправлять не следует: он может содержать персональные и доменные данные.

\n

Проверить функцию можно без браузера, подставив небольшие моки Response или объект с теми же свойствами. Набор тестов должен включать валидный JSON, неизвестный статус, строку вместо массива, пустое тело 204, 202 с Location, неверный media type и 422 с application/problem+json. Ожидаемый результат — конкретный kind, а не случайное исключение parser.

\n

Симптом — причина — проверка — действие

\n
Диагностика рассогласования UI и API
СимптомВероятная причинаПроверкаДействие
Экран показал «Готово», следующий клик падает200 приняли за модельсравнить тело с runtime-схемойзапретить запись в state до валидации
DELETE вызывает ошибку parserhelper ждёт JSON после 204проверить статус до json()вернуть empty-success и перечитать ресурс
Появилась кнопка неизвестной командыUI доверяет свободной строкепроверить каждый элемент словарёмотклонить модель и не создавать кнопку
422 превратился в общий toastприкладной отказ назвали сетьюпроверить media type и поля problem detailsсопоставить machine-readable ошибку с полем
После timeout команда выполнилась дваждыклиент повторил запрос вслепуюпроверить идемпотентность и статус операциине повторять без гарантии; перечитать результат
Типы проходят, внешний сервис прислал другой JSONтип сборки не проверяет сетьсохранить фактический ответ и request idдобавить contract или runtime test
\n

Таблица задаёт порядок разговора на инциденте. Сначала фиксируется наблюдаемый ответ, затем проверяется слой, на котором возникло расхождение. Не стоит начинать с переписывания JSX: пока неизвестно, нарушены транспорт, формат или форма данных, изменение компонента может только спрятать причину.

\n

202, 204 и обрыв после команды

\n

Query и command нельзя обрабатывать одинаково. Query получает representation и после проверки обновляет экран. Command просит сервер изменить состояние. Ответ 202 означает, что запрос принят в обработку, но она не завершена; клиенту нужен адрес проверки, идентификатор операции или другой явно описанный способ узнать результат. Не следует показывать финальное «Готово» на основании одного 202.

\n

Ответ 204 означает успешное выполнение без дополнительного содержимого. После него экран обычно перечитывает ресурс, обновляет локальную модель по известному результату команды или закрывает экран — выбор зависит от контракта. Ни один из вариантов нельзя вывести из кода 204 без знания того, какая модель считается актуальной.

\n

Timeout после отправки команды — неопределённый результат, а не доказательство неуспеха. Сервер мог применить операцию, пока клиент ждал. Для повторяемого запроса нужен idempotency key, идемпотентная семантика метода или способ проверить состояние. Для POST без такой защиты слепой retry может создать вторую операцию. Если API не предоставляет проверку результата, UI должен честно показать неопределённость и дать безопасный следующий шаг.

\n

Ошибки API и повтор запросов

\n

Статусы 409, 422, 429 и 5xx сужают выбор, но не заменяют тело ошибки. 409 часто требует перечитать конфликтующий ресурс. 422 может содержать ошибки полей. Для 429 нужно учитывать лимит и, если он прислан, Retry-After. 5xx допускает повтор только при известной идемпотентности, ограниченном числе попыток и контроле нагрузки.

\n

Problem details позволяют передать машинный тип проблемы, заголовок, подробность и расширения вроде указателя на поле. Клиент не обязан показывать пользователю сырые detail или instance: адаптер выбирает локализованный текст по безопасному коду, а диагностические поля отправляет в защищённый канал с ограниченным составом данных.

\n

Кэш и конкуренция добавляют ещё один слой. Формально валидный status: ready может устареть через секунду. Для изменения состояния нужны версия ресурса, ETag или другой механизм, предусмотренный API. Runtime-валидация отвечает на вопрос «форма подходит?», но не на вопросы «данные свежие?» и «операция разрешена?».

\n

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

\n
  1. Выберите endpoint, где компонент читает поля напрямую или использует fallback после ошибки parser.
  2. Зафиксируйте intent: query, command или получение результата фоновой операции.
  3. Выпишите минимальную screen model: обязательные поля, типы, закрытые словари и версию ресурса.
  4. Разделите обработку HTTP, отсутствия тела, media type, JSON parse и runtime validation.
  5. Определите отрицательный путь для неизвестного поля, неверного типа, 4xx, 5xx, 202, 204 и timeout.
  6. Добавьте тесты на валидный ответ, неизвестный статус, неправильный массив, пустое тело, неверный media type и problem details.
  7. Передавайте в компонент только проверенную модель. Ошибку показывайте безопасным состоянием, диагностику связывайте с request id.
  8. Проверьте повтор команды отдельно и зафиксируйте, как клиент узнаёт результат после обрыва сети.
\n

Для воспроизводимости сохраните в тесте не только ожидаемый экран, но и входной HTTP-контекст: статус, media type, тело и способ обработки. Иначе тест может продолжать проверять старый мок с правильными типами, пока реальный сервис отдаёт другую representation.

\n

Ограничения применимости

\n

Этот подход не заменяет серверный контракт, авторизацию, бизнес-валидацию и проверку свежести. Он защищает границу от того, чтобы случайный payload стал UI-состоянием. Для сложных вложенных моделей ручная функция быстро станет второй схемой; используйте согласованный schema validator и измерьте стоимость на реальном размере ответа.

\n

Проверка media type не гарантирует, что промежуточный proxy не изменил тело, а runtime-схема не гарантирует, что поле означает правильный бизнес-факт. Для независимых релизов нужны contract-тесты или тесты фактических ответов. Для асинхронных операций нужен API статуса, callback или другой документированный способ узнать завершение; один 202 этого не создаёт.

\n

Пример не описывает конкретную бизнес-модель и не даёт права копировать названия статусов. Если один endpoint обслуживает несколько экранов, не расширяйте универсальный объект до бесконечности. Разделите read model, версионируйте ответ или добавьте адаптер на стороне потребителя.

\n

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

\n

Граница готова, если для каждого поддержанного ответа команда может назвать четыре вещи: какую модель можно передать в UI, какое действие доступно, какое запрещено и каким тестом это доказано. Минимальный набор должен показать, что валидный 200 проходит в render, 204 не запускает JSON parser, 202 не маскируется под завершение, неизвестный статус останавливает render, а timeout команды не вызывает слепой повтор.

\n

Проверка незавершена, если компонент выбирает состояние по умолчанию после ошибки схемы, строит кнопку из свободной строки или показывает общий toast вместо различимой прикладной ошибки. Искомый результат — наблюдаемая граница, где транспортный ответ превращается в screen model только после проверки и с понятным следующим шагом.

\n

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

" }