|
|
|
@ -1,25 +1,94 @@
|
|
|
|
# Define base error model
|
|
|
|
# Define base error model and exception hierarchy
|
|
|
|
import logging
|
|
|
|
import logging
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
import uuid
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
|
|
|
|
|
|
from asyncpg.exceptions import DataError as AsyncPGDataError
|
|
|
|
from asyncpg.exceptions import DataError as AsyncPGDataError
|
|
|
|
from asyncpg.exceptions import PostgresError
|
|
|
|
from fastapi import Request
|
|
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
|
|
|
|
|
|
from fastapi.exceptions import RequestValidationError
|
|
|
|
from fastapi.exceptions import RequestValidationError
|
|
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from pydantic import BaseModel
|
|
|
|
from pydantic import BaseModel
|
|
|
|
from slowapi import _rate_limit_exceeded_handler
|
|
|
|
|
|
|
|
from slowapi.errors import RateLimitExceeded
|
|
|
|
from slowapi.errors import RateLimitExceeded
|
|
|
|
from sqlalchemy.exc import (DataError, DBAPIError, IntegrityError,
|
|
|
|
from sqlalchemy.exc import DataError, DBAPIError, IntegrityError, SQLAlchemyError, NoResultFound
|
|
|
|
SQLAlchemyError)
|
|
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
|
|
|
|
|
|
|
|
from src.enums import ResponseStatus
|
|
|
|
from src.enums import ResponseStatus
|
|
|
|
from src.auth.service import notify_admin_on_rate_limit_sync
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Custom exception classes ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OptimumOHBaseException(Exception):
|
|
|
|
|
|
|
|
def __init__(self, message: str, data: Any = None):
|
|
|
|
|
|
|
|
self.message = message
|
|
|
|
|
|
|
|
self.data = data
|
|
|
|
|
|
|
|
super().__init__(message)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BadRequestError(OptimumOHBaseException): pass
|
|
|
|
|
|
|
|
class UnauthorizedError(OptimumOHBaseException): pass
|
|
|
|
|
|
|
|
class ForbiddenError(OptimumOHBaseException): pass
|
|
|
|
|
|
|
|
class NotFoundError(OptimumOHBaseException): pass
|
|
|
|
|
|
|
|
class RequestTimeoutError(OptimumOHBaseException): pass
|
|
|
|
|
|
|
|
class UnprocessableEntityError(OptimumOHBaseException): pass
|
|
|
|
|
|
|
|
class TooManyRequestsError(OptimumOHBaseException): pass
|
|
|
|
|
|
|
|
class InternalServerError(OptimumOHBaseException): pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Fixed client response messages (generic, not for logging) ─────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_FIXED_MESSAGES = {
|
|
|
|
|
|
|
|
400: "Malformed JSON body input",
|
|
|
|
|
|
|
|
401: "Invalid authorization token",
|
|
|
|
|
|
|
|
403: "Forbidden Access",
|
|
|
|
|
|
|
|
404: "Resource not found",
|
|
|
|
|
|
|
|
408: "Request timeout",
|
|
|
|
|
|
|
|
422: "Invalid request parameter",
|
|
|
|
|
|
|
|
429: "Please try again later",
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Human-readable error types for logging (no underscores)
|
|
|
|
|
|
|
|
_ERROR_TYPE_MAP = {
|
|
|
|
|
|
|
|
BadRequestError: "Bad Request",
|
|
|
|
|
|
|
|
UnauthorizedError: "Unauthorized",
|
|
|
|
|
|
|
|
ForbiddenError: "Forbidden",
|
|
|
|
|
|
|
|
NotFoundError: "Not Found",
|
|
|
|
|
|
|
|
RequestTimeoutError: "Request Timeout",
|
|
|
|
|
|
|
|
UnprocessableEntityError: "Unprocessable Entity",
|
|
|
|
|
|
|
|
TooManyRequestsError: "Too Many Requests",
|
|
|
|
|
|
|
|
InternalServerError: "Internal Server Error",
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_EXCEPTION_STATUS_MAP = {
|
|
|
|
|
|
|
|
BadRequestError: 400,
|
|
|
|
|
|
|
|
UnauthorizedError: 401,
|
|
|
|
|
|
|
|
ForbiddenError: 403,
|
|
|
|
|
|
|
|
NotFoundError: 404,
|
|
|
|
|
|
|
|
RequestTimeoutError: 408,
|
|
|
|
|
|
|
|
UnprocessableEntityError: 422,
|
|
|
|
|
|
|
|
TooManyRequestsError: 429,
|
|
|
|
|
|
|
|
InternalServerError: 500,
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_HTTP_STATUS_NAMES = {
|
|
|
|
|
|
|
|
400: "Bad Request",
|
|
|
|
|
|
|
|
401: "Unauthorized",
|
|
|
|
|
|
|
|
403: "Forbidden",
|
|
|
|
|
|
|
|
404: "Not Found",
|
|
|
|
|
|
|
|
405: "Method Not Allowed",
|
|
|
|
|
|
|
|
408: "Request Timeout",
|
|
|
|
|
|
|
|
409: "Conflict",
|
|
|
|
|
|
|
|
413: "Payload Too Large",
|
|
|
|
|
|
|
|
414: "URI Too Long",
|
|
|
|
|
|
|
|
415: "Unsupported Media Type",
|
|
|
|
|
|
|
|
422: "Unprocessable Entity",
|
|
|
|
|
|
|
|
429: "Too Many Requests",
|
|
|
|
|
|
|
|
500: "Internal Server Error",
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ErrorDetail(BaseModel):
|
|
|
|
class ErrorDetail(BaseModel):
|
|
|
|
field: Optional[str] = None
|
|
|
|
field: Optional[str] = None
|
|
|
|
message: str
|
|
|
|
message: str
|
|
|
|
@ -34,30 +103,17 @@ class ErrorResponse(BaseModel):
|
|
|
|
errors: Optional[List[ErrorDetail]] = None
|
|
|
|
errors: Optional[List[ErrorDetail]] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Custom exception handler setup
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_request_context(request: Request) -> dict:
|
|
|
|
|
|
|
|
"""Get detailed request context for logging."""
|
|
|
|
|
|
|
|
|
|
|
|
def get_request_context(request: Request):
|
|
|
|
def get_client_ip() -> str:
|
|
|
|
"""
|
|
|
|
|
|
|
|
Get detailed request context for logging.
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_client_ip():
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
Get the real client IP address from Kong Gateway headers.
|
|
|
|
|
|
|
|
Kong sets X-Real-IP and X-Forwarded-For headers by default.
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
# Kong specific headers
|
|
|
|
|
|
|
|
if "X-Real-IP" in request.headers:
|
|
|
|
if "X-Real-IP" in request.headers:
|
|
|
|
return request.headers["X-Real-IP"]
|
|
|
|
return request.headers["X-Real-IP"]
|
|
|
|
|
|
|
|
|
|
|
|
# Fallback to X-Forwarded-For
|
|
|
|
|
|
|
|
if "X-Forwarded-For" in request.headers:
|
|
|
|
if "X-Forwarded-For" in request.headers:
|
|
|
|
# Get the first IP (original client)
|
|
|
|
|
|
|
|
return request.headers["X-Forwarded-For"].split(",")[0].strip()
|
|
|
|
return request.headers["X-Forwarded-For"].split(",")[0].strip()
|
|
|
|
|
|
|
|
return request.client.host if request.client else "unknown"
|
|
|
|
# Last resort
|
|
|
|
|
|
|
|
return request.client.host
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
return {
|
|
|
|
"endpoint": request.url.path,
|
|
|
|
"endpoint": request.url.path,
|
|
|
|
@ -67,169 +123,255 @@ def get_request_context(request: Request):
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle_sqlalchemy_error(error: SQLAlchemyError):
|
|
|
|
def _json_response(status_code: int, message: str, data: Any = None) -> JSONResponse:
|
|
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
|
|
status_code=status_code,
|
|
|
|
|
|
|
|
content={"data": data, "message": message, "status": ResponseStatus.ERROR},
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _store_log_state(
|
|
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
|
|
status_code: int,
|
|
|
|
|
|
|
|
error_id: str,
|
|
|
|
|
|
|
|
error_type: str,
|
|
|
|
|
|
|
|
error_message: str,
|
|
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
|
|
"""Store logging metadata on request.state so db_session_middleware emits a single structured log."""
|
|
|
|
|
|
|
|
request.state.log_status_code = status_code
|
|
|
|
|
|
|
|
request.state.log_error_id = error_id
|
|
|
|
|
|
|
|
request.state.log_error_type = error_type
|
|
|
|
|
|
|
|
request.state.log_error_message = error_message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _short_db_error(orig_str: str) -> str:
|
|
|
|
|
|
|
|
"""Extract a concise, human-readable reason from a raw DB/asyncpg error string."""
|
|
|
|
|
|
|
|
s = orig_str.lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if "uuid" in s:
|
|
|
|
|
|
|
|
if "length must be between" in s or "got " in s:
|
|
|
|
|
|
|
|
return "Invalid UUID length"
|
|
|
|
|
|
|
|
if "badly formed" in s or "invalid hexadecimal" in s or "invalid uuid" in s:
|
|
|
|
|
|
|
|
return "Invalid UUID format"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if "invalid input syntax for type" in s:
|
|
|
|
|
|
|
|
m = re.search(r"invalid input syntax for type (\w+)", s)
|
|
|
|
|
|
|
|
type_name = m.group(1) if m else "value"
|
|
|
|
|
|
|
|
return f"Invalid format for type {type_name}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if "invalid input for query argument" in s:
|
|
|
|
|
|
|
|
m = re.search(r"\(([^)]+)\)\s*$", orig_str)
|
|
|
|
|
|
|
|
if m:
|
|
|
|
|
|
|
|
return _short_db_error(m.group(1))
|
|
|
|
|
|
|
|
return "Invalid query argument value"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if "unique constraint" in s:
|
|
|
|
|
|
|
|
return "Unique constraint violation"
|
|
|
|
|
|
|
|
if "foreign key constraint" in s:
|
|
|
|
|
|
|
|
return "Foreign key constraint violation"
|
|
|
|
|
|
|
|
if "null value in column" in s:
|
|
|
|
|
|
|
|
m = re.search(r'null value in column "([^"]+)"', orig_str)
|
|
|
|
|
|
|
|
col = f' "{m.group(1)}"' if m else ""
|
|
|
|
|
|
|
|
return f"Required field{col} is missing"
|
|
|
|
|
|
|
|
if "check constraint" in s:
|
|
|
|
|
|
|
|
return "Check constraint violation"
|
|
|
|
|
|
|
|
if "out of range" in s:
|
|
|
|
|
|
|
|
return "Value out of allowed range"
|
|
|
|
|
|
|
|
if "numeric field overflow" in s:
|
|
|
|
|
|
|
|
return "Numeric field overflow"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
first_line = orig_str.split("\n")[0].split(". ")[0].strip()
|
|
|
|
|
|
|
|
first_line = re.sub(r"^<class '[^']+'>:\s*", "", first_line)
|
|
|
|
|
|
|
|
return first_line if first_line else "Database error"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Core error response builder ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_error_response(request: Request, exc: Exception, error_id: str) -> JSONResponse:
|
|
|
|
"""
|
|
|
|
"""
|
|
|
|
Handle SQLAlchemy errors and return user-friendly error messages.
|
|
|
|
Build JSON error response and store log metadata on request.state.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Status codes handled:
|
|
|
|
|
|
|
|
400 – BadRequestError
|
|
|
|
|
|
|
|
401 – UnauthorizedError (missing/expired token)
|
|
|
|
|
|
|
|
403 – ForbiddenError (valid token, access denied)
|
|
|
|
|
|
|
|
404 – NotFoundError
|
|
|
|
|
|
|
|
408 – RequestTimeoutError
|
|
|
|
|
|
|
|
422 – UnprocessableEntityError / validation errors / security payloads
|
|
|
|
|
|
|
|
429 – TooManyRequestsError / RateLimitExceeded
|
|
|
|
|
|
|
|
500 – InternalServerError / SQLAlchemy errors / unexpected
|
|
|
|
"""
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 1. Custom OptimumOH exceptions
|
|
|
|
|
|
|
|
for exc_class, status_code in _EXCEPTION_STATUS_MAP.items():
|
|
|
|
|
|
|
|
if isinstance(exc, exc_class):
|
|
|
|
|
|
|
|
error_type = _ERROR_TYPE_MAP[exc_class]
|
|
|
|
|
|
|
|
client_msg = _FIXED_MESSAGES.get(status_code, exc.message)
|
|
|
|
|
|
|
|
_store_log_state(request, status_code, error_id, error_type, exc.message)
|
|
|
|
|
|
|
|
return _json_response(status_code, f"{client_msg}. Error ID: {error_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 2. SlowAPI RateLimitExceeded
|
|
|
|
|
|
|
|
if isinstance(exc, RateLimitExceeded):
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
from src.auth.service import notify_admin_on_rate_limit_sync
|
|
|
|
|
|
|
|
request_info = get_request_context(request)
|
|
|
|
|
|
|
|
notify_admin_on_rate_limit_sync(
|
|
|
|
|
|
|
|
endpoint_name=request_info["endpoint"],
|
|
|
|
|
|
|
|
ip_address=request_info["remote_addr"],
|
|
|
|
|
|
|
|
method=request_info["method"],
|
|
|
|
|
|
|
|
request=request,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
_store_log_state(request, 429, error_id, "Too Many Requests", "Please try again later")
|
|
|
|
|
|
|
|
return _json_response(429, f"Please try again later. Error ID: {error_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 3. Pydantic / FastAPI RequestValidationError
|
|
|
|
|
|
|
|
if isinstance(exc, RequestValidationError):
|
|
|
|
|
|
|
|
errors = exc.errors()
|
|
|
|
|
|
|
|
detail_parts = []
|
|
|
|
|
|
|
|
for err in errors:
|
|
|
|
|
|
|
|
loc = " -> ".join(str(part) for part in err.get("loc", []))
|
|
|
|
|
|
|
|
msg = err.get("msg", "")
|
|
|
|
|
|
|
|
detail_parts.append(f"{loc}: {msg}" if loc else msg)
|
|
|
|
|
|
|
|
detail_str = "; ".join(detail_parts) if detail_parts else "Input validation failed"
|
|
|
|
|
|
|
|
_store_log_state(request, 422, error_id, "Unprocessable Entity", detail_str)
|
|
|
|
|
|
|
|
return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 4. Starlette/FastAPI HTTPException
|
|
|
|
|
|
|
|
if isinstance(exc, StarletteHTTPException):
|
|
|
|
|
|
|
|
status_code = exc.status_code
|
|
|
|
|
|
|
|
error_type = _HTTP_STATUS_NAMES.get(status_code, f"HTTP {status_code}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if status_code == 401:
|
|
|
|
|
|
|
|
log_msg = str(exc.detail) if exc.detail else "Invalid authorization token"
|
|
|
|
|
|
|
|
_store_log_state(request, 401, error_id, "Unauthorized", log_msg)
|
|
|
|
|
|
|
|
return _json_response(401, f"{_FIXED_MESSAGES[401]}. Error ID: {error_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if status_code == 422:
|
|
|
|
|
|
|
|
log_msg = str(exc.detail) if exc.detail else _FIXED_MESSAGES[422]
|
|
|
|
|
|
|
|
_store_log_state(request, 422, error_id, "Unprocessable Entity", log_msg)
|
|
|
|
|
|
|
|
return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(exc.detail, dict):
|
|
|
|
|
|
|
|
log_msg = exc.detail.get("error", error_type)
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
|
|
log_msg = str(exc.detail) if exc.detail else error_type
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
client_msg = _FIXED_MESSAGES.get(status_code, log_msg)
|
|
|
|
|
|
|
|
_store_log_state(request, status_code, error_id, error_type, log_msg)
|
|
|
|
|
|
|
|
return _json_response(status_code, f"{client_msg}. Error ID: {error_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 5. SQLAlchemy errors
|
|
|
|
|
|
|
|
if isinstance(exc, SQLAlchemyError):
|
|
|
|
|
|
|
|
orig = getattr(exc, "orig", None)
|
|
|
|
|
|
|
|
orig_str = str(orig) if orig else str(exc)
|
|
|
|
|
|
|
|
short = _short_db_error(orig_str)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# NoResultFound: .one() / .scalar_one() found no row → 404
|
|
|
|
|
|
|
|
if isinstance(exc, NoResultFound):
|
|
|
|
|
|
|
|
_store_log_state(request, 404, error_id, "Not Found", "Resource not found")
|
|
|
|
|
|
|
|
return _json_response(404, f"Resource not found. Error ID: {error_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(exc, IntegrityError):
|
|
|
|
|
|
|
|
orig_lower = orig_str.lower()
|
|
|
|
|
|
|
|
if "unique constraint" in orig_lower:
|
|
|
|
|
|
|
|
_store_log_state(request, 409, error_id, "Conflict", f"Unique constraint violation: {short}")
|
|
|
|
|
|
|
|
return _json_response(409, f"This record already exists. Error ID: {error_id}")
|
|
|
|
|
|
|
|
elif "foreign key constraint" in orig_lower:
|
|
|
|
|
|
|
|
_store_log_state(request, 422, error_id, "Unprocessable Entity", f"Foreign key constraint violation: {short}")
|
|
|
|
|
|
|
|
return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}")
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
|
|
_store_log_state(request, 422, error_id, "Unprocessable Entity", f"Database integrity error: {short}")
|
|
|
|
|
|
|
|
return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(exc, DataError):
|
|
|
|
|
|
|
|
_store_log_state(request, 422, error_id, "Unprocessable Entity", f"Invalid data format: {short}")
|
|
|
|
|
|
|
|
return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(exc, DBAPIError):
|
|
|
|
|
|
|
|
orig_lower = orig_str.lower()
|
|
|
|
|
|
|
|
if "invalid input syntax" in orig_lower or "invalid uuid" in orig_lower or "invalid input for query argument" in orig_lower:
|
|
|
|
|
|
|
|
_store_log_state(request, 422, error_id, "Unprocessable Entity", f"Invalid input format: {short}")
|
|
|
|
|
|
|
|
return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}")
|
|
|
|
|
|
|
|
elif "unique constraint" in orig_lower:
|
|
|
|
|
|
|
|
_store_log_state(request, 409, error_id, "Conflict", f"Unique constraint violation: {short}")
|
|
|
|
|
|
|
|
return _json_response(409, f"This record already exists. Error ID: {error_id}")
|
|
|
|
|
|
|
|
elif "foreign key constraint" in orig_lower:
|
|
|
|
|
|
|
|
_store_log_state(request, 422, error_id, "Unprocessable Entity", f"Foreign key constraint violation: {short}")
|
|
|
|
|
|
|
|
return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}")
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
|
|
_store_log_state(request, 500, error_id, "Internal Server Error", f"Database query error: {short}")
|
|
|
|
|
|
|
|
return _json_response(500, f"An unexpected error occurred. Error ID: {error_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_store_log_state(request, 500, error_id, "Internal Server Error", f"Database error: {short}")
|
|
|
|
|
|
|
|
return _json_response(500, f"An unexpected error occurred. Error ID: {error_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 5b. httpx upstream errors (raise_for_status() from external service calls)
|
|
|
|
try:
|
|
|
|
try:
|
|
|
|
original_error = error.orig
|
|
|
|
import httpx as _httpx
|
|
|
|
except AttributeError:
|
|
|
|
if isinstance(exc, _httpx.HTTPStatusError):
|
|
|
|
original_error = None
|
|
|
|
upstream_status = exc.response.status_code
|
|
|
|
|
|
|
|
error_type = _HTTP_STATUS_NAMES.get(upstream_status, f"HTTP {upstream_status}")
|
|
|
|
|
|
|
|
client_msg = _FIXED_MESSAGES.get(upstream_status, "Upstream service error")
|
|
|
|
|
|
|
|
log_msg = f"Upstream service returned {upstream_status}: {exc.request.url}"
|
|
|
|
|
|
|
|
_store_log_state(request, upstream_status, error_id, error_type, log_msg)
|
|
|
|
|
|
|
|
return _json_response(upstream_status, f"{client_msg}. Error ID: {error_id}")
|
|
|
|
|
|
|
|
if isinstance(exc, (_httpx.ConnectError, _httpx.TimeoutException, _httpx.RequestError)):
|
|
|
|
|
|
|
|
log_msg = f"Upstream service unreachable: {type(exc).__name__}: {exc}"
|
|
|
|
|
|
|
|
_store_log_state(request, 502, error_id, "Bad Gateway", log_msg)
|
|
|
|
|
|
|
|
return _json_response(502, f"Upstream service unavailable. Error ID: {error_id}")
|
|
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 6. Catch-all
|
|
|
|
|
|
|
|
detail = type(exc).__name__
|
|
|
|
|
|
|
|
_store_log_state(request, 500, error_id, "Internal Server Error", detail)
|
|
|
|
|
|
|
|
return _json_response(500, f"An unexpected error occurred. Error ID: {error_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Single exception handler registered with FastAPI ─────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def handle_exception(request: Request, exc: Exception) -> JSONResponse:
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
Global exception handler.
|
|
|
|
|
|
|
|
Sets request.state.log_* so that db_session_middleware emits a single structured log.
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
error_id = str(uuid.uuid4())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if not hasattr(request.state, "request_id"):
|
|
|
|
|
|
|
|
request.state.request_id = error_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return _build_error_response(request, exc, error_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Legacy helper kept for backward compatibility ─────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle_sqlalchemy_error(error: SQLAlchemyError):
|
|
|
|
|
|
|
|
"""Return (user_message, status_code) for a SQLAlchemy error."""
|
|
|
|
|
|
|
|
original_error = error.orig if hasattr(error, "orig") else None
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(error, IntegrityError):
|
|
|
|
if isinstance(error, IntegrityError):
|
|
|
|
if "unique constraint" in str(error).lower():
|
|
|
|
if "unique constraint" in str(error).lower():
|
|
|
|
return "This record already exists.", 422
|
|
|
|
return "This record already exists.", 409
|
|
|
|
elif "foreign key constraint" in str(error).lower():
|
|
|
|
elif "foreign key constraint" in str(error).lower():
|
|
|
|
return "Related record not found.", 422
|
|
|
|
return "Related record not found.", 400
|
|
|
|
else:
|
|
|
|
else:
|
|
|
|
return "Data integrity error.", 422
|
|
|
|
return "Data integrity error.", 400
|
|
|
|
elif isinstance(error, DataError) or isinstance(original_error, AsyncPGDataError):
|
|
|
|
elif isinstance(error, DataError) or isinstance(original_error, AsyncPGDataError):
|
|
|
|
return "Invalid data provided.", 422
|
|
|
|
return "Invalid data provided.", 400
|
|
|
|
elif isinstance(error, DBAPIError):
|
|
|
|
elif isinstance(error, DBAPIError):
|
|
|
|
if "unique constraint" in str(error).lower():
|
|
|
|
if "unique constraint" in str(error).lower():
|
|
|
|
return "This record already exists.", 422
|
|
|
|
return "This record already exists.", 409
|
|
|
|
elif "foreign key constraint" in str(error).lower():
|
|
|
|
elif "foreign key constraint" in str(error).lower():
|
|
|
|
return "Related record not found.", 422
|
|
|
|
return "Related record not found.", 400
|
|
|
|
elif "null value in column" in str(error).lower():
|
|
|
|
elif "null value in column" in str(error).lower():
|
|
|
|
return "Required data missing.", 422
|
|
|
|
return "Required data missing.", 400
|
|
|
|
elif "invalid input for query argument" in str(error).lower():
|
|
|
|
elif "invalid input for query argument" in str(error).lower():
|
|
|
|
return "Invalid data provided.", 422
|
|
|
|
return "Invalid data provided.", 400
|
|
|
|
else:
|
|
|
|
else:
|
|
|
|
return "Database error.", 500
|
|
|
|
return "Database error.", 500
|
|
|
|
else:
|
|
|
|
else:
|
|
|
|
# Log the full error for debugging purposes
|
|
|
|
|
|
|
|
log.error(f"Unexpected database error: {str(error)}")
|
|
|
|
log.error(f"Unexpected database error: {str(error)}")
|
|
|
|
return "An unexpected database error occurred.", 500
|
|
|
|
return "An unexpected database error occurred.", 500
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle_exception(request: Request, exc: Exception):
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
Global exception handler for Fastapi application.
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
|
|
error_id = str(uuid.uuid1())
|
|
|
|
|
|
|
|
request_info = get_request_context(request)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Store error_id in request.state for middleware/logging
|
|
|
|
|
|
|
|
request.state.error_id = error_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(exc, RateLimitExceeded):
|
|
|
|
|
|
|
|
# Kirim notifikasi ke admin
|
|
|
|
|
|
|
|
notify_admin_on_rate_limit_sync(
|
|
|
|
|
|
|
|
endpoint_name=request_info["endpoint"],
|
|
|
|
|
|
|
|
ip_address=request_info["remote_addr"],
|
|
|
|
|
|
|
|
method=request_info["method"],
|
|
|
|
|
|
|
|
request=request,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
log.warning(
|
|
|
|
|
|
|
|
f"Rate limit exceeded: {str(exc.description) if hasattr(exc, 'description') else str(exc)} | Error ID: {error_id}",
|
|
|
|
|
|
|
|
extra={
|
|
|
|
|
|
|
|
"error_id": error_id,
|
|
|
|
|
|
|
|
"error_category": "rate_limit",
|
|
|
|
|
|
|
|
"detail": str(exc.description) if hasattr(exc, "description") else str(exc),
|
|
|
|
|
|
|
|
"request": request_info,
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
|
|
status_code=429,
|
|
|
|
|
|
|
|
content={
|
|
|
|
|
|
|
|
"data": None,
|
|
|
|
|
|
|
|
"message": "Rate limit exceeded",
|
|
|
|
|
|
|
|
"status": ResponseStatus.ERROR,
|
|
|
|
|
|
|
|
"error_id": error_id
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(exc, RequestValidationError):
|
|
|
|
|
|
|
|
log.warning(
|
|
|
|
|
|
|
|
f"Validation error: {exc.errors()} | Error ID: {error_id}",
|
|
|
|
|
|
|
|
extra={
|
|
|
|
|
|
|
|
"error_id": error_id,
|
|
|
|
|
|
|
|
"error_category": "validation",
|
|
|
|
|
|
|
|
"errors": exc.errors(),
|
|
|
|
|
|
|
|
"request": request_info,
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
|
|
status_code=422,
|
|
|
|
|
|
|
|
content={
|
|
|
|
|
|
|
|
"data": exc.errors(),
|
|
|
|
|
|
|
|
"message": "Validation Error",
|
|
|
|
|
|
|
|
"status": ResponseStatus.ERROR,
|
|
|
|
|
|
|
|
"error_id": error_id
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(exc, (HTTPException, StarletteHTTPException)):
|
|
|
|
|
|
|
|
# Log as warning for 4xx, error for 5xx
|
|
|
|
|
|
|
|
status_code = exc.status_code if hasattr(exc, "status_code") else 500
|
|
|
|
|
|
|
|
detail = exc.detail if hasattr(exc, "detail") else str(exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
log_level = logging.WARNING if 400 <= status_code < 500 else logging.ERROR
|
|
|
|
|
|
|
|
log.log(
|
|
|
|
|
|
|
|
log_level,
|
|
|
|
|
|
|
|
f"HTTP {status_code}: {detail} | Error ID: {error_id}",
|
|
|
|
|
|
|
|
extra={
|
|
|
|
|
|
|
|
"error_id": error_id,
|
|
|
|
|
|
|
|
"error_category": "http",
|
|
|
|
|
|
|
|
"status_code": status_code,
|
|
|
|
|
|
|
|
"request": request_info,
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
|
|
status_code=status_code,
|
|
|
|
|
|
|
|
content={
|
|
|
|
|
|
|
|
"data": None,
|
|
|
|
|
|
|
|
"message": str(detail),
|
|
|
|
|
|
|
|
"status": ResponseStatus.ERROR,
|
|
|
|
|
|
|
|
"error_id": error_id
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(exc, SQLAlchemyError):
|
|
|
|
|
|
|
|
error_message, status_code = handle_sqlalchemy_error(exc)
|
|
|
|
|
|
|
|
# Log integrity errors as warning, others as error
|
|
|
|
|
|
|
|
log_level = logging.WARNING if 400 <= status_code < 500 else logging.ERROR
|
|
|
|
|
|
|
|
log.log(
|
|
|
|
|
|
|
|
log_level,
|
|
|
|
|
|
|
|
f"Database error: {error_message} | Error ID: {error_id}",
|
|
|
|
|
|
|
|
extra={
|
|
|
|
|
|
|
|
"error_id": error_id,
|
|
|
|
|
|
|
|
"error_category": "database",
|
|
|
|
|
|
|
|
"violation": str(exc).split('\n')[0],
|
|
|
|
|
|
|
|
"request": request_info,
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
|
|
status_code=status_code,
|
|
|
|
|
|
|
|
content={
|
|
|
|
|
|
|
|
"data": None,
|
|
|
|
|
|
|
|
"message": error_message,
|
|
|
|
|
|
|
|
"status": ResponseStatus.ERROR,
|
|
|
|
|
|
|
|
"error_id": error_id
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Log unexpected errors
|
|
|
|
|
|
|
|
log.error(
|
|
|
|
|
|
|
|
f"Unexpected error: {str(exc)} | Error ID: {error_id}",
|
|
|
|
|
|
|
|
extra={
|
|
|
|
|
|
|
|
"error_id": error_id,
|
|
|
|
|
|
|
|
"error_category": "unexpected",
|
|
|
|
|
|
|
|
"request": request_info,
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
|
|
status_code=500,
|
|
|
|
|
|
|
|
content={
|
|
|
|
|
|
|
|
"data": None,
|
|
|
|
|
|
|
|
"message": "An unexpected error occurred",
|
|
|
|
|
|
|
|
"status": ResponseStatus.ERROR,
|
|
|
|
|
|
|
|
"error_id": error_id
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|