"""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]