Response API#

Одна успешная case#

Тип результата операции и есть успешный случай:

from dataclasses import dataclass

from pydantic import BaseModel

from eazy_sdk import AsyncApi, Http, HttpOperation, Path, op

class User(BaseModel):
    id: int

@dataclass(frozen=True, slots=True, kw_only=True)
class GetUser(HttpOperation[User]):
    __http__ = Http.get("/users/{user_id}")

    user_id: Path[int]

class UsersApi(AsyncApi):
    get_user = op(GetUser)

Семья читается из модели: модель с селекторами CSS, XPath или Regex означает документ, модель, у которой каждое поле читается из заголовков ответа, читается без тела, любая другая модель означает JSON, str означает текст, bytes означает байты, а None означает пустое тело. Явные Json(status=201), Html(...), HeaderModel(...), Text() нужны, когда важны статус, медиа-тип или условие when=/accept=.

Несколько случаев#

success=, errors= и fallback= принимают мэппинг селектора на случай:

__http__ = Http.get(
    "/users/{user_id}",
    success={200: User, 202: PendingUser},
    errors={404: UserNotFound, "5xx": ServiceUnavailable},
    fallback=Problem,
)

Ключом служит статус, диапазон ("4xx", "5xx") или DEFAULT; DEFAULT допустим только в fallback=. Значение может быть моделью, представлением, классом ApiError[Model], парой (Model, factory) или их списком. Эквивалентная запись через Responses(success=(Success(...),), errors=(Error(...),)) остаётся доступной: это то же значение, собранное вручную.

Representation

Результат

Json(Model, media_type=...)

dataclass, Pydantic, msgspec или custom model adapter

Html(Model, media_type=...)

CSS/XPath extraction с последующим model adapter

HeaderModel(Model)

Модель из Location, FromHeader и FromCookie; тело не читается

Extracted(Model, using=...)

Custom primitive extractor с последующим model adapter

Parsed(Model, using=...)

Custom response parser

Text(media_type=...)

str

Bytes(media_type=...)

bytes

Empty(media_type=...)

None, только для пустого body

Status selector может быть int, StatusRange(start, end) или DEFAULT.

Источники поля вне тела#

Маркер поля

Откуда значение

Пропуск обязательного поля

FromHeader("X-Request-Id")

одна строка заголовка ответа

MalformedResponseError

FromCookie("sid")

кука из Set-Cookie этого ответа, последняя из одноимённых

MalformedResponseError

Location(host=..., path=..., query=..., contains=...)

полный адрес из Location

случай не выбран

Location.query("fail")

значение параметра адреса

случай не выбран

Location на поле, тип которого не допускает None, считается критерием случая наравне с when=, accept= и Const(...). Тот же объект передаётся в when= как условие. Случай на статусе 3xx без критерия отклоняется при объявлении операции.

Редирект, на статус которого операция объявила случай, читает операция: клиент ему не следует.

Несколько источников одного поля#

A | B на маркерах источников даёт FirstOf: источники пробуются слева направо, поле получает первое найденное значение.

class LoginPage(BaseModel):
    csrf: Annotated[str, Regex(r'"csrf":"([^"]+)"') | FromCookie("act")]

В цепочку входят селекторы документа (CSS, XPath, Regex), FromHeader, FromCookie и Location.query(...). Обязательное поле, которое не нашлось ни в одном источнике, даёт MalformedResponseError; если в цепочке есть Location.query(...), случай не выбран. Шаблон Location(...) в цепочку не входит: он стоит на поле один.

Теги поля#

Тег

Утверждение

Const(200)

поле равно значению

OneOf(401, 403)

поле равно одному из значений

Not(200)

поле есть и не равно значению

Not(200, 401, 403)

поле есть и не равно ни одному из значений

Поле объявляет один тег. Значения сравниваются вместе с типом. Тег читается одинаково в success= и в errors=. Два случая, чьи теги подошли одному ответу, дают AmbiguousResponseError: Not(200, 401, 403) рядом с OneOf(401, 403) не пересекаются, Not(200) рядом с ним пересекается.

Payload[T] работает в обобщённой pydantic-модели: success={200: Ok[Account]} возвращает Account. Модель без параметра отклоняется при объявлении.

Ошибка, означающая устаревшую сессию#

class TokenRejected(ApiError[TokenRefused]):
    reauth = True

Ответ, который выбрал такой случай, обновляет сессию и повторяет запрос: тот же бюджет Resilience.auth_retries и то же требование идемпотентности, что у HTTP 401. Исключение поднимается, когда обновлять больше нечем. Полная форма: Error(..., reauth=True). На операции без security такой случай отклоняется при объявлении.

Typed callable parser#

Для одного custom model используйте фабрику, связывающую callback с типом заранее:

from eazy_sdk.ext import Malformed, NoMatch, ParseAttempt, ParsedValue
from eazy_sdk.response import ResponseContext, callable_parser

def parse_challenge(
    context: ResponseContext[object],
) -> ParseAttempt[Challenge]:
    if context.response.status_code != 403:
        return NoMatch()
    return ParsedValue(Challenge(context.text.value or ""))

CHALLENGE_PARSER = callable_parser(Challenge, parse_challenge)

Runtime не передаёт callback-у type[object]. Несовместимый return type отклоняют mypy и Pyright. Для multi-model parser реализуйте advanced ResponseParser SPI. Старого CallableParser и compatibility alias нет.

Результаты и ошибки#

Обычный вызов даёт значение success case. method.with_response(...) возвращает ResponseEnvelope: value, status_code, headers и normalized raw response.

В ApiError[T] доступны typed .error и безопасный .context. Свойство .summary всегда доступно и даёт ErrorSummary с operation_id, статусом, media type, номером попытки, методом и редактированным target. Именно она переживает pickle: сериализованное исключение не несёт ни тела ответа, ни его заголовков, а восстановленный .context содержит ErrorSummary.

Ошибки классификации разделены:

  • UnexpectedResponseError: подходящей case нет; если ответ пришёл документной медиа, а все кандидаты структурные, сообщение это называет;

  • MalformedResponseError: body не разобран выбранным parser/extractor;

  • AmbiguousResponseError: совпало больше одной одинаково специфичной case. При сравнении наличие заявленного критерия важнее его отсутствия, точный статус важнее диапазона, а диапазон важнее DEFAULT. Явная медиа важнее wildcard и отсутствия медиа; кейс операции важнее кейса сервиса, а кейс сервиса важнее кейса клиента. Критерием служит when=, accept= или тег, который объявила модель.

Parsing session создаётся один раз на response attempt, поэтому custom parser или extractor может кэшировать decoded document по identity.