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 +52 -0
- errors/config.py +71 -0
- errors/context.py +65 -0
- errors/exceptions.py +261 -0
- errors/handler.py +179 -0
- errors/monitoring.py +120 -0
- errors/problem_details.py +140 -0
- errors/responses.py +84 -0
- errors/retry.py +115 -0
- errors/severity.py +86 -0
- msaas_errors-0.1.0.dist-info/METADATA +15 -0
- msaas_errors-0.1.0.dist-info/RECORD +13 -0
- msaas_errors-0.1.0.dist-info/WHEEL +4 -0
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,,
|