EMAP — one error table, one owner (port registry row 19).
Before this module the code→status table was defined once and applied in
four places with three subtly different fallbacks, plus a fifth path with its
own 404. The duplication was not the defect; the divergence was. This module
is the whole mapping, and the four entry points differ only in the
:class:~symfonic.agent.fastapi.transport.frames.ErrorPolicy they pass.
Two rules govern every line below:
- Map on class and
code, never on message text (EMAP-2). A reworded
message must not turn a 429 into a 500. Exactly one legacy bridge survives,
quarantined and named, because a shipped raiser used to carry no code and an
adopter's raiser still might.
- Never echo a server-side fault (EMAP-7). The message an operator needs
goes to the log; the socket gets a neutral string.
HttpErrorMapper
ErrorMapper (port row 19): map(error, policy) -> ErrorFrame.
Sync, total, and pure: no I/O, no ambient state, no logging. A mapper that
logged would make the same failure appear twice in different words.
map
map(error: BaseException, policy: ErrorPolicy) -> ErrorFrame
Map any exception onto the frame the entry point should emit.
Source code in src/symfonic/agent/fastapi/transport/error_mapper.py
| def map(self, error: BaseException, policy: ErrorPolicy) -> ErrorFrame:
"""Map any exception onto the frame the entry point should emit."""
# The two scope branches are class-level decisions with verbatim
# details; they precede code lookup because ``SecurityScopeError`` is
# a ``SymfonicAgentError`` and would otherwise fall through uncoded.
if isinstance(error, SecurityScopeError):
return ErrorFrame(status=403, detail=SCOPE_DENIED_DETAIL, code="forbidden")
if isinstance(error, ScopeValidationError):
return ErrorFrame(status=400, detail=str(error), code="bad_request")
# The platform resolver's refusals, mapped here rather than at the
# dependency that catches them. Same reason as the two branches above:
# a status code written in a handler is one the mapper cannot keep
# consistent, which is what ``STATUS_LITERAL_CEILING`` ratchets down.
auth_frame = _platform_auth_frame(error)
if auth_frame is not None:
return auth_frame
if not isinstance(error, _TAXONOMY):
return ErrorFrame(status=500, detail=OPAQUE_SERVER_DETAIL, code=None)
code = self._code_of(error)
rule = CODE_TO_STATUS.get(code) if code is not None else None
if rule is None:
return self._fallback(error, policy, code)
status = rule.active
if (
status >= 500
and policy.collapse_server_errors
and isinstance(error, SymfonicAgentError)
):
# Shipped ``/resume`` behaviour: a server-side status becomes the
# entry point's 400 and the message is echoed.
return ErrorFrame(
status=policy.fallback_status, detail=str(error), code=code,
)
return ErrorFrame(
status=status,
detail=self._detail(error, policy, rule),
code=code,
headers=rule.headers,
)
|