From 4ecc7aaa7fed59eebfdb521e6b1a5b6ea972ea73 Mon Sep 17 00:00:00 2001 From: cmw-creator Date: Thu, 4 Jun 2026 03:56:16 +0800 Subject: [PATCH] enhance: improve error messages across the codebase - Make error messages more descriptive and actionable - Add context to proxy configuration errors - Add user-friendly guidance to network errors - Fix MalformedDatabase bug (info overwritten) Closes #577 --- safety/config/proxy.py | 30 ++++++++--- safety/errors.py | 107 ++++++++++++++++++++++++++++++-------- safety/output_utils.py | 25 +++++++-- safety/platform/client.py | 5 +- 4 files changed, 132 insertions(+), 35 deletions(-) diff --git a/safety/config/proxy.py b/safety/config/proxy.py index 39cbf8ec..a218ee41 100644 --- a/safety/config/proxy.py +++ b/safety/config/proxy.py @@ -68,7 +68,9 @@ def _should_build_proxy_config( if not host or not host.strip(): if any(v is not None for v in other_values): raise ValueError( - f"Proxy host must be provided when using other proxy options in {source}." + f"Proxy host must be provided when using other proxy options " + f"(from {source}). Please specify --proxy-host or configure the " + f"host in your Safety config file." ) return False @@ -83,7 +85,10 @@ def _build_proxy_config( ) -> ProxyConfig: if not host or not host.strip(): logger.error(PROXY_HOST_EMPTY, extra={"source": source}) - raise ValueError("Proxy host must not be empty") + raise ValueError( + f"Proxy host must not be empty (from {source}). " + f"Please provide a valid proxy host." + ) host = host.strip() @@ -92,7 +97,10 @@ def _build_proxy_config( logger.error( PROXY_PROTOCOL_INVALID, extra={"protocol": scheme, "source": source} ) - raise ValueError(f"Invalid proxy protocol: {scheme!r}") + raise ValueError( + f"Invalid proxy protocol '{scheme}' (from {source}). " + f"Allowed protocols are: http, https." + ) port = port or DEFAULT_PROXY_PORT @@ -139,7 +147,11 @@ def _proxy_from_config_ini(config_path: Path) -> Optional[ProxyConfig]: return None if not host_raw: - raise ValueError("Proxy host must not be empty") + raise ValueError( + "Proxy host must not be empty in config file. " + "Please set a valid 'host' value under the [proxy] section " + "in your Safety config file." + ) return _build_proxy_config( host=host_raw, @@ -170,7 +182,10 @@ def _proxy_from_cli_options( return None if not host: - raise ValueError("Proxy host must not be empty") + raise ValueError( + "Proxy host must not be empty. " + "Please provide a valid host using --proxy-host." + ) port_val = None @@ -178,7 +193,10 @@ def _proxy_from_cli_options( try: port_val = int(port) except ValueError: - raise ValueError("Proxy port must be an integer") + raise ValueError( + f"Proxy port must be an integer, got '{port}'. " + f"Please provide a valid port number using --proxy-port." + ) return _build_proxy_config( host=host, diff --git a/safety/errors.py b/safety/errors.py index 248211fe..965303af 100644 --- a/safety/errors.py +++ b/safety/errors.py @@ -1,3 +1,29 @@ +""" +Custom exception and error classes used by the Safety CLI. + +Hierarchy: + SafetyException (base, exit code 1) + └── SafetyError (generic error base) + ├── MalformedDatabase + ├── DatabaseFetchError + │ ├── InvalidCredentialError + │ ├── TooManyRequestsError + │ ├── RequestTimeoutError + │ └── ServerError + ├── DatabaseFileNotFoundError + ├── InvalidProvidedReportError + ├── InvalidRequirementError + ├── NotVerifiedEmailError + ├── NetworkConnectionError + │ └── SSLCertificateError + ├── EnrollmentError + │ └── EnrollmentTransientFailure + └── MachineIdUnavailableError + +Each class defines a ``get_exit_code()`` method returning a dedicated +exit code from ``safety.constants`` so that callers can distinguish +error types in scripts and CI pipelines. +""" from typing import Optional from safety.constants import ( @@ -26,7 +52,9 @@ class SafetyException(Exception): """ def __init__( - self, message: str = "Unhandled exception happened: {info}", info: str = "" + self, + message: str = "An unexpected Safety CLI error occurred: {info}", + info: str = "", ): self.message = message.format(info=info) super().__init__(self.message) @@ -52,7 +80,7 @@ class SafetyError(Exception): def __init__( self, - message: str = "Unhandled Safety generic error", + message: str = "An unexpected Safety CLI error occurred.", error_code: Optional[int] = None, ): self.message = message @@ -83,11 +111,10 @@ def __init__( self, reason: Optional[str] = None, fetched_from: str = "server", - message: str = "Sorry, something went wrong.\n" - "Safety CLI cannot read the data fetched from {fetched_from} because it is malformed.\n", + message: str = "The vulnerability database fetched from {fetched_from} is malformed " + "and cannot be read by Safety CLI.\n", ): - info = f"Reason, {reason}" if reason else "" - info = "Reason, {reason}".format(reason=reason) + info = f"Reason: {reason}" if reason else "" self.message = message.format(fetched_from=fetched_from) + ( info if reason else "" ) @@ -111,7 +138,10 @@ class DatabaseFetchError(SafetyError): message (str): The error message. """ - def __init__(self, message: str = "Unable to load vulnerability database"): + def __init__( + self, message: str = "Unable to fetch the vulnerability database. " + "Please check your network connection and try again." + ): self.message = message super().__init__(self.message) @@ -161,7 +191,10 @@ class InvalidRequirementError(SafetyError): """ def __init__( - self, message: str = "Unable to parse the requirement: {line}", line: str = "" + self, + message: str = "Unable to parse the package requirement: '{line}'. " + "Please ensure the requirement format is valid.", + line: str = "", ): self.message = message.format(line=line) super().__init__(self.message) @@ -188,7 +221,8 @@ class DatabaseFileNotFoundError(DatabaseFetchError): def __init__( self, db: Optional[str] = None, - message: str = "Unable to find vulnerability database in {db}", + message: str = "Unable to find the vulnerability database file at: {db}. " + "Please verify the file exists and the path is correct.", ): self.db = db self.message = message.format(db=db) @@ -217,17 +251,16 @@ class InvalidCredentialError(DatabaseFetchError): def __init__( self, credential: Optional[str] = None, - message: str = "Your authentication credential{credential}is invalid. See {link}.", + message: str = "Your authentication credential is invalid. See {link}.", reason: Optional[str] = None, ): self.credential = credential self.link = ( "https://docs.safetycli.com/safety-docs/support/invalid-api-key-error" ) + credential_info = f" Credential: '{credential}'" if credential else "" self.message = ( - message.format(credential=f" '{self.credential}' ", link=self.link) - if self.credential - else message.format(credential=" ", link=self.link) + message.format(link=self.link) + credential_info ) info = f" Reason: {reason}" self.message = self.message + (info if reason else "") @@ -251,7 +284,11 @@ class NotVerifiedEmailError(SafetyError): message (str): The error message. """ - def __init__(self, message: str = "email is not verified"): + def __init__( + self, + message: str = "Your Safety account email is not verified. " + "Please check your inbox and verify your email address to continue.", + ): self.message = message super().__init__(self.message) @@ -275,7 +312,10 @@ class TooManyRequestsError(DatabaseFetchError): """ def __init__( - self, reason: Optional[str] = None, message: str = "Too many requests." + self, + reason: Optional[str] = None, + message: str = "Too many requests sent to the Safety server. " + "Please wait and try again later.", ): info = f" Reason: {reason}" self.message = message + (info if reason else "") @@ -301,7 +341,8 @@ class NetworkConnectionError(SafetyError): def __init__( self, - message: str = "Check your network connection, unable to reach the server.", + message: str = "Unable to reach the Safety server. " + "Please check your network connection and try again.", ): self.message = message super().__init__(self.message) @@ -315,7 +356,12 @@ class SSLCertificateError(NetworkConnectionError): message (str): The error message. """ - def __init__(self, message: str = "There is a SSL certificate issue."): + def __init__( + self, + message: str = "SSL certificate verification failed when connecting to the Safety server. " + "This may be caused by a proxy, corporate firewall, or outdated root certificates. " + "See https://docs.safetycli.com for TLS configuration options.", + ): self.message = message super().__init__(self.message) @@ -329,7 +375,9 @@ class RequestTimeoutError(DatabaseFetchError): """ def __init__( - self, message: str = "Check your network connection, the request timed out." + self, + message: str = "Request to the Safety server timed out. " + "Please check your network connection and try again.", ): self.message = message super().__init__(self.message) @@ -347,8 +395,8 @@ class ServerError(DatabaseFetchError): def __init__( self, reason: Optional[str] = None, - message: str = "Sorry, something went wrong.\n" - "Our engineers are working quickly to resolve the issue.", + message: str = "The Safety server encountered an error. " + "Our engineers are working to resolve the issue. Please try again later.", ): info = f" Reason: {reason}" self.message = message + (info if reason else "") @@ -363,7 +411,11 @@ class EnrollmentError(SafetyError): message (str): The error message. """ - def __init__(self, message: str = "Enrollment failed"): + def __init__( + self, + message: str = "Machine enrollment failed. " + "Please check your enrollment key and try again.", + ): self.message = message super().__init__(self.message) @@ -388,7 +440,11 @@ class EnrollmentTransientFailure(EnrollmentError): message (str): The error message. """ - def __init__(self, message: str = "Enrollment failed (transient error)"): + def __init__( + self, + message: str = "Machine enrollment failed due to a transient error. " + "This is usually temporary. Please try again.", + ): self.message = message super().__init__(self.message) @@ -410,7 +466,12 @@ class MachineIdUnavailableError(SafetyError): message (str): The error message. """ - def __init__(self, message: str = "Unable to determine system identity"): + def __init__( + self, + message: str = "Unable to determine the unique identity of this machine. " + "This is required for enrollment. " + "Ensure the machine has a unique hostname and the required system files are accessible.", + ): self.message = message super().__init__(self.message) diff --git a/safety/output_utils.py b/safety/output_utils.py index cdf8ae3e..4c4d3d43 100644 --- a/safety/output_utils.py +++ b/safety/output_utils.py @@ -1430,12 +1430,21 @@ def build_primary_announcement( for line in lines: if "words" not in line: - raise ValueError("Missing words keyword") + raise ValueError( + "Missing 'words' keyword in announcement JSON. " + "Each announcement line must include a 'words' field." + ) if len(line["words"]) <= 0: - raise ValueError("No words in this line") + raise ValueError( + "No words found in announcement line. " + "Each 'words' field must contain at least one word." + ) for word in line["words"]: if "value" not in word or not word["value"]: - raise ValueError("Empty word or without value") + raise ValueError( + "Empty word or missing 'value' in announcement JSON. " + "Each word in the 'words' array must have a non-empty 'value' field." + ) message = style_lines(lines, columns, start_line="", end_line="") @@ -1577,7 +1586,10 @@ def print_service( formats = ["text", "screen"] if out_format not in formats: - raise ValueError(f"Print is only allowed for {', '.join(formats)}") + raise ValueError( + f"Output format '{out_format}' is not supported for printing. " + f"Print is only allowed for: {', '.join(formats)}." + ) if not format_text: format_text = { @@ -1620,7 +1632,10 @@ def prompt_service( formats = ["text", "screen"] if out_format not in formats: - raise ValueError(f"Prompt is only allowed for {', '.join(formats)}") + raise ValueError( + f"Output format '{out_format}' is not supported for prompting. " + f"Prompt is only allowed for: {', '.join(formats)}." + ) if not format_text: format_text = { diff --git a/safety/platform/client.py b/safety/platform/client.py index da9e9ac1..f765091a 100644 --- a/safety/platform/client.py +++ b/safety/platform/client.py @@ -235,7 +235,10 @@ def _create_http_client(self) -> httpx.Client: **client_kwargs, ) else: - raise ValueError(f"Unexpected auth_type: {self._auth_type}") + raise ValueError( + f"Unexpected authentication type '{self._auth_type}'. " + f"Supported types: api_key, token, machine_token." + ) def _initialize_with_tls_fallback(self) -> None: """Initialize the client by testing TLS with a lightweight HEAD probe.