| name | http-io |
| description | Проектирование I/O-объекта поверх HTTP к внешнему дозируемому (rate-limited / metered) сервису — LLM, linter-as-service, embeddings, любой внешний API. Применять, когда слайсу нужен исходящий HTTP-вызов и важно НЕ перегрузить поставщика и НЕ отправить лишний контекст. Спина скилла — два бюджета (нагрузки и payload), считаются в дизайне слайса ДО кода. Поток: curl-проба → машинная спека провайдера (OpenAPI/AsyncAPI; если у поставщика её нет — пишем свою) → из неё выводятся клиент, стаб и фикстуры юнит/компонентных тестов; бюджеты протягиваются в тесты как ассершены. Для LLM-специфики (OpenAI-совместимый протокол, response_format, JTBD-фан-аут) — см. скилл llm-client как специализацию. |
http-io — дисциплина исходящих HTTP-вызовов к дозируемому сервису
Скилл обобщает уроки реализации LLMClient (internal/slice/fitness/io.go,
девлог devlog/01-llm-client-lessons.md) на любой I/O-объект, скрывающий HTTP
к внешнему сервису с тарификацией или лимитами: LLM, линтер-как-сервис,
embeddings, корпоративный API. RepoStore (ФС) и ReportSink (stdout/файл) сюда
не относятся — у них нет ни поставщика, который можно перегрузить, ни
дозируемого контекста.
Главный тезис девлога: все шесть дефектов S5 закрывались не в кодинге, а в
дизайне и в верификации-до-кода. Этот скилл переносит решения на этап
проектирования слайса. Для LLM-частностей — скилл llm-client.
Два бюджета — проектные параметры, не дефолты
Любой исходящий вызов к дозируемому сервису имеет два независимых ограничения.
Оба считаются в дизайн-карте слайса до первой строки I/O-кода, фиксируются
в конфиге и затем протягиваются в тесты как ассершены (раздел «От curl к
тестам»), а не «всплывают» после первого 429.
| Бюджет | Вопрос | Где зафиксирован | Дефект девлога |
|---|
| Нагрузки | сколько запросов × как часто ≤ окно поставщика? | пауза/конкуррентность в I/O-объекте, тир в доке | #2, #4 |
| Payload | что и сколько байт уходит в один запрос? | whitelist входа в конфиге (docs:) | #3 |
«Отправить всё» и «звать в цикле без паузы» — не дефолты, а отложенные
аварии. Дизайн обязан явно ответить на оба вопроса.
Бюджет нагрузки — не перегружать поставщика
Считается формулой, до кода
N_вызовов_за_команду × токены_на_вызов ≤ TPM-окно тира
N_вызовов_за_команду ≤ RPM-окно тира
Для S5: 4 JTBD-роли × ~15k токенов = 60k токенов за ~секунды. На tier-1 это
превышает TPM-окно (одно окно — 1 минута) → второй вызов ловит 429, и пауза
не помогает, пока окно не сбросится (девлог, ошибка 3–4).
Лимиты читаем у поставщика, не хардкодим
Тиры и TPM/RPM меняются и зависят от модели — таблицу в коде держать нельзя,
она протухнет. Два надёжных источника:
- Заголовки ответа (Anthropic):
anthropic-ratelimit-requests-remaining,
anthropic-ratelimit-tokens-remaining, anthropic-ratelimit-tokens-reset,
retry-after. Это источник истины в рантайме.
- Консоль поставщика — для проектной оценки «влезаем ли вообще».
Пацинг: адаптивный, не фиксированный sleep
Текущий Ask ставит фиксированную паузу callDelayMs между вызовами (девлог,
ошибка 4). Это пол, а не решение. Правильная лестница, от простого к точному:
- Последовательно + пауза (есть сейчас) — годится, пока
N мало и команда
не на горячем пути. Пауза в конфиге, не хардкод.
- Backoff по
Retry-After — на 429 не падать сразу, а уважить заголовок:
спать ровно Retry-After секунд и повторить (с потолком повторов). Это
снимает большинство 429 без раздувания фиксированной паузы.
- Адаптивный пацинг по
*-tokens-remaining — если в окне осталось мало
токенов, притормозить перед следующим вызовом. Опционально, когда N растёт.
- Ограниченная конкуррентность — если нужна скорость: семафор на
k
одновременных вызовов, k подобран под RPM. Не «все сразу».
Решение, какую ступень брать, — проектное, зависит от N слайса и тира.
Зафиксировать в дизайн-карте слайса вместе с числами.
Маппинг 429 — отдельная семантика
429 — штатный сценарий, не edge case (девлог, P4). В I/O-объекте: либо
backoff-и-повтор (если выбрана ступень 2+), либо доменная sentinel-ошибка
(ErrLLMRateLimited и аналоги). Не «общий HTTP error».
Бюджет payload — не слать лишний контекст
Оценка размера, до кода
токены ≈ байты / 4 (грубо, для англ/смешанного текста)
Σ(размер входа) / 4 × N_вызовов должно влезать в TPM-окно
Репа passkey-demo-api: 851 KB markdown → ~233k токенов на вызов, ×4 роли ≈ 900k
токенов за команду — в 9× выше TPM tier-1 (девлог, ошибка 3). Это считается из
размера целевых репо на этапе дизайна, не после первого запуска.
Whitelist входа в конфиге — обязателен
Граница контекста — проектное решение (девлог, P3). «Что именно отправляем?» —
явный список в конфиге, а не «всё подряд рекурсивно»:
docs:
- README.md
- CLAUDE.md
- CONTRIBUTING.md
- AGENTS.md
I/O-объект читает только перечисленное (ReadMarkdownDocsByList), отсутствующее
пропускает. Без whitelist крупная репа съедает TPM на первом же вызове.
Что НЕ отправлять
- бинарники, vendored/
node_modules, генерённое;
- дубли (один и тот же корпус в N промптов — если контент общий, дешевле
один раз; для S5 общий — но осознанно);
- то, что не нужно слою для решения (L5 оценивает doc-файлы, не весь код).
Если документ всё равно велик
- truncation с логом — обрезать до лимита и сказать об этом в отчёте/логе.
Молчаливая обрезка читается как «оценили целиком», хотя это не так (принцип
«no silent caps»);
- chunking — разбить и агрегировать, если слою это корректно;
- дельта — для слоёв дрейфа (S6/S8) слать только изменённое, не весь файл.
Верификация до кода — curl-first
Три из шести дефектов S5 нашёл бы минимальный curl до написания клиента
(девлог, P1). Перед реализацией I/O-объекта проверить руками:
- endpoint — точный путь (для Anthropic OpenAI-слоя:
/v1/chat/completions,
а не /chat/completions — пропавший /v1 дал 400, девлог ошибка 1);
- auth — какой заголовок (
Authorization: Bearer vs x-api-key);
- форма payload и ответа — поля, вложенность;
- тело ошибок — что приходит на 4xx/5xx, как отличить квоту от невалида;
- (LLM)
response_format — рабочий вариант, см. llm-client
(json_object Anthropic не поддерживает — только json_schema + strict + additionalProperties:false, три итерации curl, девлог ошибка 6).
Сначала curl убеждает, что формат живой, потом кодируется структура запроса.
Это дешевле трёх итераций курл-диагностики на уже написанном коде.
Спека провайдера — источник истины (OpenAPI / AsyncAPI)
Curl доказывает, что контракт живой; спека его замораживает. Идеальный
порядок: curl-проба → проверенный контракт оформляется машинной спекой → из неё
выводятся клиент, стаб и фикстуры. Тогда три артефакта не разъезжаются — у них
один источник.
- У поставщика есть машинная спека (OpenAPI/AsyncAPI) — берём её как источник
истины, не переписываем формы руками.
- Спеки нет (частый случай: Anthropic OpenAI-слой задокументирован прозой, не
машинно под наше использование) — пишем свою на проверенный curl-ом контракт
и кладём рядом с контрактом тула:
api-specification/providers/<name>.openapi.yaml
(параллель к api-specification/cli.md + report.schema.json).
Какую спеку:
| Спека | Когда | Что описывает |
|---|
| OpenAPI | sync request/response (POST /chat/completions) | эндпоинт, auth, схема запроса (вкл. response_format), схема ответа, тела ошибок 4xx/5xx |
| AsyncAPI | стрим/события (SSE токен-стрим, вебхуки, очереди) | каналы, формат сообщений, порядок событий |
Дисциплина объёма: спекаем только те эндпоинты и поля, что реально
используем, не весь API провайдера. Это та же граница, что и у payload-бюджета
— не моделируем то, что не отправляем и не читаем.
Что спека даёт дальше:
- клиент — request/response-структуры выводятся из схем спеки, не из догадок;
- стаб — обязан соответствовать той же спеке (один контракт у клиента и
стаба → стаб не «врёт» относительно реального провайдера);
- тесты — happy-ответ валидируется против схемы из спеки (как отчёт тула
против
report.schema.json в E1.1); фикстуры ошибок — из описанных тел 4xx/5xx.
От curl к тестам — с учётом формул
Спека провайдера (выше) — источник истины, который протягивается в оба слоя
тестов. А два бюджета из формул становятся ассершенами, не комментариями.
Что во что превращается:
| Зафиксировано спекой / curl | Куда едет |
|---|
форма запроса (endpoint, auth, поля, response_format) | стаб реализует тот же эндпоинт, соответствует спеке |
| схема happy-ответа | валидация ответа в компонент-тесте (как report.schema.json) |
| реальный ответ happy | testdata/real-responses/ → детерминированный режим стаба + фикстура юнита парсера |
| варианты ответа (чистый JSON / markdown-fenced / тела ошибок 4xx/5xx) | фикстуры юнита парсера и режимы стаба |
Юнит-тесты (logic.go, чистые листья) — тестируют формулы
I/O-объект сам юнитами не покрывается (infra: success → happy-сценарий,
failure → сценарий отказа). Юнитим то, что вокруг него — чистую логику
бюджетов и парсинга, оформленную отдельными функциями именно ради тестируемости:
- оценка токенов —
estimateTokens(corpus) против известного размера
(формула байты/4): на фикстуре N байт ожидаем ~N/4;
- whitelist-отбор —
selectDocs(repoFiles, cfg.Docs) → ровно перечисленные,
отсутствующие пропущены, лишние .md не попали;
- пацинг/backoff —
waitFor(retryAfter, remaining) → ожидаемая пауза для
выбранной в дизайне ступени (уважение Retry-After, потолок повторов);
- парсинг ответа против curl-захваченных фикстур — чистый JSON и
markdown-fenced (
extractJSON как второй эшелон);
- маппинг статуса → класс отказа — 429→transient, 4xx-невалид→permanent,
usage>limit→quota → нужный sentinel.
Компонентные тесты (стаб) — контрактные ветки + границы бюджетов
- happy — ответ формы, проверенной curl (через
real-responses режим стаба);
- режимы отказа из контракта — по одному на различимый
error.code:
rate_limited → 429 + Retry-After, unavailable → 5xx,
budget_exceeded → стаб отдаёт usage > limit;
- граница payload — стаб ассертит, что тело запроса несёт только
whitelisted-доки, а не все
.md: payload-бюджет как наблюдаемое поведение
контракта, а не внутренняя деталь.
Граница со слоем юнитов (memory component-tests-contract-rubric): узкие
фикстуры «под один слой» не плодим. Новый компонент-сценарий оправдан новой
веткой контракта (новый error.code, новый формат ответа), а не «больше
данных». Бюджетная формула, которая не даёт новой ветки контракта (точное число
токенов, точная пауза backoff), проверяется юнитом, не компонентом —
компонент проверяет лишь наблюдаемый результат (отдали whitelist / поймали 429).
Форма I/O-объекта
Как у всех I/O в internal/io (см. infrastructure.md): автономный объект,
скрывающий зависимость; метод — труба (одно сообщение → внешний вызов →
результат/доменная ошибка); единственное ветвление — маппинг кода внешней
системы в доменный sentinel.
Три класса режимов отказа — обобщение трёх LLM-sentinel на любой сервис:
| Класс | Природа | Что делать | LLM-пример |
|---|
| transient | 429, 5xx, сеть, таймаут | backoff-повтор или sentinel | ErrLLMRateLimited / ErrLLMUnavailable |
| permanent | 4xx-невалид, decode | sentinel сразу, без повтора | ErrLLMUnavailable (parse) |
| quota | usage > limit, отсутствие ключа | fail-fast, sentinel | ErrLLMBudgetExceeded |
- ключ/секрет — из env по имени из конфига (
api_key_env), fail-fast до I/O,
если не задан (девлог; ADR 0003 — секретов в YAML нет);
- защитный лимит токенов (
tokenBudgetLimit) — предохранитель от аномалий,
выше реального максимума целевых репо, не ниже (девлог P5; 50k был занижен
в 4–5×, поднят до 300k);
- таймаут HTTP-клиента задан явно (
http.Client{Timeout: …}), не «бесконечный».
Стаб в Docker Compose
Внешний сервис в компонентных тестах — отдельный HTTP-сервис в Compose на том
же эндпоинте, не in-code мок (скилл component-tests; memory: harness-развилку не
пере-обсуждать). Переключение режима через POST /control {"mode":"…"}; режимы
соответствуют различимым режимам отказа из контракта (healthy, rate_limited →
429+Retry-After, unavailable → 5xx, и т.д.). LLM-специфика стаба (маркер
role:<key>, реальные ответы в testdata/real-responses/) — в llm-client.
Чеклист дизайна слайса (до первой строки I/O-кода)
Это то, что добавляется в дизайн-карту слайса с HTTP-вызовом до реализации:
Чеклист реализации (после дизайна)
Перед коммитом
gofmt -l ./internal/slice/<name>/ (пусто = чисто) и, при новой зависимости,
go.sum закоммичен + копируется в Dockerfile.runtime (COPY go.mod go.sum ./).
Подробности — в llm-client → «Перед коммитом».