- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
443 lines
31 KiB
Markdown
443 lines
31 KiB
Markdown
# points Specification
|
||
|
||
## Purpose
|
||
|
||
Ряд точек одной метрики за период — то, ради чего собирается витрина. Каталог
|
||
отвечает «что у тебя есть», этот маршрут — «дай значения». Форма запроса и его
|
||
разбор, правило выбора слоя, состав конверта и объявление измеренного рода
|
||
вместе с границей окна измерения живут здесь; общие правила читающих маршрутов —
|
||
в capability `read-api`.
|
||
|
||
## Requirements
|
||
### Requirement: Точки метрики за период отдаются одним запросом
|
||
|
||
Система SHALL отдавать значения одной метрики за запрошенный период по
|
||
`GET /api/v1/metrics/{name}` с обязательными параметрами `from` и `to`, не
|
||
требуя от потребителя доступа к файлу базы и не требуя второго запроса за
|
||
смыслом отданных чисел.
|
||
|
||
Имя метрики берётся из пути **ровно один раз декодированным** и далее
|
||
дословно: система его не нормализует и не сверяет со списком известных — имена
|
||
приходят из тела доставки и хранилищу не принадлежат. Повторное декодирование
|
||
MUST NOT происходить: имя, само содержащее процентную последовательность
|
||
(`a%41b`), после второго декодирования становится именем **другой** метрики, и
|
||
маршрут отвечает `200` с её данными. Имя, которое путём не
|
||
выражается (пустое), маршрутом недостижимо; каталог такую метрику показывает, и
|
||
это названная цена адресации именем в пути, а не молчание.
|
||
|
||
Маршрут закрыт токеном чтения тем же контуром, что и каталог, и MUST NOT
|
||
перехватывать маршрут каталога `GET /api/v1/metrics`.
|
||
|
||
#### Scenario: Период запрошен
|
||
|
||
- **GIVEN** метрика, у которой в витрине есть объекты внутри периода
|
||
- **WHEN** потребитель запрашивает `GET /api/v1/metrics/{name}?from=…&to=…` с
|
||
действующим токеном чтения
|
||
- **THEN** ответ `200` несёт значения этой метрики за этот период
|
||
|
||
#### Scenario: Каталог остаётся достижим
|
||
|
||
- **WHEN** потребитель запрашивает `GET /api/v1/metrics`
|
||
- **THEN** отвечает каталог, а не маршрут точек
|
||
|
||
#### Scenario: Имя метрики закодировано в пути
|
||
|
||
- **GIVEN** метрика, чьё имя содержит символ, требующий процентного кодирования
|
||
- **WHEN** потребитель запрашивает её точки, закодировав имя
|
||
- **THEN** отвечают точки этой метрики, а не пустой ряд
|
||
|
||
#### Scenario: Имя метрики само содержит процентную последовательность
|
||
|
||
- **GIVEN** метрики с именами `a%41b` и `aAb` в витрине
|
||
- **WHEN** потребитель запрашивает `a%41b`, закодировав имя
|
||
- **THEN** отвечают точки `a%41b`, а не точки `aAb`
|
||
|
||
#### Scenario: Токен чтения отсутствует
|
||
|
||
- **WHEN** запрос точек приходит без действующего токена чтения при непустом
|
||
списке токенов чтения
|
||
- **THEN** ответ `401`, и тело ответа собрано объявленным типом транспорта
|
||
|
||
### Requirement: Конверт ответа объявляет слой, род свёртки, его применимость и границу окна измерения
|
||
|
||
Система SHALL сопровождать точки конвертом, в котором ВСЕГДА присутствуют поля
|
||
`metric`, `from`, `to`, `layer`, `bucket`, `aggregation` и `points`, а объект
|
||
`aggregation` MUST всегда нести поля `style`, `applicable` и `last_hour`. Поле,
|
||
которому нечего сообщить, MUST уезжать как `null` и MUST NOT исчезать из ответа
|
||
и MUST NOT подменяться нулевым значением своего типа.
|
||
|
||
Смысл полей:
|
||
|
||
- `metric` — имя метрики, как оно пришло путём после декодирования;
|
||
- `from` и `to` — фактически применённые границы периода, нормализованные к UTC
|
||
в RFC 3339;
|
||
- `layer` — слой, из которого собран ряд;
|
||
- `bucket` — сетка свёртки; `null`, когда свёртки не было;
|
||
- `aggregation.style` — измеренный род метрики (`cumulative` / `instant` /
|
||
`unknown`) тем же правилом и тем же окном, что у каталога;
|
||
- `aggregation.applicable` — применим ли объявленный род к **отданному ряду**;
|
||
- `aggregation.last_hour` — ярлык самого свежего часа окна измерения; `null`,
|
||
когда окно пусто;
|
||
- `points` — ряд, пустой коллекцией `[]`, а не `null`.
|
||
|
||
`last_hour` обязателен именно потому, что окно измерения считается в **общих
|
||
часах**, а не в часах календаря: выключенная минутная автоматизация HAE
|
||
останавливает пополнение общих часов, окно замирает и продолжает объявлять род.
|
||
Без `last_hour` у клиента нет ни одного способа это увидеть.
|
||
|
||
#### Scenario: Свёртки не было
|
||
|
||
- **WHEN** маршрут отвечает на запрос без сетки
|
||
- **THEN** в ответе присутствуют `metric`, `from`, `to`, `layer`, `bucket`,
|
||
`aggregation` со всеми тремя полями и `points`, причём `bucket` равен `null`
|
||
|
||
#### Scenario: Окно измерения замерло
|
||
|
||
- **GIVEN** метрика, чьи самые свежие общие часы старше конца запрошенного
|
||
периода
|
||
- **WHEN** маршрут отвечает
|
||
- **THEN** `aggregation.last_hour` называет ярлык самого свежего общего часа, а
|
||
не конец периода и не текущее время
|
||
|
||
#### Scenario: Окна измерения нет вовсе
|
||
|
||
- **GIVEN** метрика, у которой нет ни одного общего часа минутного и часового
|
||
слоёв
|
||
- **WHEN** маршрут отвечает
|
||
- **THEN** `aggregation.style` равен `"unknown"`, а `aggregation.last_hour`
|
||
равен `null`
|
||
|
||
### Requirement: Род, объявленный в ответе, едет вместе со своей применимостью к отданному ряду
|
||
|
||
Система SHALL объявлять `aggregation.applicable` равным `false` всегда, когда
|
||
объявленный род нельзя применить к отданному ряду, и MUST считать неприменимым
|
||
род `cumulative` на **нижнем слое HAE** (`raw`), род `unknown` и любой род при
|
||
отсутствии выбранного слоя. Система MUST NOT досчитывать свёртку по
|
||
неизмеренному роду и MUST NOT выражать неизвестность отсутствием поля.
|
||
|
||
Поле существует потому, что род — свойство **метрики**, а слой — свойство
|
||
**отданного ряда**, и их сочетание бывает опасным: нижний слой HAE это
|
||
интерполяция, а не сэмплы, и сумма по нему завышает втрое. Конверт, объявляющий
|
||
`cumulative` рядом с рядом из `raw` и молчащий о неприменимости, приглашает
|
||
потребителя сложить интерполяцию самостоятельно — система при этом не
|
||
складывает ничего, а решение у потребителя уже принято по завышенному числу.
|
||
|
||
#### Scenario: Род метрики не измерен
|
||
|
||
- **GIVEN** метрика, у которой род агрегации не измерен
|
||
- **WHEN** потребитель запрашивает её точки за период
|
||
- **THEN** ответ `200` несёт точки как есть, `aggregation.style` равен
|
||
`"unknown"`, а `aggregation.applicable` равен `false`
|
||
|
||
#### Scenario: Накопительная метрика отдана нижним слоем
|
||
|
||
- **GIVEN** метрика с измеренным родом `cumulative`, ряд которой собран из слоя
|
||
`raw`
|
||
- **WHEN** маршрут отвечает
|
||
- **THEN** `aggregation.style` равен `"cumulative"`, а
|
||
`aggregation.applicable` равен `false`
|
||
|
||
#### Scenario: Род применим
|
||
|
||
- **GIVEN** метрика с измеренным родом, ряд которой собран из слоя `minute`,
|
||
`hour` или `sample`
|
||
- **WHEN** маршрут отвечает
|
||
- **THEN** `aggregation.applicable` равен `true`
|
||
|
||
### Requirement: Слой выбирается по охвату точек внутри периода и не меняется внутри ответа
|
||
|
||
Система SHALL собирать ряд ровно из одного слоя и MUST NOT склеивать в одном
|
||
ответе точки разных слоёв. При отсутствии параметра `layer` система SHALL брать
|
||
слой с наибольшим **охватом** внутри запрошенного периода, а при равном охвате —
|
||
самый мелкий слой в порядке `sample`, `raw`, `minute`, `hour`, `day`.
|
||
|
||
**Охват меряется метками точек, а не часами объектов.** Охват слоя есть длина
|
||
пересечения отрезка `[первая метка слоя, последняя метка слоя]` с запрошенным
|
||
периодом; слой с пустым пересечением из выбора MUST выбывать. Мера названа
|
||
именно так потому, что объекты адресуются часом, а ряд отбирается точной меткой:
|
||
на периоде короче часа множество «слоёв с объектами» и множество «слоёв с
|
||
точками» расходятся, и слой, выбранный по часам, отдал бы пустой ряд при
|
||
непустых данных соседнего слоя.
|
||
|
||
Охват сравнивается между слоями, а не с запрошенным периодом: границы данных
|
||
законно короче запроса и законно имеют дыры внутри.
|
||
|
||
Заданный параметр `layer` отменяет правило целиком: система SHALL отдавать
|
||
запрошенный разрез, в том числе пустым, и SHALL называть его в ответе.
|
||
|
||
#### Scenario: Мелкий слой охватывает меньше крупного
|
||
|
||
- **GIVEN** метрика, у которой внутри периода нижний слой покрывает несколько
|
||
дней, а часовой — весь период
|
||
- **WHEN** потребитель запрашивает период без параметра `layer`
|
||
- **THEN** ряд собран из часового слоя, и `layer` называет его
|
||
|
||
#### Scenario: Охваты равны
|
||
|
||
- **GIVEN** метрика, у которой два слоя охватывают внутри периода одно и то же
|
||
- **WHEN** потребитель запрашивает период без параметра `layer`
|
||
- **THEN** ряд собран из более мелкого слоя
|
||
|
||
#### Scenario: Период короче часа
|
||
|
||
- **GIVEN** период внутри одного часа, в котором у крупного слоя есть объект без
|
||
единой точки внутри периода, а у мелкого — точки внутри периода
|
||
- **WHEN** потребитель запрашивает период без параметра `layer`
|
||
- **THEN** ряд собран из мелкого слоя и не пуст
|
||
|
||
#### Scenario: Слой задан явно
|
||
|
||
- **WHEN** потребитель задаёт `layer` явно
|
||
- **THEN** ряд собран из этого слоя, даже если другой слой охватывает период
|
||
шире, и `layer` в ответе равен запрошенному
|
||
|
||
#### Scenario: Имя слоя незнакомо
|
||
|
||
- **WHEN** параметр `layer` присутствует, а его значение не является одним из
|
||
`sample`, `raw`, `minute`, `hour`, `day`
|
||
- **THEN** ответ `400`, и умолчание молча не подставляется
|
||
|
||
#### Scenario: Значение слоя пусто
|
||
|
||
- **WHEN** параметр `layer` присутствует с пустым значением
|
||
- **THEN** ответ `400`, а не автоматический выбор слоя
|
||
|
||
### Requirement: Период задаётся явно и разбирается строго
|
||
|
||
Система SHALL требовать оба параметра `from` и `to`, SHALL принимать их только в
|
||
формате RFC 3339 с явным смещением зоны и SHALL толковать период как
|
||
полуинтервал `[from, to)`. Отсутствующий параметр, неразбираемое значение,
|
||
значение без явной зоны, `from >= to` и граница, чей год после приведения к UTC
|
||
выходит за диапазон 1–9999, MUST давать `400` с человекочитаемым сообщением,
|
||
которое MUST NOT содержать значений из запроса, и MUST NOT подменяться
|
||
умолчанием.
|
||
|
||
Граница за пределами четырёхзначного года отвергается потому, что объекты
|
||
адресуются строкой RFC 3339 и границы сравниваются лексикографически:
|
||
`9999-12-31T23:00:00-07:00` становится `10000-01-01T06:00:00Z`, который как
|
||
строка меньше любой настоящей метки, — и запрос молча отдал бы пустой ряд при
|
||
непустых данных.
|
||
|
||
Полуинтервал взят потому, что соседние окна обязаны склеиваться без двойного
|
||
счёта граничной точки. Явная зона обязательна потому, что вопрос «в какой зоне
|
||
считать сутки» в проекте открыт: принять голую дату значило бы выбрать зону за
|
||
клиента молча.
|
||
|
||
Параметр `bucket` этой версией маршрута не поддержан: система SHALL отвечать
|
||
`400` на его присутствие с любым значением и MUST NOT игнорировать его молча.
|
||
Молчаливое игнорирование дало бы клиенту, попросившему суточную сетку, полный
|
||
минутный ряд — зеркало того самого промаха, ради которого соседняя задача
|
||
различает «указали сетку» как информацию и как защиту. Прочие незнакомые
|
||
параметры запроса система игнорирует.
|
||
|
||
#### Scenario: Параметр периода отсутствует
|
||
|
||
- **WHEN** в запросе нет `from` или нет `to`
|
||
- **THEN** ответ `400`, и период умолчанием не подставляется
|
||
|
||
#### Scenario: Метка времени без зоны
|
||
|
||
- **WHEN** значение `from` или `to` записано без явного смещения зоны
|
||
- **THEN** ответ `400`
|
||
|
||
#### Scenario: Границы периода вывернуты
|
||
|
||
- **WHEN** `from` не раньше `to`
|
||
- **THEN** ответ `400`
|
||
|
||
#### Scenario: Граница выходит за четырёхзначный год
|
||
|
||
- **WHEN** граница периода после приведения к UTC попадает в год за пределами
|
||
диапазона 1–9999
|
||
- **THEN** ответ `400`, а не `200` с пустым рядом
|
||
|
||
#### Scenario: Запрошена сетка свёртки
|
||
|
||
- **WHEN** в запросе присутствует параметр `bucket`
|
||
- **THEN** ответ `400`, называющий, что свёртка ещё не поддержана
|
||
|
||
#### Scenario: Точка стоит ровно на границе
|
||
|
||
- **GIVEN** точки с метками ровно в `from` и ровно в `to`
|
||
- **WHEN** маршрут отвечает
|
||
- **THEN** точка на `from` в ответе есть, а точка на `to` — нет
|
||
|
||
### Requirement: Значение точки уезжает дословно, нормализовано только время
|
||
|
||
Система SHALL отдавать каждую точку объектом `{ts, ts_end, tz_offset, units,
|
||
values}`, где `values` MUST быть содержимым точки ровно в том виде, в каком его
|
||
сохранило хранилище, без переименования полей, пересчёта единиц, отбрасывания
|
||
незнакомого и **без экранирования**. `ts` и `ts_end` MUST быть нормализованными
|
||
к UTC метками начала и конца координаты точки; у точки-измерения `ts_end` равен
|
||
`ts`. `tz_offset` и `units` MUST принадлежать самой точке: смещение исходной
|
||
зоны — её собственное, единицы — того объекта, из которого точка прочитана.
|
||
|
||
Точки MUST идти по возрастанию `(ts, ts_end)`. Порядок этим определён
|
||
однозначно: пара `(начало, конец)` внутри одного слоя одной метрики есть ключ
|
||
идентичности, и двух точек с равной парой в витрине не существует.
|
||
|
||
Принадлежность точки периоду определяется её **началом**: точка-интервал,
|
||
начавшаяся раньше `from`, в ответ не входит, даже если её конец лежит внутри
|
||
периода. Правило то же, каким час объекта берётся по началу точки; цена названа
|
||
вслух — запрос «сон за ночь с полуночи» не увидит эпизод, начавшийся до неё.
|
||
|
||
`ts_end` присутствует потому, что идентичность точки — интервал, а не метка: под
|
||
одной меткой лежит до трёх записей сна, и конверт с одним `ts` предлагал бы
|
||
клиенту различать их, разбирая дословное содержимое.
|
||
|
||
#### Scenario: Точка-интервал
|
||
|
||
- **GIVEN** точка, у которой конец координаты отличается от начала
|
||
- **WHEN** маршрут отвечает
|
||
- **THEN** `ts_end` отличается от `ts` и называет конец координаты
|
||
|
||
#### Scenario: Содержимое не переписывается
|
||
|
||
- **WHEN** точка уезжает клиенту
|
||
- **THEN** `values` побайтово совпадает с сохранённым содержимым точки
|
||
|
||
#### Scenario: Содержимое несёт символы, которые сериализатор склонен экранировать
|
||
|
||
- **GIVEN** точка, чьё содержимое несёт `&`, `<` или `>`
|
||
- **WHEN** маршрут отвечает
|
||
- **THEN** эти символы уезжают как есть, а не escape-последовательностями
|
||
|
||
### Requirement: Пустота периода — успех, а не отсутствие ресурса
|
||
|
||
Система SHALL отвечать `200` на запрос метрики, у которой нет данных в
|
||
запрошенном периоде, и MUST отдавать `points` пустым списком. Система MUST NOT
|
||
отвечать `404` по признаку «нет данных».
|
||
|
||
`layer` равен `null` **только** тогда, когда слой выбирала система и выбирать
|
||
было не из чего. Заданный клиентом `layer` уезжает в ответе всегда, даже когда
|
||
ряд пуст: иначе клиент, спросивший разрез поимённо, не отличил бы «этого разреза
|
||
за период нет» от «маршрут проигнорировал параметр».
|
||
|
||
Различать опечатку в имени метрики и честную пустоту — работа каталога: список
|
||
имён маршруту точек не принадлежит.
|
||
|
||
#### Scenario: Данных за период нет и слой не задан
|
||
|
||
- **WHEN** у метрики нет ни одной точки внутри периода и параметр `layer` не
|
||
задан
|
||
- **THEN** ответ `200`, `points` равен `[]`, `layer` равен `null`
|
||
|
||
#### Scenario: Данных за период нет, а слой задан
|
||
|
||
- **WHEN** у метрики нет точек запрошенного слоя внутри периода
|
||
- **THEN** ответ `200`, `points` равен `[]`, `layer` равен запрошенному
|
||
|
||
#### Scenario: Имени метрики в витрине нет вовсе
|
||
|
||
- **WHEN** запрошено имя метрики, которого в витрине нет
|
||
- **THEN** ответ `200` с пустым `points`, а не `404`
|
||
|
||
### Requirement: Ответ снимается одним снимком витрины
|
||
|
||
Система SHALL читать охваты слоёв, точки выбранного слоя и объекты окна
|
||
измерения **в одной транзакции чтения**. Смесь «слой выбран до коммита свёртки,
|
||
точки прочитаны после» и «род измерен на третьем состоянии» дала бы ответ,
|
||
внутренне противоречивый и неотличимый от обычного свежего; пара проб версии
|
||
такой ответ обнаруживает, но не предотвращает — она снимает метку, а тело всё
|
||
равно уезжает.
|
||
|
||
#### Scenario: Свёртка коммитит во время чтения
|
||
|
||
- **WHEN** фоновая свёртка коммитит в витрину между выбором слоя и чтением точек
|
||
- **THEN** ответ собран из одного снимка витрины, а не из двух состояний
|
||
|
||
### Requirement: Метка ответа включает канонизированную форму запроса и горизонт измерения
|
||
|
||
Система SHALL помечать ответ меткой, область действия которой MUST быть
|
||
функцией канонизированной формы запроса — имени метрики, границ периода и слоя —
|
||
и которая MUST включать **горизонт измерения**, огрублённый до часа, наравне с
|
||
версией витрины. При невозможности подписать ответ система SHALL отдавать его
|
||
без метки, а не отказом.
|
||
|
||
Область MUST быть **ограничена по длине и не выносить значения запроса наружу**.
|
||
Имя метрики приходит из чужого тела дословно и ничем не ограничено: положенное
|
||
в метку как есть, оно даёт заголовок в тысячи байт, а кавычка внутри имени по
|
||
HTTP кончает метку — разбор обрежет её там, и условный запрос по такой метрике
|
||
не сработает никогда. Тот же вход в лог уезжает обрезанным, и у метки предел
|
||
обязан быть по той же причине.
|
||
|
||
Канонизация границ MUST сохранять точность, по которой отбираются точки: два
|
||
запроса, различающиеся долей секунды, дают разные ряды, и одна метка на них
|
||
подтвердила бы неизменность чужого набора данных.
|
||
|
||
Горизонт входит в метку по той же причине, по какой он входит в метку каталога:
|
||
род объявляется по окну, ограниченному сверху `текущее время + запас`, и метка
|
||
из будущего, лежащая в витрине, въезжает в окно **без единого коммита**, меняя
|
||
`aggregation.style` и `last_hour`. Метка без горизонта подтвердила бы
|
||
неизменность ответа, в котором род уже перевернулся. Форма запроса входит в
|
||
область действия потому, что ответ этого маршрута есть функция параметров: метка
|
||
без них однажды подтвердила бы неизменность чужого набора данных. Род и метка
|
||
MUST сниматься с одного горизонта.
|
||
|
||
Цена названа: один полный ответ в час на потребителя при неизменившейся витрине.
|
||
|
||
#### Scenario: Запросы различаются параметром
|
||
|
||
- **WHEN** два запроса при одном состоянии витрины различаются метрикой,
|
||
границей периода или слоем
|
||
- **THEN** метки их ответов различны
|
||
|
||
#### Scenario: Запросы различаются только формой записи
|
||
|
||
- **WHEN** два запроса при одном состоянии витрины задают одно и то же разной
|
||
записью смещения зоны
|
||
- **THEN** метки их ответов совпадают
|
||
|
||
#### Scenario: Границы различаются долей секунды
|
||
|
||
- **WHEN** два запроса при одном состоянии витрины различаются границей периода
|
||
на долю секунды и дают разные ряды
|
||
- **THEN** метки их ответов различны
|
||
|
||
#### Scenario: Имя метрики длинное или содержит кавычку
|
||
|
||
- **WHEN** запрошена метрика, чьё имя длиной в тысячи символов или содержит
|
||
кавычку
|
||
- **THEN** метка ответа остаётся короткой и не содержит имени метрики
|
||
|
||
#### Scenario: Горизонт перешёл через час
|
||
|
||
- **WHEN** горизонт измерения перешёл через границу часа при неизменной версии
|
||
витрины
|
||
- **THEN** метка ответа изменилась
|
||
|
||
#### Scenario: Витрина изменилась во время чтения
|
||
|
||
- **WHEN** пробы версии витрины разошлись
|
||
- **THEN** ответ уходит целиком и без метки
|
||
|
||
### Requirement: Исход маршрута точек наблюдаем, а данные о здоровье в лог не уезжают
|
||
|
||
Система SHALL писать один логирующий чекпоинт исхода на доменной границе
|
||
маршрута и MUST NOT доводить до записи лога значения точек, содержимое `values`
|
||
и границы запроса; имя метрики MUST быть обрезано по тому же пределу, что у
|
||
каталога. Отмена запроса клиентом и занятость базы MUST писаться уровнем
|
||
`DEBUG`, а настоящий отказ хранилища — уровнем `ERROR`.
|
||
|
||
Предупреждения измерения — противоречащий род и данные, помеченные будущим, —
|
||
остаются привилегией каталога: маршрут точек их MUST NOT повторять. Агент
|
||
опрашивает по расписанию, и `WARN` на каждый опрос обесценил бы уровень ровно
|
||
так же, как обесценила бы его строка на каждый `304`.
|
||
|
||
#### Scenario: Клиент оборвал запрос
|
||
|
||
- **WHEN** потребитель обрывает запрос точек по своему тайм-ауту
|
||
- **THEN** запись об этом уходит уровнем `DEBUG`, а не `ERROR`
|
||
|
||
#### Scenario: Метрика с противоречащим родом запрошена многократно
|
||
|
||
- **WHEN** потребитель повторно запрашивает точки метрики, у которой род
|
||
противоречив
|
||
- **THEN** маршрут точек предупреждений об этом не пишет
|
||
|
||
#### Scenario: Тело ответа оборвалось на середине
|
||
|
||
- **WHEN** запись тела ответа отказала после того, как код ответа уже отдан
|
||
- **THEN** отказ записи оставляет собственный чекпоинт и не проходит молча под
|
||
видом успешного ответа
|