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
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue