diff --git a/episodic/api/errors.py b/episodic/api/errors.py index e64958a7..613e6c57 100644 --- a/episodic/api/errors.py +++ b/episodic/api/errors.py @@ -5,19 +5,10 @@ adapters use these helpers to map domain exceptions to Falcon HTTP errors and attach envelope metadata before Falcon serialises the response. -Examples --------- ->>> http_error( -... falcon.HTTPBadRequest(description="Invalid limit."), -... code="validation_error", -... ) -HTTPBadRequest(...) ->>> validation_error("Invalid UUID.", field="profile_id", constraint="uuid") -HTTPBadRequest(...) ->>> try: -... raise EntityNotFoundError("Profile not found.") -... except EntityNotFoundError as exc: -... raise map_profile_template_error(exc, entity_id="profile-1") from exc +Only unmapped exceptions that become HTTP ``500`` responses are logged here. +Mapped ``400``, ``404``, and ``409`` outcomes are expected results and are +deferred to request-level logging. + """ import collections.abc as cabc @@ -47,6 +38,7 @@ UploadNotReadyError, UploadSizeMismatchError, ) +from episodic.logging import get_logger, log_error if typ.TYPE_CHECKING: from episodic.canonical.profile_templates.types import ProfileTemplateError @@ -55,6 +47,8 @@ type _HttpErrorFactory = cabc.Callable[..., falcon.HTTPError] +logger = get_logger(__name__) + @dc.dataclass(frozen=True, slots=True) class ErrorEnvelope: @@ -241,6 +235,14 @@ def map_profile_template_error( details=details, ) case _: + log_error( + logger, + "Unmapped profile/template error: type=%s code=%s entity_id=%s", + type(exc).__name__, + exc.code, + exc.entity_id, + exc_info=True, + ) return http_error( falcon.HTTPInternalServerError(description=str(exc)), code=exc.code, @@ -297,6 +299,13 @@ def map_reference_error( code="conflict", ) msg = f"Unexpected {context} error." + log_error( + logger, + "Unmapped reference error: context=%s type=%s", + context, + type(exc).__name__, + exc_info=True, + ) return http_error( falcon.HTTPInternalServerError(description=msg), code="internal_error", @@ -309,6 +318,12 @@ def map_source_intake_error(exc: SourceIntakeError) -> falcon.HTTPError: for error_type, factory, code in mapping: if isinstance(exc, error_type): return http_error(factory(description=str(exc)), code=code) + log_error( + logger, + "Unmapped source-intake error: type=%s", + type(exc).__name__, + exc_info=True, + ) return http_error( falcon.HTTPInternalServerError(description="Unexpected source-intake error."), code="internal_error", @@ -364,7 +379,7 @@ def _status_code(exc: falcon.HTTPError) -> int: return exc.status try: return int(exc.status.split(" ", maxsplit=1)[0]) - except IndexError, ValueError: # parsed as tuple in Python 3 + except IndexError, ValueError: return 500 diff --git a/episodic/api/resources/__init__.py b/episodic/api/resources/__init__.py index 49241853..87dab95c 100644 --- a/episodic/api/resources/__init__.py +++ b/episodic/api/resources/__init__.py @@ -3,6 +3,9 @@ This package exposes route adapter classes that translate Falcon request/ response handling into calls to canonical profile/template services. +Resource modules deliberately add no per-handler logs. Request-level logging +and correlation belong to the roadmap item 4.1.3 correlation middleware. + Utilities provided ------------------ - Shared read base classes: ``_GetResourceBase``, ``_GetHistoryResourceBase`` diff --git a/tests/test_api_errors.py b/tests/test_api_errors.py new file mode 100644 index 00000000..eb6f8c0f --- /dev/null +++ b/tests/test_api_errors.py @@ -0,0 +1,154 @@ +"""Verify diagnostics for API errors that lack dedicated HTTP mappings. + +The examples isolate each mapper's unexpected-exception branch, confirming that +it preserves the internal-error envelope and emits one error record with +traceback information through the logging port. +""" + +import dataclasses as dc +import typing as typ + +import falcon +import pytest + +from episodic.api import errors as api_errors +from episodic.canonical.profile_templates.types import ProfileTemplateError +from episodic.canonical.reference_documents.types import ReferenceDocumentError +from episodic.canonical.source_intake_errors import SourceIntakeError + + +@dc.dataclass(slots=True) +class _SpyLogger: + """Capture error records emitted through the logging port.""" + + calls: list[tuple[str, str, object | None]] = dc.field(default_factory=list) + + def error( + self, + message: str, + /, + *, + exc_info: object | None = None, + stack_info: bool = False, + ) -> None: + """Record an error log call.""" + del stack_info + self.calls.append(("ERROR", message, exc_info)) + + +class _UnexpectedProfileTemplateError(ProfileTemplateError): + """Represent a profile/template error without a dedicated HTTP mapping.""" + + +class _UnexpectedReferenceDocumentError(ReferenceDocumentError): + """Represent a reference error without a dedicated HTTP mapping.""" + + +class _UnexpectedSourceIntakeError(SourceIntakeError): + """Represent a source-intake error without a dedicated HTTP mapping.""" + + +class _HasEnvelopeCode(typ.Protocol): + """Describe the dynamic envelope metadata added to Falcon errors.""" + + envelope_code: str + + +def _envelope_code(error: falcon.HTTPError) -> str: + """Return the dynamic error-envelope code attached by ``http_error``.""" + return typ.cast("_HasEnvelopeCode", error).envelope_code + + +@pytest.fixture +def spy_logger(monkeypatch: pytest.MonkeyPatch) -> _SpyLogger: + """Patch the API-error logger with an isolated call-recording spy.""" + spy = _SpyLogger() + monkeypatch.setattr(api_errors, "logger", spy) + return spy + + +class TestApiErrors: + """Tests for unmapped API-error envelope diagnostics.""" + + @staticmethod + def test_unmapped_profile_template_error_logs_and_returns_internal_error( + spy_logger: _SpyLogger, + ) -> None: + """Unmapped profile/template errors retain their code and emit diagnostics.""" + exc = _UnexpectedProfileTemplateError( + "Unexpected profile/template failure.", + code="internal_error", + entity_id="template-7", + ) + + result = api_errors.map_profile_template_error(exc) + + assert isinstance(result, falcon.HTTPInternalServerError), ( + "unmapped profile/template errors must return HTTP 500" + ) + assert _envelope_code(result) == "internal_error", ( + "unmapped profile/template errors must use the internal-error envelope" + ) + assert _envelope_code(result) == exc.code, ( + "profile/template error envelopes must preserve the exception code" + ) + assert spy_logger.calls == [ + ( + "ERROR", + ( + "Unmapped profile/template error: " + "type=_UnexpectedProfileTemplateError code=internal_error " + "entity_id=template-7" + ), + True, + ) + ], "profile/template fallback must emit one diagnostic with traceback info" + + @staticmethod + def test_unmapped_reference_error_logs_and_returns_internal_error( + spy_logger: _SpyLogger, + ) -> None: + """Unmapped reference errors emit their context without response details.""" + exc = _UnexpectedReferenceDocumentError("Unexpected reference failure.") + + result = api_errors.map_reference_error(exc, context="reference-document") + + assert isinstance(result, falcon.HTTPInternalServerError), ( + "unmapped reference errors must return HTTP 500" + ) + assert _envelope_code(result) == "internal_error", ( + "unmapped reference errors must use the internal-error envelope" + ) + assert spy_logger.calls == [ + ( + "ERROR", + ( + "Unmapped reference error: " + "context=reference-document type=_UnexpectedReferenceDocumentError" + ), + True, + ) + ], "reference fallback must emit one diagnostic with traceback info" + + @staticmethod + def test_unmapped_source_intake_error_logs_and_returns_internal_error( + spy_logger: _SpyLogger, + ) -> None: + """Unmapped source-intake errors emit diagnostics without credentials.""" + exc = _UnexpectedSourceIntakeError("Unexpected source-intake failure.") + + result = api_errors.map_source_intake_error(exc) + + assert isinstance(result, falcon.HTTPInternalServerError), ( + "unmapped source-intake errors must return HTTP 500" + ) + assert _envelope_code(result) == "internal_error", ( + "unmapped source-intake errors must use the internal-error envelope" + ) + assert spy_logger.calls == [ + ( + "ERROR", + "Unmapped source-intake error: type=_UnexpectedSourceIntakeError", + True, + ) + ], "source-intake fallback must emit one diagnostic with traceback info"