Skip to content

Commit e2c95ff

Browse files
committed
feat(errors): implement RFC 7807 compliant domain exception handling
1 parent d45915b commit e2c95ff

File tree

3 files changed

+112
-5
lines changed

3 files changed

+112
-5
lines changed

backend/api/main.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import structlog
22
from fastapi import FastAPI
33

4-
from backend.api.middleware.error_handler import global_exception_handler
4+
from backend.api.middleware.error_handler import (
5+
domain_exception_handler,
6+
global_exception_handler,
7+
)
58
from backend.api.middleware.logging import StructuredLoggingMiddleware
69
from backend.api.routes import drafts, feedback, health
710
from backend.config.settings import settings
11+
from backend.services.exceptions import DomainError
812

913
# Basic structlog configuration for JSON output
1014
structlog.configure(
@@ -28,6 +32,7 @@ def create_app() -> FastAPI:
2832
)
2933

3034
app.add_middleware(StructuredLoggingMiddleware)
35+
app.add_exception_handler(DomainError, domain_exception_handler)
3136
app.add_exception_handler(Exception, global_exception_handler)
3237
app.include_router(health.router, prefix="/api/v1")
3338
app.include_router(drafts.router, prefix="/api/v1")

backend/api/middleware/error_handler.py

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,91 @@
22
from fastapi import Request, status
33
from fastapi.responses import JSONResponse
44

5+
from backend.services.exceptions import (
6+
DraftNotFoundError,
7+
PublishingFailedError,
8+
UnsupportedPlatformError,
9+
)
10+
511
logger = structlog.get_logger()
612

13+
_PROBLEM_TYPE_BASE = "https://serotonin-script.internal/errors"
14+
15+
16+
def _problem(
17+
request: Request,
18+
status_code: int,
19+
title: str,
20+
detail: str,
21+
extra: dict[str, object] | None = None,
22+
) -> JSONResponse:
23+
"""Return an RFC 7807 Problem Details response."""
24+
body: dict[str, object] = {
25+
"type": f"{_PROBLEM_TYPE_BASE}/{title.lower().replace(' ', '-')}",
26+
"title": title,
27+
"status": status_code,
28+
"detail": detail,
29+
"instance": request.url.path,
30+
}
31+
if extra:
32+
body.update(extra)
33+
return JSONResponse(status_code=status_code, content=body)
34+
35+
36+
async def domain_exception_handler(request: Request, exc: Exception) -> JSONResponse:
37+
"""Map domain errors to structured RFC 7807 HTTP responses."""
38+
if isinstance(exc, DraftNotFoundError):
39+
return _problem(
40+
request,
41+
status.HTTP_404_NOT_FOUND,
42+
"Draft Not Found",
43+
str(exc),
44+
{"draft_id": str(exc.draft_id)},
45+
)
46+
if isinstance(exc, UnsupportedPlatformError):
47+
return _problem(
48+
request,
49+
status.HTTP_422_UNPROCESSABLE_ENTITY,
50+
"Unsupported Platform",
51+
str(exc),
52+
{"platform": exc.platform},
53+
)
54+
if isinstance(exc, PublishingFailedError):
55+
logger.error(
56+
"publishing_failed",
57+
platform=str(exc.platform),
58+
reason=exc.reason,
59+
path=request.url.path,
60+
)
61+
return _problem(
62+
request,
63+
status.HTTP_502_BAD_GATEWAY,
64+
"Publishing Failed",
65+
str(exc),
66+
{"platform": str(exc.platform)},
67+
)
68+
# Catch-all for any future DomainError subclasses
69+
logger.error("unhandled_domain_error", error=str(exc), path=request.url.path)
70+
return _problem(
71+
request,
72+
status.HTTP_400_BAD_REQUEST,
73+
"Domain Error",
74+
str(exc),
75+
)
76+
777

878
async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
9-
"""Handle all unhandled exceptions and return a 500 JSON response."""
79+
"""Handle all unhandled exceptions and return a 500 RFC 7807 response."""
1080
logger.error(
1181
"unhandled_exception",
1282
error=str(exc),
1383
error_type=type(exc).__name__,
1484
path=request.url.path,
1585
method=request.method,
1686
)
17-
return JSONResponse(
18-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
19-
content={"detail": "Internal server error occurred."},
87+
return _problem(
88+
request,
89+
status.HTTP_500_INTERNAL_SERVER_ERROR,
90+
"Internal Server Error",
91+
"An unexpected error occurred.",
2092
)

backend/services/exceptions.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from backend.models.enums import Platform
2+
3+
4+
class DomainError(Exception):
5+
"""Base class for all domain-level errors."""
6+
7+
8+
class DraftNotFoundError(DomainError):
9+
"""Raised when a requested draft does not exist."""
10+
11+
def __init__(self, draft_id: int | str) -> None:
12+
super().__init__(f"Draft '{draft_id}' not found.")
13+
self.draft_id = draft_id
14+
15+
16+
class PublishingFailedError(DomainError):
17+
"""Raised when publishing to a social platform fails."""
18+
19+
def __init__(self, platform: Platform | str, reason: str) -> None:
20+
super().__init__(f"Publishing to '{platform}' failed: {reason}")
21+
self.platform = platform
22+
self.reason = reason
23+
24+
25+
class UnsupportedPlatformError(DomainError):
26+
"""Raised when no publisher is registered for the requested platform."""
27+
28+
def __init__(self, platform: str) -> None:
29+
super().__init__(f"No publisher registered for platform '{platform}'.")
30+
self.platform = platform

0 commit comments

Comments
 (0)