Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 29 additions & 14 deletions episodic/api/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -55,6 +47,8 @@

type _HttpErrorFactory = cabc.Callable[..., falcon.HTTPError]

logger = get_logger(__name__)


@dc.dataclass(frozen=True, slots=True)
class ErrorEnvelope:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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


Expand Down
3 changes: 3 additions & 0 deletions episodic/api/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand Down
154 changes: 154 additions & 0 deletions tests/test_api_errors.py
Original file line number Diff line number Diff line change
@@ -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"
Loading