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

@ -188,7 +188,8 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
ukjente felt NEKTES ved navn (400), aldri stille droppet (valg-doc §0-fella anvendt på vår egen
flate); `profile` defaulter til `azure` KUN her (containeren har ingen lokal endpoint;
`run_project`s egen default forblir LOCAL). Feilmapping ærlig: `ValueError` (pydantic-kontrakter
subklasser den) → 400, alt annet → 500 `{error_type, error}` (speiler `RunFailure`), og en
subklasser den) → 400, `BudgetExceeded` → 429 (EGEN rad under, 14.08), alt annet → 500
`{error_type, error}` (speiler `RunFailure`), og en
`Rejection` er en VELLYKKET kjøring → 200 — det negative utfallet tilhører payloaden, aldri
transporten. `outbox.outcome_payload` er den ENE kopien av validated/rejected-forgreningen
(delt av fil-skriveren og HTTP-responsen — to kopier drifter, kø-(p)-regelen). **Container-innpakningen
@ -216,6 +217,34 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
(`tests/test_hosting_loadbearing.py`), de tre gjenværende mutasjonene alle røde på riktig test og
på INGEN annen: send `project_id` to ganger · whitelist et felt `run_project` ikke tar · fjern
`bundle_dir` fra whitelisten.
- **Et tak som fyrer er IKKE en krasj — `BudgetExceeded` får sin EGEN kanal (429), og trippelen
bæres som STRUKTUR (1b-køen, 14.08):** prosjektets første levende kjøring døde på
`rounds limit=12 observed=13`, og den hostede flaten svarte `500 {error_type, error}` — altså
nøyaktig det samme den sier når modell-endepunktet faller. **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å nøyaktig 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 derfor i payloaden), mens et uttømt
budsjett produserte ingen `proposal` i det hele tatt — 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, og å heve dem er
kallerens egen botemiddel), aldri av en serverfeil — derfor 4xx, ikke 5xx.
`kind`/`limit`/`observed` legges ut som felt, ALDRI `str(exc)` (kø-(y): de beskriver ÉN ledger,
og «hvilket tak bandt, og hvor langt forbi» er hele det operative spørsmålet); `error_type`
holdes UTE — den nøkkelen tilhører feilkanalen, og en kaller som switcher på dens
tilstedeværelse skal ikke finne den her. `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`, som en uttømt kjøring ikke har noen av. **Ærlighets-grense,
uttalt:** ingen `Retry-After` — å vente endrer ingenting, botemiddelet er et større tak eller å
akseptere stoppet, og en header som lover tid ville vært en løgn. Load-bearing MÅLT
(`tests/test_hosting_loadbearing.py`), fem mutasjoner alle røde mot HELE suiten, hver med sin
egen signatur + grønn kontroll 867/4: detach armen (2 røde) · flat streng i stedet for struktur
(1 rød — struktur-testen ALENE, altså rir den ikke på status-asserten) · ekko `limit` som
`observed` (1 rød) · utvid armen til `Exception` (6 røde, inkl. 400-armen) · 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å å bare legge til en ny arm
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.
- **Påstander flaten gjør om SEG SELV gates som rå tekst, linjeforankret (Fase 3, A5):** to påstander
bodde i prosa der ingen test kunne se dem, og begge drev. (1) `env.template` sa at credential
resolves via `DefaultAzureCredential` — den har ALDRI gjort det; gaten leser de klassene

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

View file

@ -38,7 +38,22 @@ unknown field is a 400 naming the field — the permissive-schema trap (valg-doc
to our own surface. Error mapping is honest: ``ValueError`` (pydantic contract violations
subclass it) → 400; any other failure → 500 ``{error_type, error}`` (mirrors
``RunFailure``); a ``Rejection`` is a SUCCESSFUL run → 200 with ``outcome_type:
"rejected"`` — the negative outcome belongs to the payload, never to the transport. The
"rejected"`` — the negative outcome belongs to the payload, never to the transport.
``BudgetExceeded`` gets its OWN arm → 429, for the same reason ``BudgetStop`` is kept out of
``stop_reason`` (S3.4): a cap that fires is the feature working (``Budget`` exists so a run can
never hang unbounded), and answering it on the crash channel makes "it did not work"
unreadable — the first live run died exactly here and the surface said 500, the same thing it
says when the endpoint falls over. It is NOT 200 either: unlike a ``Rejection``, which is a run
that CONCLUDED, an exhausted budget produced no proposal, and a 2xx would let an automated
caller record "analysed" for a run that analysed nothing. 429 because the condition arises from
an ALLOWANCE — ``max_rounds``/``max_tokens`` are whitelisted request fields and raising them is
the caller's own remedy — never from a server fault. The ``kind``/``limit``/``observed`` triple
is carried as STRUCTURE, not flattened into ``str(exc)`` (kø-(y): it describes one ledger and
answering "which cap bound, and by how much" is the operational question), and ``error_type``
is deliberately absent — that key belongs to the failure channel. Honesty limit, stated: no
``Retry-After``. Retrying an unchanged body hits the same cap; the remedy is a larger allowance
or accepting the stop, and a header promising time would be a lie. The
platform's injected headers (``x-agent-user-id``/``x-agent-foundry-call-id``) are absent
locally by contract and unused here; forwarding the call-id on outgoing Foundry calls has
no seam in ``backends.py`` today and is deliberately not built (90 %-prinsippet).
@ -52,6 +67,7 @@ import os
import signal
from typing import Any
from portfolio_optimiser.budget import BudgetExceeded
from portfolio_optimiser.outbox import outcome_payload
from portfolio_optimiser.run import RunResult, run_project
@ -60,7 +76,13 @@ _HOSTED_DEFAULT_PROFILE = "azure"
_REQUIRED_FIELDS = ("project_id", "docs_dir", "verdict_input")
_OPTIONAL_FIELDS = ("bundle_dir", "profile", "max_rounds", "max_tokens", "top_k")
_ALLOWED_FIELDS = frozenset(_REQUIRED_FIELDS + _OPTIONAL_FIELDS)
_REASONS = {200: "OK", 400: "Bad Request", 404: "Not Found", 500: "Internal Server Error"}
_REASONS = {
200: "OK",
400: "Bad Request",
404: "Not Found",
429: "Too Many Requests",
500: "Internal Server Error",
}
class InvocationRefused(ValueError):
@ -115,6 +137,20 @@ async def invoke(payload: Any) -> dict[str, Any]:
return _response_payload(result)
def _budget_payload(exc: BudgetExceeded) -> dict[str, Any]:
"""The exhausted-budget body: the ledger's own triple, plus the human line for the log.
The ``budget_exhausted`` key's PRESENCE is the discriminator — it is not folded into
``outcome_type`` (whose values, ``validated``/``rejected``, mean "the run concluded and
here is the verdict") for the same reason ``BudgetStop`` was given its own field instead of
widening ``stop_reason``. Nor could it be: ``outcome_payload`` is the ONE copy of that fork
and takes a ``ValidatedProposal | Rejection``, neither of which an exhausted run has."""
return {
"budget_exhausted": {"kind": exc.kind, "limit": exc.limit, "observed": exc.observed},
"error": str(exc),
}
def _http_response(status: int, content_type: str, body: bytes) -> bytes:
head = (
f"HTTP/1.1 {status} {_REASONS[status]}\r\n"
@ -171,6 +207,10 @@ async def _respond(method: str, path: str, body: bytes) -> bytes:
return _json_response(400, {"error": "body is not valid JSON"})
try:
return _json_response(200, await invoke(payload))
except BudgetExceeded as exc:
# A cap that fired, not a failure — its own channel, and the triple kept as
# structure rather than re-parsed out of the message by whoever reads this.
return _json_response(429, _budget_payload(exc))
except ValueError as exc:
# The caller's error: InvocationRefused + run_project's fail-fast contract
# violations (pydantic ValidationError subclasses ValueError).

View file

@ -317,8 +317,11 @@ async def test_contract_violation_is_400_and_run_failure_is_500(
) -> None:
"""ValueError (pydantic contract violations subclass it) is the CALLER's error → 400; any
other failure is an honest 500 carrying {error_type, error} (mirrors RunFailure's shape).
BudgetExceeded is RuntimeError, so it lands in the 500 arm — with observed != limit so the
two can never be conflated by an echo (kø-(y))."""
The 500 witness is a NON-budget RuntimeError on purpose. It used to be ``BudgetExceeded``,
which is what made this test the one that pinned exhaustion to the crash channel; the two
now have separate arms, and this half is what keeps the budget arm NARROW — RED if it is
widened to catch ``Exception`` and route every failure to 429."""
monkeypatch.setattr(
hosting, "run_project", _Recorder(error=ValueError("docs_dir does not exist"))
)
@ -326,12 +329,61 @@ async def test_contract_violation_is_400_and_run_failure_is_500(
assert status == 400
assert "docs_dir does not exist" in body["error"]
monkeypatch.setattr(hosting, "run_project", _Recorder(error=BudgetExceeded("tokens", 100, 173)))
monkeypatch.setattr(
hosting, "run_project", _Recorder(error=RuntimeError("chat client fell over"))
)
status, body = await _post(served, "/invocations", _PAYLOAD)
assert status == 500
assert body["error_type"] == "BudgetExceeded"
assert "limit=100" in body["error"]
assert "173" in body["error"]
assert body["error_type"] == "RuntimeError"
assert "chat client fell over" in body["error"]
assert "budget_exhausted" not in body
async def test_budget_exhaustion_is_not_the_failure_channel(
served: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Budget exhaustion is a DESIGNED terminal state — the cap firing IS the feature working
(``Budget``: fail-fast, never an unbounded loop) — so it must not share a channel with a
crash. The first live run died exactly here (``rounds limit=12 observed=13``) and the hosted
surface answered 500, i.e. the same thing it says when the model endpoint falls over.
* NOT 500: nothing broke.
* NOT 200: unlike a ``Rejection`` — which is a run that CONCLUDED, and therefore belongs in
the payload — an exhausted budget produced no proposal at all. A 2xx would let an automated
caller record "analysed" for a run that analysed nothing.
* 429: the condition arises from an ALLOWANCE (``max_rounds``/``max_tokens`` are whitelisted
request fields, and the raise is the caller's own remedy), never from a server fault.
* ``error_type`` is ABSENT: that key belongs to the failure channel, and a caller switching
on its presence must not find it on a run that did not fail.
RED when the arm is detached (falls through to 500) or relabelled to any other status."""
monkeypatch.setattr(hosting, "run_project", _Recorder(error=BudgetExceeded("rounds", 12, 13)))
status, body = await _post(served, "/invocations", _PAYLOAD)
assert status == 429
assert "error_type" not in body
async def test_budget_stop_triple_survives_as_structure(
served: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""kø-(y): ``kind``/``limit``/``observed`` describe ONE ledger and are ONE structured stop
event. ``str(exc)`` flattens them into prose the caller has to re-parse to learn WHICH cap
bound and how far past it the run got — which is the whole operational question (raise
``max_rounds``? raise ``max_tokens``? give up?).
Built with ``observed != limit`` deliberately: at an exactly-exhausted cap the two coincide,
and a test written there cannot tell a faithful implementation from one that echoes the limit
back as the observed value. RED when the payload carries only the message string."""
monkeypatch.setattr(hosting, "run_project", _Recorder(error=BudgetExceeded("rounds", 12, 13)))
status, body = await _post(served, "/invocations", _PAYLOAD)
assert status == 429
assert body["budget_exhausted"] == {"kind": "rounds", "limit": 12, "observed": 13}
# The human-readable line stays alongside the structure — an operator reading a log needs it.
assert body["error"] == "budget exceeded: rounds limit=12 observed=13"
async def test_readiness_answers_while_an_invocation_is_in_flight(