---
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`](../llm-client/SKILL.md).

---

## Два бюджета — проектные параметры, не дефолты

Любой исходящий вызов к дозируемому сервису имеет два независимых ограничения.
Оба считаются **в дизайн-карте слайса до первой строки 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 **меняются и зависят от модели** — таблицу в коде держать нельзя,
она протухнет. Два надёжных источника:

1. **Заголовки ответа** (Anthropic): `anthropic-ratelimit-requests-remaining`,
   `anthropic-ratelimit-tokens-remaining`, `anthropic-ratelimit-tokens-reset`,
   `retry-after`. Это источник истины в рантайме.
2. **Консоль поставщика** — для проектной оценки «влезаем ли вообще».

### Пацинг: адаптивный, не фиксированный sleep

Текущий `Ask` ставит фиксированную паузу `callDelayMs` между вызовами (девлог,
ошибка 4). Это **пол**, а не решение. Правильная лестница, от простого к точному:

1. **Последовательно + пауза** (есть сейчас) — годится, пока `N` мало и команда
   не на горячем пути. Пауза в конфиге, не хардкод.
2. **Backoff по `Retry-After`** — на 429 не падать сразу, а уважить заголовок:
   спать ровно `Retry-After` секунд и повторить (с потолком повторов). Это
   снимает большинство 429 без раздувания фиксированной паузы.
3. **Адаптивный пацинг по `*-tokens-remaining`** — если в окне осталось мало
   токенов, притормозить перед следующим вызовом. Опционально, когда `N` растёт.
4. **Ограниченная конкуррентность** — если нужна скорость: семафор на `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). «Что именно отправляем?» —
явный список в конфиге, а не «всё подряд рекурсивно»:

```yaml
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-объекта проверить руками:

1. **endpoint** — точный путь (для Anthropic OpenAI-слоя: `/v1/chat/completions`,
   а не `/chat/completions` — пропавший `/v1` дал 400, девлог ошибка 1);
2. **auth** — какой заголовок (`Authorization: Bearer` vs `x-api-key`);
3. **форма payload и ответа** — поля, вложенность;
4. **тело ошибок** — что приходит на 4xx/5xx, как отличить квоту от невалида;
5. **(LLM)** `response_format` — рабочий вариант, см. [`llm-client`](../llm-client/SKILL.md)
   (`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`](../llm-client/SKILL.md).

---

## Чеклист дизайна слайса (до первой строки I/O-кода)

Это то, что добавляется в дизайн-карту слайса с HTTP-вызовом **до** реализации:

- [ ] endpoint + auth + форма payload/ответа проверены **curl-ом**
- [ ] есть машинная спека провайдера; если нет — написана своя (OpenAPI для sync,
      AsyncAPI для стрима) на проверенный контракт, в `api-specification/providers/`;
      клиент и стаб выводятся из неё
- [ ] curl-захваченные ответы (happy + варианты ошибок) сохранены как фикстуры
      юнита парсера и как режимы стаба
- [ ] **бюджет нагрузки** посчитан: `N_вызовов × размер ≤ TPM`, `N ≤ RPM`; числа
      и тир записаны в карте
- [ ] выбрана ступень пацинга (последовательно+пауза / backoff по `Retry-After` /
      адаптив / конкуррентность) — обоснована числом `N`
- [ ] **бюджет payload** посчитан из размера целевых репо: `Σ байт /4 × N < TPM`
- [ ] **whitelist входа** определён и вынесен в конфиг (не «всё подряд»)
- [ ] стратегия на слишком большой вход: truncate-с-логом / chunk / дельта
- [ ] формулы бюджетов оформлены как **чистые функции** (`estimateTokens`,
      `selectDocs`, `waitFor`) — ради юнит-тестируемости
- [ ] режимы отказа размечены по классам transient / permanent / quota → sentinel
- [ ] секрет — из env по имени из конфига, fail-fast до I/O
- [ ] защитный лимит токенов — выше реального максимума целевых репо
- [ ] таймаут HTTP-клиента задан явно
- [ ] режимы стаба перечислены из контракта (по одному на различимый отказ)

---

## Чеклист реализации (после дизайна)

- [ ] `baseURL` включает версионный префикс (`/v1`), не просто домен
- [ ] маппинг внешних кодов → доменные sentinel, не «общий HTTP error»
- [ ] пацинг/backoff реализован по выбранной ступени; пауза из конфига, не хардкод
- [ ] вход читается по whitelist из конфига
- [ ] (LLM) structured output обязателен — см. [`llm-client`](../llm-client/SKILL.md)
- [ ] **юнит-тесты формул**: токен-оценка, whitelist-отбор, пацинг, парсинг
      (чистый + fenced), маппинг статуса → класс отказа
- [ ] **компонент**: happy формы из curl + transient/permanent/quota +
      граница payload (стаб ассертит, что ушёл только whitelist)
- [ ] стаб различает режимы через `/control`

---

## Перед коммитом

`gofmt -l ./internal/slice/<name>/` (пусто = чисто) и, при новой зависимости,
`go.sum` закоммичен + копируется в `Dockerfile.runtime` (`COPY go.mod go.sum ./`).
Подробности — в [`llm-client`](../llm-client/SKILL.md) → «Перед коммитом».
