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:
parent
605c02aa62
commit
986fc19350
4 changed files with 143 additions and 9 deletions
31
CLAUDE.md
31
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
13
DEPLOY.md
13
DEPLOY.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue