feat(1b): et tak som fyrer er ikke en krasj — BudgetExceeded får sin egen kanal [skip-docs]

Prosjektets første levende kjøring døde på `rounds limit=12 observed=13`, og den
hostede flaten svarte `500 {error_type, error}` — nøyaktig det samme den sier når
modell-endepunktet faller. Nå: 429 med trippelen som STRUKTUR.

Beslutningen er S3.4-invarianten anvendt på transporten: `budget_stop` ble holdt
utenfor `stop_reason` fordi de to stoppene betyr motsatte ting, og å svare
ressurs-utmattelse på krasj-kanalen gjør «det gikk ikke» uleselig på samme måte.

IKKE 200, og det er dét som skiller den fra `Rejection`: en `Rejection` er en
kjøring som KONKLUDERTE og hører i payloaden, mens et uttømt budsjett produserte
ingen proposal — en 2xx ville latt en automatisk kaller bokføre «analysert» for en
kjøring som analyserte ingenting. 429 fordi betingelsen oppstår av en TILDELING
(`max_rounds`/`max_tokens` er whitelistede request-felt), aldri av en serverfeil.

`kind`/`limit`/`observed` legges ut som felt, aldri `str(exc)` (kø-(y));
`error_type` holdes ute — den nøkkelen tilhører feilkanalen. `budget_exhausted` er
ikke foldet inn i `outcome_type` og kunne ikke vært det: `outbox.outcome_payload`
er den ene kopien av den forgreningen og tar `ValidatedProposal | Rejection`.
Ærlighets-grense: ingen `Retry-After` — å vente endrer ingenting.

Iron Law: begge nye tester RØDE før armen fantes. Fem mutasjoner mot HELE suiten,
alle røde med hver sin signatur, grønn kontroll 867/4: detach armen (2 røde) ·
flat streng i stedet for struktur (1 rød — struktur-testen alene) · ekko `limit`
som `observed` (1 rød) · utvid armen til `Exception` (6 røde) · stemple
`error_type` på budsjett-kroppen (1 rød).

500-armens vitne ble BYTTET, ikke slettet: den eksisterende testen brukte
`BudgetExceeded` som sin 500-prøve, så en ny arm alene ville etterlatt
krasj-kanalen uten vitne. Den bærer nå en ekte ikke-budsjett-`RuntimeError`, og er
dét som holder den nye armen smal.

Kjørt, ikke bare testet: `python main.py` startet, `/readiness` 200, ukjent felt →
400 med navnet, 404, SIGTERM → exit 0.

865 → 867 passed / 4 skipped; ruff + format + mypy rene. DEPLOY.md §6 dokumenterer
429 for mottakeren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1bsX79aDS7fJ5udWGWAEN
This commit is contained in:
Kjell Tore Guttormsen 2026-08-14 17:06:11 +02:00
commit 986fc19350
4 changed files with 143 additions and 9 deletions

View file

@ -183,6 +183,19 @@ name with HTTP 400, never silently ignored:
HTTP 400 means the request was malformed; 500 means the run itself failed, with `error_type` and
`error` in the body. A rejected proposal is **200** — see section 3.
**429 is not a failure.** It means the run reached its own allowance — the round cap or the token
cap — and stopped, which is the cap doing its job rather than anything breaking. The body carries
the ledger's own numbers, so you can tell *which* cap bound and how far past it the run got:
```json
{"budget_exhausted": {"kind": "rounds", "limit": 12, "observed": 13},
"error": "budget exceeded: rounds limit=12 observed=13"}
```
There is no `error_type` (nothing failed) and no `Retry-After` (waiting changes nothing). Retrying
the same body hits the same cap; the remedies are raising `max_rounds`/`max_tokens` for that
invocation, or accepting that this project did not resolve within the allowance you set.
---
## 7. Honest limits