HR-ATS-Portal/app/core/errors.py

162 lines
5.2 KiB
Python

"""Domain exceptions, stable error codes, and provider-error classification.
Public messages are fixed strings. Provider response bodies, stack traces, prompts,
and document content never reach a client.
"""
from __future__ import annotations
import asyncio
import openai
from pydantic import ValidationError
class ErrorCode:
"""Stable, client-visible error codes."""
INVALID_PDF = "INVALID_PDF"
PDF_ENCRYPTED = "PDF_ENCRYPTED"
PDF_TEXT_UNAVAILABLE = "PDF_TEXT_UNAVAILABLE"
MODEL_RATE_LIMITED = "MODEL_RATE_LIMITED"
MODEL_TIMEOUT = "MODEL_TIMEOUT"
MODEL_REFUSED = "MODEL_REFUSED"
MODEL_RESPONSE_INVALID = "MODEL_RESPONSE_INVALID"
MODEL_UNAVAILABLE = "MODEL_UNAVAILABLE"
INTERNAL_ERROR = "INTERNAL_ERROR"
# Request-level (batch is rejected outright).
INVALID_REQUEST = "INVALID_REQUEST"
PAYLOAD_TOO_LARGE = "PAYLOAD_TOO_LARGE"
UNSUPPORTED_FILE_TYPE = "UNSUPPORTED_FILE_TYPE"
UNPROCESSABLE_FIELD = "UNPROCESSABLE_FIELD"
RATE_LIMITED = "RATE_LIMITED"
PROVIDER_UNAVAILABLE = "PROVIDER_UNAVAILABLE"
class ATSError(Exception):
"""Base domain error.
``detail`` is for logs only. ``public_message`` is the only text a client sees.
"""
error_code: str = ErrorCode.INTERNAL_ERROR
public_message: str = "An internal error occurred."
http_status: int = 500
def __init__(self, detail: str | None = None) -> None:
super().__init__(detail or self.public_message)
self.detail = detail
# --- Per-candidate failures (batch still returns 200) ------------------------
class InvalidPDFError(ATSError):
error_code = ErrorCode.INVALID_PDF
public_message = "The file is not a readable PDF."
class EncryptedPDFError(ATSError):
error_code = ErrorCode.PDF_ENCRYPTED
public_message = "The PDF is password protected and cannot be read."
class PDFTextUnavailableError(ATSError):
error_code = ErrorCode.PDF_TEXT_UNAVAILABLE
public_message = "No usable text could be extracted from the PDF."
class ModelRefusedError(ATSError):
error_code = ErrorCode.MODEL_REFUSED
public_message = "The evaluator declined to score this document."
class ModelResponseInvalidError(ATSError):
error_code = ErrorCode.MODEL_RESPONSE_INVALID
public_message = "The evaluator returned an unusable result."
class ModelUnavailableError(ATSError):
error_code = ErrorCode.MODEL_UNAVAILABLE
public_message = "The scoring provider was unavailable for this candidate."
# --- Request-level failures --------------------------------------------------
class InvalidRequestError(ATSError):
error_code = ErrorCode.INVALID_REQUEST
public_message = "The request is malformed."
http_status = 400
class PayloadTooLargeError(ATSError):
error_code = ErrorCode.PAYLOAD_TOO_LARGE
public_message = "The upload exceeds the configured limits."
http_status = 413
class UnsupportedFileTypeError(ATSError):
error_code = ErrorCode.UNSUPPORTED_FILE_TYPE
public_message = "Only PDF resumes are accepted."
http_status = 415
class UnprocessableFieldError(ATSError):
error_code = ErrorCode.UNPROCESSABLE_FIELD
public_message = "A field value is outside the accepted range."
http_status = 422
class ProviderUnavailableError(ATSError):
error_code = ErrorCode.PROVIDER_UNAVAILABLE
public_message = "The scoring provider is unavailable. Try again later."
http_status = 503
# --- Classification ----------------------------------------------------------
_PUBLIC_MESSAGES: dict[str, str] = {
ErrorCode.MODEL_RATE_LIMITED: "The scoring provider rate limited this request.",
ErrorCode.MODEL_TIMEOUT: "Scoring timed out for this candidate.",
ErrorCode.MODEL_UNAVAILABLE: "The scoring provider was unavailable for this candidate.",
ErrorCode.MODEL_RESPONSE_INVALID: ModelResponseInvalidError.public_message,
ErrorCode.INTERNAL_ERROR: ATSError.public_message,
}
def classify_error(exc: BaseException) -> tuple[str, str]:
"""Map an exception to a ``(error_code, public_message)`` pair.
Never returns provider text. Unknown exceptions collapse to INTERNAL_ERROR.
"""
if isinstance(exc, ATSError):
return exc.error_code, exc.public_message
if isinstance(exc, ValidationError):
code = ErrorCode.MODEL_RESPONSE_INVALID
return code, _PUBLIC_MESSAGES[code]
if isinstance(exc, openai.APITimeoutError | asyncio.TimeoutError | TimeoutError):
code = ErrorCode.MODEL_TIMEOUT
return code, _PUBLIC_MESSAGES[code]
if isinstance(exc, openai.RateLimitError):
code = ErrorCode.MODEL_RATE_LIMITED
return code, _PUBLIC_MESSAGES[code]
if isinstance(exc, openai.APIConnectionError):
code = ErrorCode.MODEL_UNAVAILABLE
return code, _PUBLIC_MESSAGES[code]
if isinstance(exc, openai.APIStatusError):
# Auth/permission problems are configuration bugs, not candidate data
# problems, but they must not abort the batch either -- surface them as
# provider-unavailable per candidate and rely on logs for the real cause.
code = ErrorCode.MODEL_UNAVAILABLE
return code, _PUBLIC_MESSAGES[code]
code = ErrorCode.INTERNAL_ERROR
return code, _PUBLIC_MESSAGES[code]