kernelmind/Resume-Screener-API
0
1"""2Custom exceptions for the Resume Screener API.3 4Why custom exceptions instead of generic HTTPException everywhere?51. Centralized error handling - one place to change error formats62. Type safety - catch specific errors, not generic Exception73. Better logging - we can log different errors differently84. Client-friendly messages - hide internal details from API responses9"""10 11from typing import Any12 13from fastapi import HTTPException, Request14from fastapi.responses import JSONResponse15 16from src.core.logging import logger17 18 19class ResumeScreenerError(Exception):20 """Base exception for all Resume Screener errors."""21 22 def __init__(self, message: str, details: dict[str, Any] | None = None):23 self.message = message24 self.details = details or {}25 super().__init__(self.message)26 27 28class InvalidFileError(ResumeScreenerError):29 """30 Raised when uploaded file fails validation.31 32 This catches both wrong extensions AND invalid magic bytes.33 Common attack: rename malware.exe to resume.pdf34 """35 pass36 37 38class PDFParsingError(ResumeScreenerError):39 """40 Raised when pdfplumber fails to extract text.41 42 Usually means corrupted PDF or scanned image without OCR.43 """44 pass45 46 47class GeminiAPIError(ResumeScreenerError):48 """49 Raised when Gemini API calls fail after all retries.50 51 This should only bubble up after tenacity exhausts retries,52 so if we see this, something is seriously wrong (quota, key, outage).53 """54 pass55 56 57class EmbeddingError(ResumeScreenerError):58 """Raised when embedding generation fails."""59 pass60 61 62# === FastAPI Exception Handlers ===63# These convert our custom exceptions into proper HTTP responses64 65async def resume_screener_error_handler(66 request: Request, 67 exc: ResumeScreenerError68) -> JSONResponse:69 """Handle all ResumeScreener exceptions with consistent format."""70 71 # Log the error with context - escape braces to prevent loguru KeyError72 safe_message = str(exc.message).replace('{', '{{').replace('}', '}}')73 logger.error(74 f"ResumeScreenerError: {safe_message}",75 extra={"details": exc.details, "path": request.url.path}76 )77 78 return JSONResponse(79 status_code=400,80 content={81 "success": False,82 "error": {83 "type": exc.__class__.__name__,84 "message": exc.message,85 "details": exc.details,86 }87 }88 )89 90 91async def invalid_file_error_handler(92 request: Request, 93 exc: InvalidFileError94) -> JSONResponse:95 """Handle file validation errors."""96 97 safe_message = str(exc.message).replace('{', '{{').replace('}', '}}')98 logger.warning(99 f"Invalid file upload attempt: {safe_message}",100 extra={"path": request.url.path}101 )102 103 return JSONResponse(104 status_code=415, # Unsupported Media Type - more accurate than 400105 content={106 "success": False,107 "error": {108 "type": "InvalidFileError",109 "message": exc.message,110 }111 }112 )113 114 115async def gemini_api_error_handler(116 request: Request, 117 exc: GeminiAPIError118) -> JSONResponse:119 """Handle Gemini API failures."""120 121 # Escape braces to prevent loguru KeyError from Gemini's JSON error messages122 safe_message = str(exc.message).replace('{', '{{').replace('}', '}}')123 logger.error(124 f"Gemini API error after retries: {safe_message}",125 extra={"details": exc.details}126 )127 128 return JSONResponse(129 status_code=503, # Service Unavailable - indicates upstream failure130 content={131 "success": False,132 "error": {133 "type": "GeminiAPIError",134 "message": "AI processing service temporarily unavailable",135 # Don't expose internal error details to clients136 }137 }138 )139 140 141async def unhandled_exception_handler(142 request: Request, 143 exc: Exception144) -> JSONResponse:145 """146 Catch-all for unhandled exceptions.147 148 This prevents stack traces from leaking to clients.149 We log the full error but return a generic message.150 """151 152 logger.exception(153 f"Unhandled exception on {request.url.path}",154 exc_info=exc155 )156 157 return JSONResponse(158 status_code=500,159 content={160 "success": False,161 "error": {162 "type": "InternalError",163 "message": "An unexpected error occurred. Please try again.",164 }165 }166 )167 