DarkRiDDeR13 мин

Состояние экрана не угадывают по статусу: разделяем query, command и ошибку

FrontendBackend

Проблема начинается, когда UI трактует любой ответ не-200 как «сервер упал»: пользователь получает бесполезный toast, а команда теряет причину отказа. Обратная крайность не лучше: компонент считает каждый 2xx подтверждением операции и показывает новое состояние до чтения модели. Цена такой путаницы — повторные команды, неверные подсказки и разбор инцидента по снимкам интерфейса вместо точного HTTP-контракта.

Надёжная граница разделяет три намерения: query читает representation, command просит изменить состояние, а error envelope объясняет, почему переход не состоялся или что делать дальше. Статус HTTP — важная часть решения, но он не должен единолично выбирать текст, кнопку повторить и новый screen state. Ниже — таблица семантик и чистая функция, которую можно покрыть тестами без браузера.

Один ответ — несколько уровней смысла

Запрос к экрану и команда изменения могут использовать один транспорт, но у них разные последствия. Query обычно превращает валидное представление в UI. Command сначала подтверждает, что запрос принят на уровне операции, а затем либо возвращает новую модель, либо требует повторного чтения. Если эти пути слить в один handler, команда легко станет локальным флагом «успешно», хотя сервер вернул только принятие запроса.

Матрица связывает HTTP-результат с правом UI: 2xx даёт право читать representation, 409 и 422 — показать исправляемую причину, 429 и 5xx — применить отдельную политику повторов, а отправка команды сама по себе не меняет screen state.
У каждого результата есть следующий шаг и запрет. Это меньше похоже на универсальный обработчик, зато не скрывает смысл ответа.
Решение по HTTP-результату
РезультатЧто можно заключитьСледующее действие UIЧего нельзя делать
200 + valid JSONrepresentation соответствует схемеобновить view из моделидобавлять локальные доменные поля
204операция завершилась без representationинвалидировать или перечитать ресурсвызывать JSON parser
409текущее состояние конфликтует с командойпоказать причину и предложить перечитатьповторять без изменения входа
422вход не прошёл прикладную проверкуподсветить исправляемые поляназывать это сетевым сбоем
429 / 5xxвозможен временный отказиспользовать явную retry policyделать бесконечный retry

Conflict, validation и server failure

409 Conflict — не синоним 500. Он сообщает, что запрос нельзя завершить в текущем состоянии ресурса; UI часто может перечитать модель, показать конфликт или попросить пользователя выбрать действие. 422 удобно использовать для валидного по синтаксису, но неприемлемого по правилам входа. Смысл ответа зависит от API, поэтому клиент должен читать структурированный problem detail, а не выводить причину из одной цифры.

429 говорит о временном ограничении частоты, а 5xx — о проблеме на стороне сервера или его зависимости. Оба случая могут быть повторяемыми, но не одинаково: Retry-After, идемпотентность команды, бюджет попыток и состояние формы должны быть явными. Автоматический retry POST без idempotency boundary способен создать две операции. «Повторить запрос» — это политика, а не универсальная реакция на красный статус.

Problem Details как envelope

RFC 9457 предлагает формат Problem Details с полями вроде type, title, status, detail и instance. Он не диктует, какой текст показывать и можно ли повторять команду. Приложение может добавить ограниченный code и список безопасных действий, если это описано его контрактом. Важно отделить машинный тип от свободного detail: последний может быть непригоден для локализации или содержать внутреннюю информацию.

UI должен преобразовать envelope в свою модель сообщения один раз. Например, order.version-conflict превращается в «Обновить данные» и кнопку перечитать, а order.invalid-address — в подсветку поля. Компоненты не должны сравнивать строки title и detail. Это делает поведение устойчивым к переводу, изменению формулировки и разделению API между несколькими клиентами.

Воспроизводимая классификация

import { classifyHttpResponse } from './upgrade-2026-09.mjs';

console.log(classifyHttpResponse({
  status: 409,
  contentType: 'application/problem+json',
  body: { type: 'https://example.test/problems/version-conflict' },
}));
// { kind: 'domain-rejection', next: 'read-problem-type-and-show-recoverable-action' }

console.log(classifyHttpResponse({
  status: 200, contentType: 'application/json',
  body: { status: 'ready', allowedActions: ['edit'], messageCode: 'order.ready' },
}));
// { kind: 'screen-model-ready', next: 'render-from-contract' }

Функция классифицирует только названные свойства наблюдения. Она не объявляет операцию успешной по наличию поля detail и не делает retry автоматически. Для приложения это удобная точка тестирования: одна таблица входов проверяет, что 409 не попал в ветку network failure, а 200 с неподходящей model не прошёл в render.

В реальном клиенте стоит добавить request id к технической записи и убрать из неё тело problem detail, если оно может содержать пользовательский ввод. Для пользователя нужны локализованный message code и один следующий шаг. Такой минимум лучше длинного сообщения, которое перечисляет внутренний stack trace и оставляет человека без решения.

Query и command не обязаны возвращать одно и то же

После команды возможны три формы: новая screen model в ответе, 202 с идентификатором отслеживания или 204 без представления. Нельзя написать общий handler «после POST обновить store» и считать задачу решённой. Для 202 нужна отдельная модель состояния операции и правило polling или push. Для 204 нужно определить, какой ресурс инвалидировать и когда перечитать его. Контракт должен назвать форму, иначе каждый клиент изобретёт свою.

Идемпотентность относится к смыслу команды, а не к тому, как выглядит кнопка. Повтор с тем же ключом может вернуть тот же результат или безопасно сообщить о предыдущем выполнении; без такой договорённости retry после timeout не даёт клиенту права считать, что команда не дошла. Timeout — это неизвестный исход, а не отрицательный исход. UI должен показать это различие и дать безопасный путь синхронизации.

Шесть тестов, которые окупаются

  1. Валидный 200 с полной screen model: компонент получает ровно разрешённые действия.
  2. 200 с неизвестным status: render не вызывается, ошибка формы отделена от transport.
  3. 204: parser не запускается, ресурс помечается для повторного чтения.
  4. 409 с problem type: пользователь получает действие перечитать, а не автоматический бесконечный retry.
  5. 422 с кодом поля: ошибка привязывается к input, а не к общему toast.
  6. 429 и 500: политика повторов ограничена бюджетом и учитывает идемпотентность команды.

Ограничения и следующий шаг

Классификатор не проектирует бизнес-статусы и не заменяет спецификацию API. Один и тот же HTTP-код может иметь разный прикладной смысл для разных операций; поэтому таблицу нужно привязать к конкретному контракту. Problem Details тоже не решает authorization, приватность и наблюдаемость. Он даёт форму envelope, а не готовую политику продукта.

Следующий шаг — выбрать один command с потенциальным повтором, добавить в контракт ответ при неизвестном исходе и написать тест на timeout. Если после этого UI всё ещё меняет state до query, граница не закончена. Нужен не новый флаг, а явный переход: команда отправлена, результат неизвестен, read model перечитана или получен problem detail.

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

  • RFC 9110: HTTP Semantics — RFC 9110, июнь 2022. Использован для семантики классов HTTP-ответов и различия transport/application meaning. Граница: Не назначает конкретные статусы для бизнес-операции.
  • RFC 9457: Problem Details for HTTP APIs — RFC 9457, июль 2023. Использован для структуры Problem Details и разделения machine type и human detail. Граница: Не определяет локализацию, retry или доступность действия.
  • OpenAPI Specification 3.1.1 — версия 3.1.1, 24 октября 2024. Использован для идеи явно описывать response variants у операции. Граница: Не гарантирует, что реализация и фактический payload совпадают.