msaas-errors 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
errors/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ """willian-errors: Standardized error handling for willian-* backend APIs."""
2
+
3
+ from errors.config import ErrorConfig, init_errors
4
+ from errors.context import ErrorContext, with_error_context
5
+ from errors.exceptions import (
6
+ AppError,
7
+ AuthenticationError,
8
+ AuthorizationError,
9
+ BusinessLogicError,
10
+ ConflictError,
11
+ ExternalServiceError,
12
+ InternalError,
13
+ NotFoundError,
14
+ RateLimitError,
15
+ ValidationError,
16
+ )
17
+ from errors.handler import setup_error_handling
18
+ from errors.monitoring import ErrorMetrics, ErrorMonitoringHook, MonitoringBridge
19
+ from errors.problem_details import ProblemDetail, from_problem_detail, to_problem_detail
20
+ from errors.responses import ErrorResponse, ValidationErrorResponse, error_responses
21
+ from errors.retry import retriable
22
+ from errors.severity import ErrorCategory, ErrorSeverity
23
+
24
+ __all__ = [
25
+ "AppError",
26
+ "AuthenticationError",
27
+ "AuthorizationError",
28
+ "BusinessLogicError",
29
+ "ConflictError",
30
+ "ErrorCategory",
31
+ "ErrorConfig",
32
+ "ErrorContext",
33
+ "ErrorMetrics",
34
+ "ErrorMonitoringHook",
35
+ "ErrorResponse",
36
+ "ErrorSeverity",
37
+ "ExternalServiceError",
38
+ "InternalError",
39
+ "MonitoringBridge",
40
+ "NotFoundError",
41
+ "ProblemDetail",
42
+ "RateLimitError",
43
+ "ValidationError",
44
+ "ValidationErrorResponse",
45
+ "error_responses",
46
+ "from_problem_detail",
47
+ "init_errors",
48
+ "retriable",
49
+ "setup_error_handling",
50
+ "to_problem_detail",
51
+ "with_error_context",
52
+ ]
errors/config.py ADDED
@@ -0,0 +1,71 @@
1
+ """Global error handling configuration.
2
+
3
+ Call ``init_errors(config)`` once at application startup to set defaults
4
+ used by the handler, problem detail serializer, and logging integration.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from errors.monitoring import MonitoringBridge
14
+
15
+ # Module-level singleton -- mutated by init_errors, read everywhere else.
16
+ _config: ErrorConfig | None = None
17
+
18
+ # Separate module-level storage for the monitoring bridge (not frozen).
19
+ _monitoring: MonitoringBridge | None = None
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class ErrorConfig:
24
+ """Configuration for the willian-errors library."""
25
+
26
+ include_stacktrace: bool = False
27
+ """Include stack traces in error responses. Never enable in production."""
28
+
29
+ type_base_url: str = "https://errors.willian.dev/"
30
+ """Base URL for RFC 7807 ``type`` URIs."""
31
+
32
+ log_level: str = "ERROR"
33
+ """Default log level for captured errors."""
34
+
35
+ default_tags: dict[str, str] = field(default_factory=dict)
36
+ """Default tags applied to all errors (e.g. service, env)."""
37
+
38
+
39
+ def init_errors(
40
+ config: ErrorConfig | None = None,
41
+ *,
42
+ monitoring: MonitoringBridge | None = None,
43
+ ) -> ErrorConfig:
44
+ """Initialize the global error configuration.
45
+
46
+ Can be called multiple times; the last call wins.
47
+ Returns the active config.
48
+ """
49
+ global _config, _monitoring # noqa: PLW0603
50
+ _config = config or ErrorConfig()
51
+ _monitoring = monitoring
52
+ return _config
53
+
54
+
55
+ def get_config() -> ErrorConfig:
56
+ """Return the active config, lazily initializing with defaults if needed."""
57
+ global _config # noqa: PLW0603
58
+ if _config is None:
59
+ _config = ErrorConfig()
60
+ return _config
61
+
62
+
63
+ def get_monitoring() -> MonitoringBridge | None:
64
+ """Return the active monitoring bridge, or None if not configured."""
65
+ return _monitoring
66
+
67
+
68
+ def set_monitoring(bridge: MonitoringBridge) -> None:
69
+ """Set the global monitoring bridge."""
70
+ global _monitoring # noqa: PLW0603
71
+ _monitoring = bridge
errors/context.py ADDED
@@ -0,0 +1,65 @@
1
+ """Error context propagation via ContextVars.
2
+
3
+ Provides a request-scoped context (request_id, user_id, endpoint, etc.)
4
+ that is automatically attached to error responses by the handler.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterator
10
+ from contextlib import contextmanager
11
+ from contextvars import ContextVar
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ _error_context_var: ContextVar[ErrorContext | None] = ContextVar("error_context", default=None)
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class ErrorContext:
20
+ """Request-scoped metadata attached to error responses."""
21
+
22
+ request_id: str | None = None
23
+ user_id: str | None = None
24
+ endpoint: str | None = None
25
+ extra: dict[str, Any] = field(default_factory=dict)
26
+
27
+ def to_dict(self) -> dict[str, Any]:
28
+ """Serialize non-None fields to a dict."""
29
+ result: dict[str, Any] = {}
30
+ if self.request_id:
31
+ result["request_id"] = self.request_id
32
+ if self.user_id:
33
+ result["user_id"] = self.user_id
34
+ if self.endpoint:
35
+ result["endpoint"] = self.endpoint
36
+ if self.extra:
37
+ result.update(self.extra)
38
+ return result
39
+
40
+
41
+ @contextmanager
42
+ def with_error_context(
43
+ *,
44
+ request_id: str | None = None,
45
+ user_id: str | None = None,
46
+ endpoint: str | None = None,
47
+ **extra: Any,
48
+ ) -> Iterator[ErrorContext]:
49
+ """Set error context for the current scope. Restores previous on exit."""
50
+ ctx = ErrorContext(
51
+ request_id=request_id,
52
+ user_id=user_id,
53
+ endpoint=endpoint,
54
+ extra=extra,
55
+ )
56
+ token = _error_context_var.set(ctx)
57
+ try:
58
+ yield ctx
59
+ finally:
60
+ _error_context_var.reset(token)
61
+
62
+
63
+ def get_error_context() -> ErrorContext | None:
64
+ """Return the current error context, or None if not set."""
65
+ return _error_context_var.get()
errors/exceptions.py ADDED
@@ -0,0 +1,261 @@
1
+ """Exception hierarchy for willian-* backend APIs.
2
+
3
+ All application errors inherit from AppError and carry structured metadata
4
+ (status_code, error_code, message, details, severity, category) for
5
+ consistent API responses and monitoring integration.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import uuid
12
+ from typing import Any
13
+
14
+ from errors.severity import ErrorCategory, ErrorSeverity
15
+
16
+
17
+ class AppError(Exception):
18
+ """Base application error with structured metadata for API responses."""
19
+
20
+ status_code: int = 500
21
+ error_code: str = "INTERNAL_ERROR"
22
+ default_severity: ErrorSeverity = ErrorSeverity.ERROR
23
+ default_category: ErrorCategory = ErrorCategory.SERVER
24
+ default_is_retryable: bool = False
25
+
26
+ def __init__(
27
+ self,
28
+ message: str = "An unexpected error occurred",
29
+ *,
30
+ details: dict[str, Any] | None = None,
31
+ status_code: int | None = None,
32
+ error_code: str | None = None,
33
+ severity: ErrorSeverity | None = None,
34
+ category: ErrorCategory | None = None,
35
+ tags: dict[str, str] | None = None,
36
+ is_retryable: bool | None = None,
37
+ ) -> None:
38
+ super().__init__(message)
39
+ self.message = message
40
+ self.details = details or {}
41
+ if status_code is not None:
42
+ self.status_code = status_code
43
+ if error_code is not None:
44
+ self.error_code = error_code
45
+
46
+ # Monitoring fields
47
+ self.severity = severity or self.default_severity
48
+ self.category = category or self.default_category
49
+ self.error_id = uuid.uuid4().hex[:12]
50
+ self.tags: dict[str, str] = tags or {}
51
+ self.is_retryable = is_retryable if is_retryable is not None else self.default_is_retryable
52
+
53
+ @property
54
+ def fingerprint(self) -> str:
55
+ """Grouping key for monitoring tools (error_code + message hash)."""
56
+ msg_hash = hashlib.md5(self.message.encode(), usedforsecurity=False).hexdigest()[:8]
57
+ return f"{self.error_code}:{msg_hash}"
58
+
59
+ @property
60
+ def metric_name(self) -> str:
61
+ """Dot-separated metric name for counters."""
62
+ return f"errors.{self.category.value}"
63
+
64
+ def __repr__(self) -> str:
65
+ return (
66
+ f"{type(self).__name__}("
67
+ f"status_code={self.status_code}, "
68
+ f"error_code={self.error_code!r}, "
69
+ f"message={self.message!r}, "
70
+ f"severity={self.severity.name})"
71
+ )
72
+
73
+
74
+ class ValidationError(AppError):
75
+ """Request validation failed (422). Carries field-level error details."""
76
+
77
+ status_code: int = 422
78
+ error_code: str = "VALIDATION_ERROR"
79
+ default_severity: ErrorSeverity = ErrorSeverity.WARNING
80
+ default_category: ErrorCategory = ErrorCategory.VALIDATION
81
+
82
+ def __init__(
83
+ self,
84
+ message: str = "Validation failed",
85
+ *,
86
+ field_errors: list[dict[str, Any]] | None = None,
87
+ details: dict[str, Any] | None = None,
88
+ **kwargs: Any,
89
+ ) -> None:
90
+ merged = dict(details or {})
91
+ if field_errors is not None:
92
+ merged["field_errors"] = field_errors
93
+ super().__init__(message, details=merged, **kwargs)
94
+ self.field_errors = field_errors or []
95
+
96
+
97
+ class NotFoundError(AppError):
98
+ """Resource not found (404)."""
99
+
100
+ status_code: int = 404
101
+ error_code: str = "NOT_FOUND"
102
+ default_severity: ErrorSeverity = ErrorSeverity.INFO
103
+ default_category: ErrorCategory = ErrorCategory.NOT_FOUND
104
+
105
+ def __init__(
106
+ self,
107
+ message: str = "Resource not found",
108
+ *,
109
+ resource_type: str | None = None,
110
+ resource_id: str | None = None,
111
+ details: dict[str, Any] | None = None,
112
+ **kwargs: Any,
113
+ ) -> None:
114
+ merged = dict(details or {})
115
+ if resource_type:
116
+ merged["resource_type"] = resource_type
117
+ if resource_id:
118
+ merged["resource_id"] = resource_id
119
+ super().__init__(message, details=merged, **kwargs)
120
+
121
+
122
+ class ConflictError(AppError):
123
+ """Resource conflict (409)."""
124
+
125
+ status_code: int = 409
126
+ error_code: str = "CONFLICT"
127
+ default_severity: ErrorSeverity = ErrorSeverity.WARNING
128
+ default_category: ErrorCategory = ErrorCategory.CONFLICT
129
+
130
+ def __init__(
131
+ self,
132
+ message: str = "Resource conflict",
133
+ *,
134
+ details: dict[str, Any] | None = None,
135
+ **kwargs: Any,
136
+ ) -> None:
137
+ super().__init__(message, details=details, **kwargs)
138
+
139
+
140
+ class AuthenticationError(AppError):
141
+ """Authentication required or failed (401)."""
142
+
143
+ status_code: int = 401
144
+ error_code: str = "AUTHENTICATION_ERROR"
145
+ default_severity: ErrorSeverity = ErrorSeverity.WARNING
146
+ default_category: ErrorCategory = ErrorCategory.AUTHENTICATION
147
+
148
+ def __init__(
149
+ self,
150
+ message: str = "Authentication required",
151
+ *,
152
+ details: dict[str, Any] | None = None,
153
+ **kwargs: Any,
154
+ ) -> None:
155
+ super().__init__(message, details=details, **kwargs)
156
+
157
+
158
+ class AuthorizationError(AppError):
159
+ """Insufficient permissions (403)."""
160
+
161
+ status_code: int = 403
162
+ error_code: str = "AUTHORIZATION_ERROR"
163
+ default_severity: ErrorSeverity = ErrorSeverity.WARNING
164
+ default_category: ErrorCategory = ErrorCategory.AUTHORIZATION
165
+
166
+ def __init__(
167
+ self,
168
+ message: str = "Permission denied",
169
+ *,
170
+ details: dict[str, Any] | None = None,
171
+ **kwargs: Any,
172
+ ) -> None:
173
+ super().__init__(message, details=details, **kwargs)
174
+
175
+
176
+ class RateLimitError(AppError):
177
+ """Rate limit exceeded (429). Includes retry_after hint."""
178
+
179
+ status_code: int = 429
180
+ error_code: str = "RATE_LIMIT_EXCEEDED"
181
+ default_severity: ErrorSeverity = ErrorSeverity.INFO
182
+ default_category: ErrorCategory = ErrorCategory.RATE_LIMIT
183
+ default_is_retryable: bool = True
184
+
185
+ def __init__(
186
+ self,
187
+ message: str = "Rate limit exceeded",
188
+ *,
189
+ retry_after: int | None = None,
190
+ details: dict[str, Any] | None = None,
191
+ **kwargs: Any,
192
+ ) -> None:
193
+ merged = dict(details or {})
194
+ if retry_after is not None:
195
+ merged["retry_after"] = retry_after
196
+ super().__init__(message, details=merged, **kwargs)
197
+ self.retry_after = retry_after
198
+
199
+
200
+ class ExternalServiceError(AppError):
201
+ """Upstream/external service failure (502)."""
202
+
203
+ status_code: int = 502
204
+ error_code: str = "EXTERNAL_SERVICE_ERROR"
205
+ default_severity: ErrorSeverity = ErrorSeverity.ERROR
206
+ default_category: ErrorCategory = ErrorCategory.INFRASTRUCTURE
207
+ default_is_retryable: bool = True
208
+
209
+ def __init__(
210
+ self,
211
+ message: str = "External service unavailable",
212
+ *,
213
+ service_name: str | None = None,
214
+ details: dict[str, Any] | None = None,
215
+ **kwargs: Any,
216
+ ) -> None:
217
+ merged = dict(details or {})
218
+ if service_name:
219
+ merged["service_name"] = service_name
220
+ super().__init__(message, details=merged, **kwargs)
221
+
222
+
223
+ class InternalError(AppError):
224
+ """Generic internal server error (500)."""
225
+
226
+ status_code: int = 500
227
+ error_code: str = "INTERNAL_ERROR"
228
+ default_severity: ErrorSeverity = ErrorSeverity.CRITICAL
229
+ default_category: ErrorCategory = ErrorCategory.SERVER
230
+
231
+ def __init__(
232
+ self,
233
+ message: str = "An unexpected error occurred",
234
+ *,
235
+ details: dict[str, Any] | None = None,
236
+ **kwargs: Any,
237
+ ) -> None:
238
+ super().__init__(message, details=details, **kwargs)
239
+
240
+
241
+ class BusinessLogicError(AppError):
242
+ """Business rule violation (422)."""
243
+
244
+ status_code: int = 422
245
+ error_code: str = "BUSINESS_LOGIC_ERROR"
246
+ default_severity: ErrorSeverity = ErrorSeverity.WARNING
247
+ default_category: ErrorCategory = ErrorCategory.BUSINESS_LOGIC
248
+
249
+ def __init__(
250
+ self,
251
+ message: str = "Business rule violation",
252
+ *,
253
+ rule: str | None = None,
254
+ details: dict[str, Any] | None = None,
255
+ **kwargs: Any,
256
+ ) -> None:
257
+ merged = dict(details or {})
258
+ if rule:
259
+ merged["rule"] = rule
260
+ super().__init__(message, details=merged, **kwargs)
261
+ self.rule = rule
errors/handler.py ADDED
@@ -0,0 +1,179 @@
1
+ """FastAPI error handler integration.
2
+
3
+ Call ``setup_error_handling(app)`` to register exception handlers that convert
4
+ all errors into RFC 7807 Problem Details JSON responses.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import traceback
11
+ import uuid
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from errors.config import get_config, get_monitoring
15
+ from errors.context import get_error_context, with_error_context
16
+ from errors.exceptions import AppError, RateLimitError, ValidationError
17
+ from errors.problem_details import ProblemDetail, to_problem_detail
18
+
19
+ if TYPE_CHECKING:
20
+ from fastapi import FastAPI, Request
21
+ from fastapi.responses import JSONResponse
22
+
23
+ logger = logging.getLogger("errors")
24
+
25
+
26
+ def _try_structlog() -> Any:
27
+ """Return structlog.get_logger if structlog is installed, else None."""
28
+ try:
29
+ import structlog
30
+
31
+ return structlog.get_logger("errors")
32
+ except ImportError:
33
+ return None
34
+
35
+
36
+ def _get_logger() -> Any:
37
+ """Return a structlog logger if available, otherwise stdlib logger."""
38
+ return _try_structlog() or logger
39
+
40
+
41
+ def _extract_request_id(request: Any) -> str:
42
+ """Extract request_id from header or generate a new one."""
43
+ for header in ("x-request-id", "x-trace-id", "request-id"):
44
+ value = request.headers.get(header)
45
+ if value:
46
+ return value
47
+ return uuid.uuid4().hex[:16]
48
+
49
+
50
+ def _build_unhandled_response(request: Any, exc: Exception, request_id: str) -> Any:
51
+ """Build a JSON response for an unhandled exception."""
52
+ from fastapi.responses import JSONResponse
53
+
54
+ config = get_config()
55
+ log = _get_logger()
56
+ log.error(
57
+ "unhandled_exception",
58
+ exc_type=type(exc).__name__,
59
+ message=str(exc),
60
+ request_id=request_id,
61
+ )
62
+
63
+ extensions: dict[str, Any] = {"request_id": request_id}
64
+ if config.include_stacktrace:
65
+ extensions["stacktrace"] = traceback.format_exception(exc)
66
+
67
+ pd = ProblemDetail(
68
+ type=f"{config.type_base_url.rstrip('/')}/internal-error",
69
+ title="Internal Error",
70
+ status=500,
71
+ detail="An unexpected error occurred",
72
+ instance=str(request.url),
73
+ error_code="INTERNAL_ERROR",
74
+ extensions=extensions,
75
+ )
76
+ return JSONResponse(
77
+ status_code=500,
78
+ content=pd.model_dump(exclude_none=True),
79
+ media_type="application/problem+json",
80
+ )
81
+
82
+
83
+ def setup_error_handling(app: FastAPI) -> None:
84
+ """Register exception handlers on a FastAPI application."""
85
+ from fastapi.exceptions import RequestValidationError
86
+ from fastapi.responses import JSONResponse
87
+ from starlette.middleware.base import BaseHTTPMiddleware
88
+
89
+ class ErrorContextMiddleware(BaseHTTPMiddleware):
90
+ """Populate error context and catch unhandled exceptions."""
91
+
92
+ async def dispatch(self, request: Request, call_next: Any) -> Any:
93
+ request_id = _extract_request_id(request)
94
+ with with_error_context(
95
+ request_id=request_id,
96
+ endpoint=f"{request.method} {request.url.path}",
97
+ ):
98
+ try:
99
+ response = await call_next(request)
100
+ response.headers["x-request-id"] = request_id
101
+ return response
102
+ except Exception as exc:
103
+ # AppError and RequestValidationError are handled by
104
+ # dedicated handlers registered below.
105
+ if isinstance(exc, (AppError, RequestValidationError)):
106
+ raise
107
+ return _build_unhandled_response(request, exc, request_id)
108
+
109
+ app.add_middleware(ErrorContextMiddleware)
110
+
111
+ @app.exception_handler(AppError)
112
+ async def _handle_app_error(request: Request, exc: AppError) -> JSONResponse:
113
+ ctx = get_error_context()
114
+ request_id = ctx.request_id if ctx else _extract_request_id(request)
115
+
116
+ # Apply default tags from config
117
+ config = get_config()
118
+ if config.default_tags:
119
+ for k, v in config.default_tags.items():
120
+ exc.tags.setdefault(k, v)
121
+
122
+ log = _get_logger()
123
+ log_method = getattr(log, "warning" if exc.status_code < 500 else "error", log.error)
124
+ log_method(
125
+ "app_error",
126
+ error_code=exc.error_code,
127
+ status_code=exc.status_code,
128
+ message=exc.message,
129
+ severity=exc.severity.name,
130
+ category=exc.category.value,
131
+ error_id=exc.error_id,
132
+ is_retryable=exc.is_retryable,
133
+ request_id=request_id,
134
+ )
135
+
136
+ # Report to monitoring bridge
137
+ monitoring = get_monitoring()
138
+ if monitoring:
139
+ await monitoring.report(exc, ctx)
140
+
141
+ pd = to_problem_detail(exc, instance=str(request.url))
142
+ pd.extensions["request_id"] = request_id
143
+ if ctx:
144
+ pd.extensions.update({k: v for k, v in ctx.to_dict().items() if k != "request_id"})
145
+
146
+ headers: dict[str, str] = {}
147
+ if isinstance(exc, RateLimitError) and exc.retry_after is not None:
148
+ headers["Retry-After"] = str(exc.retry_after)
149
+
150
+ return JSONResponse(
151
+ status_code=exc.status_code,
152
+ content=pd.model_dump(exclude_none=True),
153
+ headers=headers or None,
154
+ media_type="application/problem+json",
155
+ )
156
+
157
+ @app.exception_handler(RequestValidationError)
158
+ async def _handle_validation_error(
159
+ request: Request, exc: RequestValidationError
160
+ ) -> JSONResponse:
161
+ field_errors = [
162
+ {
163
+ "field": ".".join(str(loc) for loc in err.get("loc", ())),
164
+ "message": err.get("msg", "Invalid value"),
165
+ "type": err.get("type", "value_error"),
166
+ }
167
+ for err in exc.errors()
168
+ ]
169
+ app_error = ValidationError(
170
+ message="Request validation failed",
171
+ field_errors=field_errors,
172
+ )
173
+ return await _handle_app_error(request, app_error)
174
+
175
+ @app.exception_handler(Exception)
176
+ async def _handle_unhandled(request: Request, exc: Exception) -> JSONResponse:
177
+ ctx = get_error_context()
178
+ request_id = ctx.request_id if ctx else _extract_request_id(request)
179
+ return _build_unhandled_response(request, exc, request_id)
errors/monitoring.py ADDED
@@ -0,0 +1,120 @@
1
+ """Error monitoring bridge and in-process metrics collector.
2
+
3
+ Provides a protocol for external monitoring tool integration (Datadog, Sentry,
4
+ Grafana) and an in-process metrics collector for error counters and summaries.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from collections import deque
11
+ from datetime import UTC, datetime
12
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
13
+
14
+ if TYPE_CHECKING:
15
+ from errors.context import ErrorContext
16
+ from errors.exceptions import AppError
17
+
18
+ logger = logging.getLogger("errors.monitoring")
19
+
20
+ MAX_RECENT_ERRORS = 100
21
+
22
+
23
+ @runtime_checkable
24
+ class ErrorMonitoringHook(Protocol):
25
+ """Protocol for monitoring tool integration.
26
+
27
+ Implement this protocol to send errors to external monitoring systems.
28
+ """
29
+
30
+ async def on_error(self, error: AppError, context: ErrorContext | None) -> None: ...
31
+
32
+
33
+ class ErrorMetrics:
34
+ """In-process error metrics collector.
35
+
36
+ Tracks error counts by code, severity, and category, plus a ring buffer
37
+ of recent errors for inspection via health/metrics endpoints.
38
+ """
39
+
40
+ def __init__(self) -> None:
41
+ self._counters: dict[str, int] = {}
42
+ self._by_severity: dict[str, int] = {}
43
+ self._by_category: dict[str, int] = {}
44
+ self._recent: deque[dict[str, Any]] = deque(maxlen=MAX_RECENT_ERRORS)
45
+
46
+ def record(self, error: AppError) -> None:
47
+ """Record an error occurrence for metrics."""
48
+ code = error.error_code
49
+ severity = error.severity.name
50
+ category = error.category.value
51
+
52
+ self._counters[code] = self._counters.get(code, 0) + 1
53
+ self._by_severity[severity] = self._by_severity.get(severity, 0) + 1
54
+ self._by_category[category] = self._by_category.get(category, 0) + 1
55
+
56
+ self._recent.append(
57
+ {
58
+ "error_id": error.error_id,
59
+ "error_code": code,
60
+ "message": error.message,
61
+ "severity": severity,
62
+ "category": category,
63
+ "fingerprint": error.fingerprint,
64
+ "is_retryable": error.is_retryable,
65
+ "timestamp": datetime.now(UTC).isoformat(),
66
+ }
67
+ )
68
+
69
+ def get_summary(self) -> dict[str, Any]:
70
+ """Get error metrics summary (for /health or /metrics endpoint)."""
71
+ return {
72
+ "total_errors": sum(self._counters.values()),
73
+ "by_code": dict(self._counters),
74
+ "by_severity": dict(self._by_severity),
75
+ "by_category": dict(self._by_category),
76
+ "recent_errors": list(self._recent)[-10:],
77
+ }
78
+
79
+ def reset(self) -> None:
80
+ """Reset all metrics to zero."""
81
+ self._counters.clear()
82
+ self._by_severity.clear()
83
+ self._by_category.clear()
84
+ self._recent.clear()
85
+
86
+
87
+ class MonitoringBridge:
88
+ """Bridge to external monitoring tools.
89
+
90
+ Aggregates in-process metrics and dispatches error events to registered
91
+ monitoring hooks (Datadog, Sentry, etc). Hook failures are silently
92
+ caught to never break the application.
93
+ """
94
+
95
+ def __init__(self) -> None:
96
+ self._hooks: list[ErrorMonitoringHook] = []
97
+ self._metrics = ErrorMetrics()
98
+
99
+ def add_hook(self, hook: ErrorMonitoringHook) -> None:
100
+ """Register a monitoring hook."""
101
+ self._hooks.append(hook)
102
+
103
+ async def report(self, error: AppError, context: ErrorContext | None = None) -> None:
104
+ """Report error to all registered hooks and record metrics."""
105
+ self._metrics.record(error)
106
+ for hook in self._hooks:
107
+ try:
108
+ await hook.on_error(error, context)
109
+ except Exception:
110
+ logger.debug(
111
+ "Monitoring hook %s failed for error %s",
112
+ type(hook).__name__,
113
+ error.error_id,
114
+ exc_info=True,
115
+ )
116
+
117
+ @property
118
+ def metrics(self) -> ErrorMetrics:
119
+ """Return the in-process metrics collector."""
120
+ return self._metrics
@@ -0,0 +1,140 @@
1
+ """RFC 7807 Problem Details representation.
2
+
3
+ Converts between ``AppError`` instances and the standard Problem Details
4
+ JSON structure used in all willian-* API responses.
5
+
6
+ Reference: https://www.rfc-editor.org/rfc/rfc7807
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+ from errors.config import get_config
16
+ from errors.exceptions import AppError
17
+
18
+
19
+ class ProblemDetail(BaseModel):
20
+ """RFC 7807 Problem Details model."""
21
+
22
+ type: str = Field(
23
+ default="about:blank",
24
+ description="URI reference identifying the problem type.",
25
+ )
26
+ title: str = Field(
27
+ default="Internal Server Error",
28
+ description="Short human-readable summary of the problem type.",
29
+ )
30
+ status: int = Field(
31
+ default=500,
32
+ description="HTTP status code.",
33
+ )
34
+ detail: str = Field(
35
+ default="An unexpected error occurred",
36
+ description="Human-readable explanation specific to this occurrence.",
37
+ )
38
+ instance: str | None = Field(
39
+ default=None,
40
+ description="URI reference identifying the specific occurrence.",
41
+ )
42
+ error_code: str = Field(
43
+ default="INTERNAL_ERROR",
44
+ description="Machine-readable error code.",
45
+ )
46
+ severity: str | None = Field(
47
+ default=None,
48
+ description="Error severity level (DEBUG, INFO, WARNING, ERROR, CRITICAL).",
49
+ )
50
+ category: str | None = Field(
51
+ default=None,
52
+ description="Error category for dashboarding.",
53
+ )
54
+ error_id: str | None = Field(
55
+ default=None,
56
+ description="Unique error occurrence identifier.",
57
+ )
58
+ is_retryable: bool = Field(
59
+ default=False,
60
+ description="Whether the client should retry the request.",
61
+ )
62
+ extensions: dict[str, Any] = Field(
63
+ default_factory=dict,
64
+ description="Additional problem-specific properties.",
65
+ )
66
+
67
+ model_config = {"extra": "allow"}
68
+
69
+
70
+ def _error_code_to_type_slug(error_code: str) -> str:
71
+ """Convert an error code like 'NOT_FOUND' to a URL slug 'not-found'."""
72
+ return error_code.lower().replace("_", "-")
73
+
74
+
75
+ def to_problem_detail(
76
+ error: AppError,
77
+ *,
78
+ instance: str | None = None,
79
+ type_base_url: str | None = None,
80
+ ) -> ProblemDetail:
81
+ """Convert an AppError to an RFC 7807 ProblemDetail."""
82
+ config = get_config()
83
+ base_url = (type_base_url or config.type_base_url).rstrip("/")
84
+ slug = _error_code_to_type_slug(error.error_code)
85
+
86
+ return ProblemDetail(
87
+ type=f"{base_url}/{slug}",
88
+ title=error.error_code.replace("_", " ").title(),
89
+ status=error.status_code,
90
+ detail=error.message,
91
+ instance=instance,
92
+ error_code=error.error_code,
93
+ severity=error.severity.name,
94
+ category=error.category.value,
95
+ error_id=error.error_id,
96
+ is_retryable=error.is_retryable,
97
+ extensions=error.details,
98
+ )
99
+
100
+
101
+ # Registry for reverse lookups -- maps error_code to AppError subclass.
102
+ _ERROR_REGISTRY: dict[str, type[AppError]] = {}
103
+
104
+
105
+ def _build_error_registry() -> dict[str, type[AppError]]:
106
+ """Build a registry of error_code -> AppError subclass (lazy, cached)."""
107
+ if _ERROR_REGISTRY:
108
+ return _ERROR_REGISTRY
109
+
110
+ def _collect(cls: type[AppError]) -> None:
111
+ code = cls.__dict__.get("error_code")
112
+ if code and cls is not AppError:
113
+ _ERROR_REGISTRY[code] = cls
114
+ for sub in cls.__subclasses__():
115
+ _collect(sub)
116
+
117
+ _collect(AppError)
118
+ return _ERROR_REGISTRY
119
+
120
+
121
+ def from_problem_detail(pd: ProblemDetail) -> AppError:
122
+ """Reconstruct an AppError from an RFC 7807 ProblemDetail.
123
+
124
+ Falls back to a generic AppError if the error_code is not recognized.
125
+ Always constructs via the base ``AppError`` constructor to avoid
126
+ subclass-specific kwargs, then patches the class for ``isinstance`` checks.
127
+ """
128
+ registry = _build_error_registry()
129
+ error_cls = registry.get(pd.error_code, AppError)
130
+
131
+ # Build through AppError to avoid subclass __init__ signature mismatches,
132
+ # then set __class__ so isinstance() works as expected.
133
+ instance = AppError(
134
+ message=pd.detail,
135
+ status_code=pd.status,
136
+ error_code=pd.error_code,
137
+ details=pd.extensions,
138
+ )
139
+ instance.__class__ = error_cls
140
+ return instance
errors/responses.py ADDED
@@ -0,0 +1,84 @@
1
+ """OpenAPI response schema helpers for FastAPI routes.
2
+
3
+ Usage::
4
+
5
+ from errors import error_responses
6
+
7
+ @router.get("/items/{id}", responses=error_responses(404, 422))
8
+ async def get_item(id: str): ...
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from pydantic import BaseModel, Field
16
+
17
+
18
+ class FieldError(BaseModel):
19
+ """A single field-level validation error."""
20
+
21
+ field: str = Field(description="Dot-separated field path (e.g. 'body.email').")
22
+ message: str = Field(description="Human-readable error message.")
23
+ type: str = Field(description="Machine-readable error type.")
24
+
25
+
26
+ class ErrorResponse(BaseModel):
27
+ """Standard RFC 7807 error response for OpenAPI docs."""
28
+
29
+ type: str = Field(
30
+ default="about:blank",
31
+ description="URI reference identifying the problem type.",
32
+ )
33
+ title: str = Field(description="Short summary of the problem.")
34
+ status: int = Field(description="HTTP status code.")
35
+ detail: str = Field(description="Explanation specific to this occurrence.")
36
+ instance: str | None = Field(default=None, description="URI of this occurrence.")
37
+ error_code: str = Field(description="Machine-readable error code.")
38
+ request_id: str | None = Field(default=None, description="Request trace identifier.")
39
+ severity: str = Field(default="ERROR", description="Error severity level.")
40
+ category: str = Field(default="server", description="Error category.")
41
+ error_id: str = Field(default="", description="Unique error occurrence identifier.")
42
+ is_retryable: bool = Field(default=False, description="Whether the client should retry.")
43
+
44
+
45
+ class ValidationErrorResponse(ErrorResponse):
46
+ """Error response with field-level validation errors."""
47
+
48
+ field_errors: list[FieldError] = Field(
49
+ default_factory=list,
50
+ description="Field-level validation errors.",
51
+ )
52
+
53
+
54
+ # Mapping of HTTP status codes to their OpenAPI schema descriptors.
55
+ _STATUS_SCHEMAS: dict[int, dict[str, Any]] = {
56
+ 400: {"description": "Bad Request", "model": ErrorResponse},
57
+ 401: {"description": "Authentication Required", "model": ErrorResponse},
58
+ 403: {"description": "Permission Denied", "model": ErrorResponse},
59
+ 404: {"description": "Resource Not Found", "model": ErrorResponse},
60
+ 409: {"description": "Resource Conflict", "model": ErrorResponse},
61
+ 422: {"description": "Validation Error", "model": ValidationErrorResponse},
62
+ 429: {"description": "Rate Limit Exceeded", "model": ErrorResponse},
63
+ 500: {"description": "Internal Server Error", "model": ErrorResponse},
64
+ 502: {"description": "External Service Error", "model": ErrorResponse},
65
+ }
66
+
67
+
68
+ def error_responses(*status_codes: int) -> dict[int, dict[str, Any]]:
69
+ """Generate OpenAPI ``responses`` dict for the given status codes.
70
+
71
+ Example::
72
+
73
+ @router.get("/items/{id}", responses=error_responses(404, 422))
74
+ async def get_item(id: str): ...
75
+
76
+ Returns a dict like ``{404: {"description": ..., "model": ...}}``.
77
+ """
78
+ result: dict[int, dict[str, Any]] = {}
79
+ for code in status_codes:
80
+ if code in _STATUS_SCHEMAS:
81
+ result[code] = _STATUS_SCHEMAS[code]
82
+ else:
83
+ result[code] = {"description": f"Error {code}", "model": ErrorResponse}
84
+ return result
errors/retry.py ADDED
@@ -0,0 +1,115 @@
1
+ """Retry decorator for transient failures.
2
+
3
+ Provides ``@retriable`` for functions that call external services and may
4
+ encounter transient network or upstream errors.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import functools
11
+ import logging
12
+ import time
13
+ from collections.abc import Callable
14
+ from typing import Any, ParamSpec, TypeVar
15
+
16
+ from errors.exceptions import ExternalServiceError
17
+
18
+ logger = logging.getLogger("errors.retry")
19
+
20
+ P = ParamSpec("P")
21
+ T = TypeVar("T")
22
+
23
+ # Default exception types considered retryable.
24
+ DEFAULT_RETRYABLE: tuple[type[BaseException], ...] = (
25
+ ExternalServiceError,
26
+ ConnectionError,
27
+ TimeoutError,
28
+ OSError,
29
+ )
30
+
31
+
32
+ def _compute_delay(attempt: int, backoff: str, base_delay: float) -> float:
33
+ """Compute delay in seconds for the given attempt number."""
34
+ if backoff == "exponential":
35
+ return base_delay * (2 ** (attempt - 1))
36
+ if backoff == "linear":
37
+ return base_delay * attempt
38
+ return base_delay # constant
39
+
40
+
41
+ def retriable(
42
+ *,
43
+ max_retries: int = 3,
44
+ backoff: str = "exponential",
45
+ base_delay: float = 0.5,
46
+ retryable_exceptions: tuple[type[BaseException], ...] | None = None,
47
+ ) -> Callable[[Callable[P, T]], Callable[P, T]]:
48
+ """Decorator that retries a function on transient failures.
49
+
50
+ Supports both sync and async callables.
51
+
52
+ Args:
53
+ max_retries: Maximum number of retry attempts after the first failure.
54
+ backoff: Backoff strategy -- ``"exponential"``, ``"linear"``, or ``"constant"``.
55
+ base_delay: Base delay in seconds between retries.
56
+ retryable_exceptions: Exception types that trigger a retry.
57
+ Defaults to ExternalServiceError, ConnectionError, TimeoutError, OSError.
58
+ """
59
+ exc_types = retryable_exceptions or DEFAULT_RETRYABLE
60
+
61
+ def decorator(fn: Callable[P, Any]) -> Callable[P, Any]:
62
+ if asyncio.iscoroutinefunction(fn):
63
+
64
+ @functools.wraps(fn)
65
+ async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
66
+ last_exc: BaseException | None = None
67
+ for attempt in range(1, max_retries + 2): # 1 initial + max_retries
68
+ try:
69
+ return await fn(*args, **kwargs)
70
+ except exc_types as exc:
71
+ last_exc = exc
72
+ if attempt <= max_retries:
73
+ delay = _compute_delay(attempt, backoff, base_delay)
74
+ logger.warning(
75
+ "Retry attempt %d/%d for %s after %.2fs: %s",
76
+ attempt,
77
+ max_retries,
78
+ fn.__qualname__,
79
+ delay,
80
+ exc,
81
+ )
82
+ await asyncio.sleep(delay)
83
+ else:
84
+ raise
85
+ raise last_exc # type: ignore[misc] # unreachable but satisfies mypy
86
+
87
+ return async_wrapper # type: ignore[return-value]
88
+ else:
89
+
90
+ @functools.wraps(fn)
91
+ def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
92
+ last_exc: BaseException | None = None
93
+ for attempt in range(1, max_retries + 2):
94
+ try:
95
+ return fn(*args, **kwargs)
96
+ except exc_types as exc:
97
+ last_exc = exc
98
+ if attempt <= max_retries:
99
+ delay = _compute_delay(attempt, backoff, base_delay)
100
+ logger.warning(
101
+ "Retry attempt %d/%d for %s after %.2fs: %s",
102
+ attempt,
103
+ max_retries,
104
+ fn.__qualname__,
105
+ delay,
106
+ exc,
107
+ )
108
+ time.sleep(delay)
109
+ else:
110
+ raise
111
+ raise last_exc # type: ignore[misc]
112
+
113
+ return sync_wrapper # type: ignore[return-value]
114
+
115
+ return decorator # type: ignore[return-value]
errors/severity.py ADDED
@@ -0,0 +1,86 @@
1
+ """Error severity levels and categories for monitoring and alerting.
2
+
3
+ Provides structured classification of errors for integration with monitoring
4
+ tools like Datadog, Sentry, and Grafana dashboards.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from enum import IntEnum, StrEnum
10
+
11
+
12
+ class ErrorSeverity(IntEnum):
13
+ """Error severity levels for monitoring tools (Datadog, Sentry, Grafana).
14
+
15
+ Values are ordered so comparisons work naturally:
16
+ ``ErrorSeverity.WARNING < ErrorSeverity.ERROR`` is True.
17
+ """
18
+
19
+ DEBUG = 10
20
+ INFO = 20
21
+ WARNING = 30
22
+ ERROR = 40
23
+ CRITICAL = 50
24
+
25
+
26
+ class ErrorCategory(StrEnum):
27
+ """Error categories for dashboarding and alerting."""
28
+
29
+ VALIDATION = "validation"
30
+ AUTHENTICATION = "authentication"
31
+ AUTHORIZATION = "authorization"
32
+ NOT_FOUND = "not_found"
33
+ CONFLICT = "conflict"
34
+ RATE_LIMIT = "rate_limit"
35
+ CLIENT = "client"
36
+ SERVER = "server"
37
+ INFRASTRUCTURE = "infrastructure"
38
+ BUSINESS_LOGIC = "business_logic"
39
+
40
+
41
+ # Default severity based on HTTP status code ranges.
42
+ _STATUS_TO_SEVERITY: dict[int, ErrorSeverity] = {
43
+ 400: ErrorSeverity.WARNING,
44
+ 401: ErrorSeverity.WARNING,
45
+ 403: ErrorSeverity.WARNING,
46
+ 404: ErrorSeverity.INFO,
47
+ 409: ErrorSeverity.WARNING,
48
+ 422: ErrorSeverity.WARNING,
49
+ 429: ErrorSeverity.INFO,
50
+ 500: ErrorSeverity.CRITICAL,
51
+ 502: ErrorSeverity.ERROR,
52
+ }
53
+
54
+
55
+ def severity_for_status(status_code: int) -> ErrorSeverity:
56
+ """Return the default severity for a given HTTP status code."""
57
+ if status_code in _STATUS_TO_SEVERITY:
58
+ return _STATUS_TO_SEVERITY[status_code]
59
+ if status_code < 400:
60
+ return ErrorSeverity.DEBUG
61
+ if status_code < 500:
62
+ return ErrorSeverity.WARNING
63
+ return ErrorSeverity.ERROR
64
+
65
+
66
+ # Default category based on HTTP status code.
67
+ _STATUS_TO_CATEGORY: dict[int, ErrorCategory] = {
68
+ 400: ErrorCategory.CLIENT,
69
+ 401: ErrorCategory.AUTHENTICATION,
70
+ 403: ErrorCategory.AUTHORIZATION,
71
+ 404: ErrorCategory.NOT_FOUND,
72
+ 409: ErrorCategory.CONFLICT,
73
+ 422: ErrorCategory.VALIDATION,
74
+ 429: ErrorCategory.RATE_LIMIT,
75
+ 500: ErrorCategory.SERVER,
76
+ 502: ErrorCategory.INFRASTRUCTURE,
77
+ }
78
+
79
+
80
+ def category_for_status(status_code: int) -> ErrorCategory:
81
+ """Return the default category for a given HTTP status code."""
82
+ if status_code in _STATUS_TO_CATEGORY:
83
+ return _STATUS_TO_CATEGORY[status_code]
84
+ if status_code < 500:
85
+ return ErrorCategory.CLIENT
86
+ return ErrorCategory.SERVER
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: msaas-errors
3
+ Version: 0.1.0
4
+ Summary: Standardized error handling library for platform backend APIs
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: fastapi>=0.110.0
7
+ Requires-Dist: pydantic>=2.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: httpx>=0.27; extra == 'dev'
10
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
11
+ Requires-Dist: pytest>=8.0; extra == 'dev'
12
+ Requires-Dist: ruff>=0.8; extra == 'dev'
13
+ Requires-Dist: structlog>=23.0; extra == 'dev'
14
+ Provides-Extra: structlog
15
+ Requires-Dist: structlog>=23.0; extra == 'structlog'
@@ -0,0 +1,13 @@
1
+ errors/__init__.py,sha256=8G_hbj0-3RFlrE7bhVTHedwgCe1RP8cSxmTt90VWb2Y,1467
2
+ errors/config.py,sha256=QOgpm-JNq8eo1c-pp5Di0GLsNEbyD4ohdd8nlFXpn54,2091
3
+ errors/context.py,sha256=-095zy2madw33eMWSe0Zuy5Cv37nYGJj3cpk6lWySG8,1871
4
+ errors/exceptions.py,sha256=zLQ7HdVWzW1OhVMRpEDGwrkdK42XjojMAu2YR4gevE0,8149
5
+ errors/handler.py,sha256=GwQMn1pPivBY2eXT2yOzi4mJtzMrPaxamMEMmGy3wY0,6286
6
+ errors/monitoring.py,sha256=Pdrvy0lCMoBP6xxmarwO5CSAY9pgoIQb6yQOaaL9-SE,4022
7
+ errors/problem_details.py,sha256=GTKM9VJoKXygO2jJA71KYwepBFKEhdcR8h71p8wJ3KM,4322
8
+ errors/responses.py,sha256=-qYGiaK-TDTQOznrVaTq0mrU3csWXsxvwiM5aiEj_7w,3277
9
+ errors/retry.py,sha256=1c9ZTcRShPi66uNLWcOU0RcRZItx0-4fT1qvrhU519s,4242
10
+ errors/severity.py,sha256=npUH-HceA4-gSebEkGxnj-dvjpWts6bhqoQACkYejdk,2481
11
+ msaas_errors-0.1.0.dist-info/METADATA,sha256=9kL9bPhmw1XGGp0C5-1q8ulH_PcXDAuSWKhRCjeWC6E,537
12
+ msaas_errors-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
+ msaas_errors-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any