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)`` (-(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 (-(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(