Skip to content

symfonic.agent.fastapi.transport.error_mapper

error_mapper

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,
    )