162 lines
5.2 KiB
Python
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]
|