websocket_request_rejected with
an error code and nothing else — not what the handler needed, not what it
found, not what to do next. The debugger had to reverse-engineer the runtime config from the code.
Same week, an enrich kick died on a raw boto InvalidAddress that never said which
queue URL was bad. A mute error is a door welded shut: the code that raised it knew everything, and told
you nothing. Story-2183 (sibling of pramaan-docs Chore-2182, agents mirror #2184) makes the
error-message contract enforceable — every boundary error answers four questions,
and a CI gate fails the suite the moment a new error class ships mute.
A pure gate walks every shipped PramaanError subclass and fails the suite unless each one
carries the stable half of the contract on the class — a real docstring (what failed)
and a non-empty remediation string (what to do next) — while the
runtime half (what was needed / what was found) rides the raised message;
the two canonical anti-examples (websocket_request_rejected,
the enrich-kick InvalidAddress) are rewritten to prove the shape, and
remediation is surfaced in the boundary handlers’ operator logs so it reads straight
off the line.
Picture the difference between two dashboard warning lights. The bad one blinks a number: ERR
47. You now own a scavenger hunt through the manual. The good one reads: “Coolant low
— expected 2.0L, sensor sees 0.3L — top up before driving.” Four facts, and you
are moving in seconds. The four facts split by how often they change. Two are fixed for
the whole class of fault and can be stamped on the part at the factory: what this fault
is (coolant, not oil) and what you generally do about it (top up). Two are specific to
this failure and can only be read at the moment it happens: the numbers. So the contract
puts the stable pair on the error class (docstring + remediation) and the
runtime pair in the raised message. And because the factory can guarantee the stamped
half, we put an inspector on the line: the CI gate refuses to let a part leave without its label.
remediationPramaanError gains one field, remediation: str = "". Every concrete
subclass sets it to the “what to do next” sentence for its class of failure —
stable, so it belongs on the class, not re-typed at every raise site. The docstring, which all subclasses
already had, carries “what failed.” The runtime message still carries the
specifics (the id, the bad value, the expected shape). Nothing about how errors are constructed or mapped
to HTTP changes — code and http_status are untouched, so the wire contract
consumers key on is byte-identical.
tests/unit/shared/errors/test_error_contract_gate.py walks
PramaanError.__subclasses__() transitively, scoped to classes whose module starts with
src. (so test fixtures and the inline mute class are out of scope), and asserts each has a
substantive docstring and a non-empty remediation:
green on main → every shipped subclass self-explains red on a mute → a bare-code class (only `code = "..."`) fails BOTH checks
The “red” half is proven in the same file without shipping a mute class into
src: test_gate_rejects_a_bare_code_class defines a local
_MuteError(PramaanError) and asserts the checker returns both violations. So the gate is
demonstrably able to fail — not a green rubber-stamp. When the gate first ran it immediately caught
two subclasses I hadn’t hand-listed (EmbeddingDimensionError, RerankError);
that is the gate doing its job — it governs the whole tree, not a curated list.
A remediation nobody reads is dead weight, so it is emitted in the two boundary handlers
as an operator-log field — not in the response body (that would change the wire shape).
The shared http_handler.py adds err.remediation to its warning/error log
context; the WebSocket gateway’s websocket_request_rejected line — the exact mute
line from the incident — now carries err.message and err.remediation
beside the code. This is precisely what pramaan-docs debugging Step 2a (Chore-2182) tells
a debugger to read first: extract the structured error field before widening to env/config/code.
Not codebase-wide (the contract lives on the classes now); a focused pass over the highest-traffic boundary messages:
InvalidAddress: kick_document_enrichment
now wraps the SQS send_message and, on failure, raises a descriptive
PramaanError naming the document, the failing queue URL, the underlying exception, and the
env var to check. Callers still swallow-and-log (best-effort is unchanged) — but the swallowed
error now self-explains instead of being a raw AWS code.router_ws.py —
“Unknown WebSocket route”, “WebSocket upstream call failed”,
“Internal WebSocket error” — are rewritten to name the actual route key, the
exception type, and where to look next. Codes and status stay identical.| Site | Before | After |
|---|---|---|
router_ws.py gateway reject log |
logger.warning("websocket_request_rejected", extra={"error": exc.code}) — a
code and nothing else. |
Same event + err.message and err.remediation on the line; every
PramaanError now owns a remediation, so “what to do” is present. |
enrich_kick.py SQS send |
Raises whatever boto raises — a raw InvalidAddress that never says which queue
URL was bad. |
Wrapped: PramaanError("Enrich kick for document <id> failed: send_message to
'<url>' raised InvalidAddress: ... Verify CONTEXT_ENRICH_QUEUE_URL resolves to the live
enrichment queue's full URL.") |
inference/errors.py tier-unavailable |
Docstring only; no stable “what to do.” | remediation = "Request a currently-live tier (US/EU/India) or wait for the tier to come
online; there is no silent fallback to another tier." |
13 PramaanError subclasses given a remediation (8 in
errors/exceptions.py: NotFound, Forbidden, Unauthenticated, BadRequest, Conflict, Validation,
Webhook, IdentityHat; 5 in inference/errors.py: the base + tier-unavailable, route-denied,
adapter, consent-required; plus EmbeddingDimensionError and RerankError in
context/), 3 terse _error_response bodies in router_ws.py
(unknown-route, upstream-failed, internal-error), the websocket_request_rejected log line,
the enrich_kick SQS wrap, and the http_handler log field.
| Skipped | Why it’s correct to skip |
|---|---|
A class-level message_template mechanism with typed placeholders |
Errors here are constructed with a runtime message at the raise site — a
template refactor would rewrite every construction and change behavior. The stable half
(remediation) is enough to gate; the runtime half stays in the message, where the
specifics already live. |
Putting remediation in the HTTP response body |
That would change the wire shape consumers see. The AC is messages/remediation only, codes stable — so remediation goes to operator logs, not the response. |
| A codebase-wide rewrite of every raise site | The issue scopes the sweep to the highest-traffic boundary errors. Once the contract lives on the classes, every raise inherits the stable half for free; only the two named anti-examples and the terse WS bodies needed hand rewriting. |
Gating the separate PramaanRailsError tree |
That hierarchy subclasses Exception, not PramaanError, and is the agents
rail’s concern — its mirror gate is Story-2184 in pramaan-agents. |
test_every_error_class_meets_the_contract green on main across the full
subclass tree; test_gate_rejects_a_bare_code_class proves it goes red on a deliberately
mute class — both in one file, so red-ability is demonstrated, not assumed.test_router_ws.py (the 502/unknown-route paths still assert the unchanged
codes), plus test_error_response_envelope.py and
test_error_log_shape.py — the wire shape and log-shape schema stay green, confirming
the added log field and rewritten messages changed nothing consumers key on.test_sqs_span_naming_audit.py green — the enrich-kick wrap keeps the single
named send_message producer, so the observability audit is unaffected.ruff check clean across src/ and the new test;
make openapi regenerated with zero drift (no DTO/route/router change);
mypy reports no new errors in the touched files.