Skip to content

Commit e992884

Browse files
committed
Move rejection logging helpers out of the exception handlers
1 parent e07b5ef commit e992884

4 files changed

Lines changed: 52 additions & 27 deletions

File tree

‎app/errors/handlers.py‎

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from app.errors.fhir import FHIRError
1111
from app.errors.mapping import DEFAULT_SPEC, EXCEPTION_MAP, UNEXPECTED_ERROR_MESSAGE, spec_for
12+
from app.logging.rejections import log_rejection, summarize_reason
1213
from app.models.fhir.resources.operation_outcome.resource import (
1314
OperationOutcome,
1415
OperationOutcomeDetail,
@@ -17,34 +18,11 @@
1718

1819
logger = logging.getLogger(__name__)
1920

20-
_DENIAL_STATUSES = frozenset({401, 403})
21-
22-
23-
def _summarize_reason(exc: Exception) -> str:
24-
if isinstance(exc, RequestValidationError):
25-
reasons = [
26-
f"{'.'.join(str(loc) for loc in err.get('loc', ()))}: {err.get('msg', '')}".strip(" :")
27-
for err in exc.errors()
28-
]
29-
return "; ".join(r for r in reasons if r) or "validation error"
30-
return str(exc)
31-
32-
33-
def log_rejection(status_code: int, exc: Exception) -> None:
34-
level = logging.WARNING if status_code in _DENIAL_STATUSES else logging.INFO
35-
logger.log(
36-
level,
37-
"Request rejected with %s (%s): %s",
38-
status_code,
39-
type(exc).__name__,
40-
_summarize_reason(exc),
41-
)
42-
4321

4422
def handle_mapped_exception(request: Request, exc: Exception) -> JSONResponse:
4523
spec = spec_for(exc)
4624
status_code = spec.http_status
47-
log_rejection(status_code, exc)
25+
log_rejection(status_code, exc, summarize_reason(exc))
4826

4927
if "fhir" in request.url.path:
5028
fhir_error = FHIRError(severity=spec.severity, code=spec.fhir_code, msg=str(exc))
@@ -60,7 +38,7 @@ def handle_mapped_exception(request: Request, exc: Exception) -> JSONResponse:
6038
def handle_request_validation_exception(req: Request, exc: RequestValidationError) -> JSONResponse:
6139
path = req.url.path
6240
status_code = 422
63-
log_rejection(status_code, exc)
41+
log_rejection(status_code, exc, summarize_reason(exc))
6442
if "fhir" in path:
6543
issues = []
6644

‎app/logging/rejections.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import logging
2+
3+
from fastapi.exceptions import RequestValidationError
4+
from pydantic import ValidationError
5+
6+
logger = logging.getLogger(__name__)
7+
8+
_DENIAL_STATUSES = frozenset({401, 403})
9+
10+
11+
def summarize_reason(exc: Exception) -> str:
12+
if isinstance(exc, (RequestValidationError, ValidationError)):
13+
reasons = [
14+
f"{'.'.join(str(loc) for loc in err.get('loc', ()))}: {err.get('msg', '')}".strip(" :")
15+
for err in exc.errors()
16+
]
17+
return "; ".join(r for r in reasons if r) or "validation error"
18+
return str(exc)
19+
20+
21+
def log_rejection(status_code: int, exc: Exception, reason: str) -> None:
22+
level = logging.WARNING if status_code in _DENIAL_STATUSES else logging.INFO
23+
logger.log(level, "Request rejected with %s (%s): %s", status_code, type(exc).__name__, reason)

‎app/services/fhir/bundle.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
import gfmodules.logging as gflog
77
from pydantic import ValidationError
88

9-
from app.errors.handlers import log_rejection
109
from app.errors.mapping import DEFAULT_SPEC, UNEXPECTED_ERROR_MESSAGE, spec_for
1110
from app.logging.events import Log
11+
from app.logging.rejections import log_rejection, summarize_reason
1212
from app.models.auth.context import AuthContext
1313
from app.models.auth.data import AuthorizationScope
1414
from app.models.fhir.bundle import Bundle, BundleEntry, EntryRequest, EntryRequestDto, EntryResponse
@@ -143,7 +143,7 @@ def _entry_result(self, index: int, action: Callable[[], BundleEntry[Any]]) -> B
143143
if unexpected:
144144
logger.exception("Bundle entry action failed")
145145
else:
146-
log_rejection(spec.http_status, exc)
146+
log_rejection(spec.http_status, exc, summarize_reason(exc))
147147

148148
return BundleEntry(
149149
response=EntryResponse.make_error_response(

‎tests/logging/test_rejections.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import pytest
2+
from pydantic import BaseModel, ValidationError
3+
4+
from app.logging.rejections import summarize_reason
5+
6+
7+
class _Model(BaseModel):
8+
values: list[int]
9+
10+
11+
def _validation_error(values: list[str]) -> ValidationError:
12+
with pytest.raises(ValidationError) as caught:
13+
_Model.model_validate({"values": values})
14+
return caught.value
15+
16+
17+
def test_summarizes_a_validation_error_without_its_input() -> None:
18+
reason = summarize_reason(_validation_error(["secret-input"]))
19+
20+
assert reason == "values.0: Input should be a valid integer, unable to parse string as an integer"
21+
22+
23+
def test_summarizes_a_plain_exception_by_its_message() -> None:
24+
assert summarize_reason(RuntimeError("boom")) == "boom"

0 commit comments

Comments
 (0)