From 8a4ee8ca9665a5fcb90e7be9d2f07cc909ef8082 Mon Sep 17 00:00:00 2001 From: Sree Siddhartha Date: Sat, 10 Jan 2026 03:56:53 +0530 Subject: [PATCH 1/3] feat: add user-friendly error messages (#379) --- src/pieces/app.py | 7 +- src/pieces/errors/__init__.py | 20 ++ src/pieces/errors/user_friendly.py | 518 +++++++++++++++++++++++++++++ src/pieces/settings.py | 27 +- tests/test_user_friendly_errors.py | 217 ++++++++++++ 5 files changed, 784 insertions(+), 5 deletions(-) create mode 100644 src/pieces/errors/__init__.py create mode 100644 src/pieces/errors/user_friendly.py create mode 100644 tests/test_user_friendly_errors.py diff --git a/src/pieces/app.py b/src/pieces/app.py index 28450633..1e9e1df1 100644 --- a/src/pieces/app.py +++ b/src/pieces/app.py @@ -5,6 +5,7 @@ from pieces.config.constants import PIECES_DATA_DIR from pieces.config.migration import run_migration +from pieces.errors import format_error from pieces.headless.exceptions import HeadlessError from pieces.headless.models.base import ErrorCode from pieces.headless.output import HeadlessOutput @@ -171,7 +172,11 @@ def main(): else: Settings.logger.critical(e, ignore_sentry=True) try: - Settings.show_error("UNKNOWN EXCEPTION", e) + # Display user-friendly error message + friendly_message = format_error(e, include_technical=False) + Settings.logger.console_error.print(f"[red]{friendly_message}[/red]") + if not Settings.run_in_loop: + sys.exit(2) except SystemExit: sentry_sdk.flush(2) raise diff --git a/src/pieces/errors/__init__.py b/src/pieces/errors/__init__.py new file mode 100644 index 00000000..4fe01925 --- /dev/null +++ b/src/pieces/errors/__init__.py @@ -0,0 +1,20 @@ +""" +User-friendly error handling for Pieces CLI. + +This module provides utilities for transforming technical error messages +into helpful, actionable messages for users. +""" + +from .user_friendly import ( + UserFriendlyError, + ErrorCategory, + format_error, + get_user_friendly_message, +) + +__all__ = [ + "UserFriendlyError", + "ErrorCategory", + "format_error", + "get_user_friendly_message", +] diff --git a/src/pieces/errors/user_friendly.py b/src/pieces/errors/user_friendly.py new file mode 100644 index 00000000..0ea7f83e --- /dev/null +++ b/src/pieces/errors/user_friendly.py @@ -0,0 +1,518 @@ +""" +User-friendly error messages for Pieces CLI. + +This module transforms technical error messages into helpful, actionable +messages that guide users toward solutions. +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional, List, Dict, Type, Callable +import re +import socket + + +class ErrorCategory(Enum): + """Categories of errors for classification.""" + + CONNECTION = "connection" + AUTHENTICATION = "authentication" + PERMISSION = "permission" + NOT_FOUND = "not_found" + TIMEOUT = "timeout" + VERSION = "version" + CONFIGURATION = "configuration" + RATE_LIMIT = "rate_limit" + SERVER = "server" + UNKNOWN = "unknown" + + +@dataclass +class UserFriendlyError: + """ + Represents a user-friendly error message with context and solutions. + + Attributes: + title: A brief description of what went wrong + reasons: Possible reasons for the error + solutions: Steps the user can try to resolve the issue + help_link: URL to documentation or troubleshooting guide + category: The category of error for classification + original_error: The original exception that was caught + technical_details: Optional technical details for debugging + """ + + title: str + reasons: List[str] = field(default_factory=list) + solutions: List[str] = field(default_factory=list) + help_link: Optional[str] = None + category: ErrorCategory = ErrorCategory.UNKNOWN + original_error: Optional[Exception] = None + technical_details: Optional[str] = None + + def format_error(self, include_technical: bool = False) -> str: + """ + Format the error into a user-friendly string. + + Args: + include_technical: Whether to include technical details + + Returns: + A formatted error message string + """ + lines = [] + + # Title with error indicator + lines.append(f"❌ {self.title}") + lines.append("") + + # Possible reasons + if self.reasons: + lines.append("Possible reasons:") + for reason in self.reasons: + lines.append(f" • {reason}") + lines.append("") + + # Solutions + if self.solutions: + lines.append("Try these solutions:") + for i, solution in enumerate(self.solutions, 1): + lines.append(f" {i}. {solution}") + lines.append("") + + # Help link + if self.help_link: + lines.append(f"📖 More help: {self.help_link}") + + # Technical details (optional, for debugging) + if include_technical and self.technical_details: + lines.append("") + lines.append(f"Technical details: {self.technical_details}") + + return "\n".join(lines) + + def __str__(self) -> str: + return self.format_error() + + +# Error patterns and their corresponding user-friendly messages +ERROR_PATTERNS: Dict[str, Dict] = { + # Connection errors + r"Connection refused|ConnectionRefusedError|\[Errno 61\]|\[Errno 111\]|\[WinError 10061\]": { + "title": "Cannot connect to Pieces OS", + "reasons": [ + "Pieces OS may not be running", + "Pieces OS may be starting up", + "The service port may be blocked", + ], + "solutions": [ + "Ensure Pieces OS is running: `pieces open`", + "Check if Pieces OS is starting: `pieces status`", + "Restart Pieces OS: `pieces restart`", + ], + "help_link": "https://docs.pieces.app/products/cli/troubleshooting", + "category": ErrorCategory.CONNECTION, + }, + + r"WebSocket.*failed|WebSocket.*error|WebSocketException|WebSocketConnectionClosedException": { + "title": "WebSocket connection failed", + "reasons": [ + "Pieces OS may not be running", + "Network connection issues", + "Firewall blocking the connection", + ], + "solutions": [ + "Ensure Pieces OS is running: `pieces open`", + "Check your network connection", + "Try restarting Pieces OS: `pieces restart`", + ], + "help_link": "https://docs.pieces.app/products/cli/troubleshooting", + "category": ErrorCategory.CONNECTION, + }, + + r"timeout|TimeoutError|timed out|ETIMEDOUT": { + "title": "Operation timed out", + "reasons": [ + "Pieces OS is taking too long to respond", + "Network connection is slow or unstable", + "The operation is processing a large amount of data", + ], + "solutions": [ + "Wait a moment and try again", + "Check your network connection", + "Restart Pieces OS: `pieces restart`", + ], + "help_link": "https://docs.pieces.app/products/cli/troubleshooting", + "category": ErrorCategory.TIMEOUT, + }, + + r"MaxRetryError|Max retries exceeded|ConnectionError": { + "title": "Cannot reach Pieces OS", + "reasons": [ + "Pieces OS may not be running", + "Too many failed connection attempts", + "Network issues preventing connection", + ], + "solutions": [ + "Start Pieces OS: `pieces open`", + "Check if Pieces OS is running: `pieces status`", + "Check your network connection", + ], + "help_link": "https://docs.pieces.app/products/cli/troubleshooting", + "category": ErrorCategory.CONNECTION, + }, + + # Authentication errors + r"401|Unauthorized|authentication failed|invalid.*token|UnauthorizedException": { + "title": "Authentication failed", + "reasons": [ + "You may not be signed in", + "Your session may have expired", + "Invalid credentials", + ], + "solutions": [ + "Sign in to Pieces: `pieces login`", + "If already signed in, try signing out and back in: `pieces logout` then `pieces login`", + ], + "help_link": "https://docs.pieces.app/products/cli/commands#login", + "category": ErrorCategory.AUTHENTICATION, + }, + + r"403|Forbidden|ForbiddenException|access denied|permission denied": { + "title": "Access denied", + "reasons": [ + "You don't have permission for this operation", + "The resource may be restricted", + "Your account may have limitations", + ], + "solutions": [ + "Verify you're signed in with the correct account: `pieces login`", + "Check if you have the necessary permissions", + ], + "help_link": "https://docs.pieces.app/products/cli/commands#login", + "category": ErrorCategory.PERMISSION, + }, + + # Not found errors + r"404|Not Found|NotFoundException|does not exist|not found": { + "title": "Resource not found", + "reasons": [ + "The requested item may have been deleted", + "The item ID or name may be incorrect", + "The resource may not exist yet", + ], + "solutions": [ + "Verify the item exists: `pieces list`", + "Check the spelling of the item name or ID", + "Create the resource if it doesn't exist", + ], + "help_link": "https://docs.pieces.app/products/cli/commands#list", + "category": ErrorCategory.NOT_FOUND, + }, + + # Version compatibility errors + r"version.*incompatible|update.*required|too old|CLI.*update|PiecesOS.*update": { + "title": "Version compatibility issue", + "reasons": [ + "Your Pieces CLI version may be outdated", + "Your Pieces OS version may need updating", + "Version mismatch between CLI and Pieces OS", + ], + "solutions": [ + "Update Pieces CLI: `pip install --upgrade pieces-cli`", + "Update Pieces OS from https://pieces.app", + "Check versions: `pieces version`", + ], + "help_link": "https://docs.pieces.app/products/meet-pieces/fundamentals", + "category": ErrorCategory.VERSION, + }, + + # Rate limit errors + r"429|rate limit|too many requests|quota exceeded": { + "title": "Rate limit exceeded", + "reasons": [ + "Too many requests in a short time", + "API quota has been reached", + ], + "solutions": [ + "Wait a few moments before trying again", + "Reduce the frequency of requests", + ], + "help_link": "https://docs.pieces.app/products/cli/troubleshooting", + "category": ErrorCategory.RATE_LIMIT, + }, + + # Server errors + r"500|502|503|504|Internal Server Error|ServiceException|server error": { + "title": "Server error", + "reasons": [ + "Pieces OS encountered an internal error", + "The service may be temporarily unavailable", + "Server overload", + ], + "solutions": [ + "Wait a moment and try again", + "Restart Pieces OS: `pieces restart`", + "Check Pieces status at https://status.pieces.app", + ], + "help_link": "https://docs.pieces.app/products/cli/troubleshooting", + "category": ErrorCategory.SERVER, + }, + + # Permission/File system errors + r"PermissionError|Permission denied|EACCES|access is denied": { + "title": "Permission denied", + "reasons": [ + "Insufficient file system permissions", + "The file or directory may be protected", + "Another process may be using the file", + ], + "solutions": [ + "Check file/folder permissions", + "Run with appropriate permissions", + "Close other applications that may be using the file", + ], + "help_link": "https://docs.pieces.app/products/cli/troubleshooting", + "category": ErrorCategory.PERMISSION, + }, + + # Configuration errors + r"config.*error|invalid.*config|configuration.*failed|FileNotFoundError.*config": { + "title": "Configuration error", + "reasons": [ + "Configuration file may be missing or corrupted", + "Invalid configuration values", + "Configuration file permissions issue", + ], + "solutions": [ + "Reset configuration: `pieces config --reset`", + "Check configuration: `pieces config --show`", + "Verify configuration file permissions", + ], + "help_link": "https://docs.pieces.app/products/cli/configuration", + "category": ErrorCategory.CONFIGURATION, + }, + + # MCP specific errors + r"MCP.*failed|MCP.*error|mcp.*not.*running": { + "title": "MCP server error", + "reasons": [ + "MCP server may not be running", + "MCP configuration may be incorrect", + "Connection to MCP server failed", + ], + "solutions": [ + "Start MCP server: `pieces mcp start`", + "Check MCP status: `pieces mcp status`", + "Repair MCP configuration: `pieces mcp repair`", + ], + "help_link": "https://docs.pieces.app/products/cli/copilot/chat#pieces-mcp", + "category": ErrorCategory.CONNECTION, + }, + + # Git/Commit errors + r"git.*error|not a git repository|commit.*failed": { + "title": "Git operation failed", + "reasons": [ + "Not in a git repository", + "Git is not installed or not in PATH", + "No changes to commit", + ], + "solutions": [ + "Ensure you're in a git repository", + "Initialize a git repository: `git init`", + "Check if there are changes to commit: `git status`", + ], + "help_link": "https://docs.pieces.app/products/cli/commands#commit", + "category": ErrorCategory.CONFIGURATION, + }, +} + + +def _match_error_pattern(error_message: str) -> Optional[Dict]: + """ + Match an error message against known patterns. + + Args: + error_message: The error message to match + + Returns: + The matching error configuration or None + """ + for pattern, config in ERROR_PATTERNS.items(): + if re.search(pattern, error_message, re.IGNORECASE): + return config + return None + + +def get_user_friendly_message( + exception: Exception, + default_help_link: str = "https://docs.pieces.app/products/cli/troubleshooting", +) -> UserFriendlyError: + """ + Transform an exception into a user-friendly error message. + + Args: + exception: The exception to transform + default_help_link: Default help URL if no specific one is found + + Returns: + A UserFriendlyError with helpful information + """ + error_message = str(exception) + exception_type = type(exception).__name__ + + # Combine exception type and message for matching + full_error = f"{exception_type}: {error_message}" + + # Try to match against known patterns + config = _match_error_pattern(full_error) + + if config: + return UserFriendlyError( + title=config["title"], + reasons=config.get("reasons", []), + solutions=config.get("solutions", []), + help_link=config.get("help_link", default_help_link), + category=config.get("category", ErrorCategory.UNKNOWN), + original_error=exception, + technical_details=full_error, + ) + + # Handle specific exception types that might not match patterns + if isinstance(exception, ConnectionRefusedError) or ( + isinstance(exception, OSError) and exception.errno in (61, 111, 10061) + ): + return UserFriendlyError( + title="Cannot connect to Pieces OS", + reasons=[ + "Pieces OS may not be running", + "The service port may be blocked", + ], + solutions=[ + "Ensure Pieces OS is running: `pieces open`", + "Restart Pieces OS: `pieces restart`", + ], + help_link="https://docs.pieces.app/products/cli/troubleshooting", + category=ErrorCategory.CONNECTION, + original_error=exception, + technical_details=full_error, + ) + + if isinstance(exception, TimeoutError) or isinstance(exception, socket.timeout): + return UserFriendlyError( + title="Operation timed out", + reasons=[ + "The operation took too long to complete", + "Network connection may be slow", + ], + solutions=[ + "Try again in a moment", + "Check your network connection", + "Restart Pieces OS: `pieces restart`", + ], + help_link="https://docs.pieces.app/products/cli/troubleshooting", + category=ErrorCategory.TIMEOUT, + original_error=exception, + technical_details=full_error, + ) + + if isinstance(exception, PermissionError): + return UserFriendlyError( + title="Permission denied", + reasons=[ + "Insufficient file system permissions", + "The file or directory may be protected", + ], + solutions=[ + "Check file/folder permissions", + "Run with appropriate permissions", + ], + help_link="https://docs.pieces.app/products/cli/troubleshooting", + category=ErrorCategory.PERMISSION, + original_error=exception, + technical_details=full_error, + ) + + if isinstance(exception, FileNotFoundError): + return UserFriendlyError( + title="File not found", + reasons=[ + "The specified file does not exist", + "The file path may be incorrect", + ], + solutions=[ + "Verify the file path is correct", + "Check if the file exists", + ], + help_link="https://docs.pieces.app/products/cli/troubleshooting", + category=ErrorCategory.NOT_FOUND, + original_error=exception, + technical_details=full_error, + ) + + # Default fallback for unknown errors + return UserFriendlyError( + title="An unexpected error occurred", + reasons=[ + "An internal error occurred", + "This may be a temporary issue", + ], + solutions=[ + "Try again in a moment", + "Restart Pieces OS: `pieces restart`", + "If the problem persists, report it: `pieces feedback`", + ], + help_link=default_help_link, + category=ErrorCategory.UNKNOWN, + original_error=exception, + technical_details=full_error, + ) + + +def format_error( + exception: Exception, + include_technical: bool = False, + default_help_link: str = "https://docs.pieces.app/products/cli/troubleshooting", +) -> str: + """ + Format an exception into a user-friendly error string. + + This is a convenience function that combines get_user_friendly_message + and UserFriendlyError.format_error. + + Args: + exception: The exception to format + include_technical: Whether to include technical details + default_help_link: Default help URL if no specific one is found + + Returns: + A formatted error message string + """ + friendly_error = get_user_friendly_message(exception, default_help_link) + return friendly_error.format_error(include_technical) + + +# Exception type to handler mapping for direct type-based lookups +EXCEPTION_HANDLERS: Dict[Type[Exception], Callable[[Exception], UserFriendlyError]] = {} + + +def register_exception_handler( + exception_type: Type[Exception], +) -> Callable[[Callable[[Exception], UserFriendlyError]], Callable[[Exception], UserFriendlyError]]: + """ + Decorator to register a custom handler for a specific exception type. + + Usage: + @register_exception_handler(MyCustomException) + def handle_my_exception(e: MyCustomException) -> UserFriendlyError: + return UserFriendlyError( + title="My custom error", + reasons=["Custom reason"], + solutions=["Custom solution"], + ) + """ + def decorator(handler: Callable[[Exception], UserFriendlyError]) -> Callable[[Exception], UserFriendlyError]: + EXCEPTION_HANDLERS[exception_type] = handler + return handler + return decorator diff --git a/src/pieces/settings.py b/src/pieces/settings.py index 67a334aa..5f0e3588 100644 --- a/src/pieces/settings.py +++ b/src/pieces/settings.py @@ -129,10 +129,29 @@ def check_login(cls): @classmethod def show_error(cls, error, error_message=None): - cls.logger.console_error.print(f"[red]{error}") - cls.logger.console_error.print( - f"[red]{error_message}" - ) if error_message else None + """Display an error message to the user. + + Args: + error: The error title or Exception object + error_message: Optional additional error message + """ + from pieces.errors import format_error + + # If error is an Exception, format it as user-friendly + if isinstance(error, Exception): + friendly_message = format_error(error, include_technical=False) + cls.logger.console_error.print(f"[red]{friendly_message}[/red]") + else: + # Legacy behavior for string errors + cls.logger.console_error.print(f"[red]{error}[/red]") + if error_message: + # Check if error_message is an Exception for user-friendly formatting + if isinstance(error_message, Exception): + friendly_message = format_error(error_message, include_technical=False) + cls.logger.console_error.print(f"[red]{friendly_message}[/red]") + else: + cls.logger.console_error.print(f"[red]{error_message}[/red]") + if not cls.run_in_loop: sys.exit(2) diff --git a/tests/test_user_friendly_errors.py b/tests/test_user_friendly_errors.py new file mode 100644 index 00000000..b190d033 --- /dev/null +++ b/tests/test_user_friendly_errors.py @@ -0,0 +1,217 @@ +""" +Tests for user-friendly error handling. +""" + +from pieces.errors import ( + UserFriendlyError, + ErrorCategory, + format_error, + get_user_friendly_message, +) + + +class TestUserFriendlyError: + """Tests for the UserFriendlyError class.""" + + def test_basic_error_creation(self): + """Test creating a basic user-friendly error.""" + error = UserFriendlyError( + title="Test error", + reasons=["Reason 1", "Reason 2"], + solutions=["Solution 1", "Solution 2"], + help_link="https://example.com", + category=ErrorCategory.CONNECTION, + ) + + assert error.title == "Test error" + assert len(error.reasons) == 2 + assert len(error.solutions) == 2 + assert error.help_link == "https://example.com" + assert error.category == ErrorCategory.CONNECTION + + def test_format_error_output(self): + """Test that format_error produces expected output structure.""" + error = UserFriendlyError( + title="Cannot connect to Pieces OS", + reasons=["Pieces OS may not be running"], + solutions=["Run: pieces open"], + help_link="https://docs.pieces.app", + ) + + formatted = error.format_error() + + assert "❌ Cannot connect to Pieces OS" in formatted + assert "Possible reasons:" in formatted + assert "• Pieces OS may not be running" in formatted + assert "Try these solutions:" in formatted + assert "1. Run: pieces open" in formatted + assert "📖 More help: https://docs.pieces.app" in formatted + + def test_format_error_with_technical_details(self): + """Test that technical details are included when requested.""" + error = UserFriendlyError( + title="Test error", + technical_details="ConnectionRefusedError: [Errno 61]", + ) + + formatted_without = error.format_error(include_technical=False) + formatted_with = error.format_error(include_technical=True) + + assert "ConnectionRefusedError" not in formatted_without + assert "ConnectionRefusedError" in formatted_with + + def test_str_method(self): + """Test that __str__ returns formatted output.""" + error = UserFriendlyError(title="Test error") + assert str(error) == error.format_error() + + +class TestGetUserFriendlyMessage: + """Tests for the get_user_friendly_message function.""" + + def test_connection_refused_error(self): + """Test handling of ConnectionRefusedError.""" + exception = ConnectionRefusedError("[Errno 61] Connection refused") + friendly = get_user_friendly_message(exception) + + assert "connect" in friendly.title.lower() + assert friendly.category == ErrorCategory.CONNECTION + assert len(friendly.solutions) > 0 + + def test_timeout_error(self): + """Test handling of TimeoutError.""" + exception = TimeoutError("Operation timed out") + friendly = get_user_friendly_message(exception) + + assert "timed out" in friendly.title.lower() + assert friendly.category == ErrorCategory.TIMEOUT + + def test_permission_error(self): + """Test handling of PermissionError.""" + exception = PermissionError("Permission denied") + friendly = get_user_friendly_message(exception) + + assert "denied" in friendly.title.lower() or "permission" in friendly.title.lower() + assert friendly.category == ErrorCategory.PERMISSION + + def test_file_not_found_error(self): + """Test handling of FileNotFoundError.""" + exception = FileNotFoundError("File not found: test.txt") + friendly = get_user_friendly_message(exception) + + assert "not found" in friendly.title.lower() + assert friendly.category == ErrorCategory.NOT_FOUND + + def test_unknown_error_fallback(self): + """Test that unknown errors get a reasonable fallback.""" + exception = Exception("Some unknown error") + friendly = get_user_friendly_message(exception) + + assert friendly.title is not None + assert friendly.category == ErrorCategory.UNKNOWN + assert friendly.original_error == exception + + def test_websocket_error_pattern(self): + """Test WebSocket error pattern matching.""" + exception = Exception("WebSocket connection failed: Connection refused") + friendly = get_user_friendly_message(exception) + + assert friendly.category == ErrorCategory.CONNECTION + assert "websocket" in friendly.title.lower() or "connect" in friendly.title.lower() + + def test_authentication_error_pattern(self): + """Test authentication error pattern matching.""" + exception = Exception("401 Unauthorized: Invalid token") + friendly = get_user_friendly_message(exception) + + assert friendly.category == ErrorCategory.AUTHENTICATION + + def test_rate_limit_error_pattern(self): + """Test rate limit error pattern matching.""" + exception = Exception("429 Too Many Requests: Rate limit exceeded") + friendly = get_user_friendly_message(exception) + + assert friendly.category == ErrorCategory.RATE_LIMIT + + def test_server_error_pattern(self): + """Test server error pattern matching.""" + exception = Exception("500 Internal Server Error") + friendly = get_user_friendly_message(exception) + + assert friendly.category == ErrorCategory.SERVER + + +class TestFormatError: + """Tests for the format_error convenience function.""" + + def test_format_error_convenience(self): + """Test that format_error returns a string.""" + exception = ConnectionRefusedError("Connection refused") + result = format_error(exception) + + assert isinstance(result, str) + assert "❌" in result + + def test_format_error_with_technical(self): + """Test format_error with technical details.""" + exception = ConnectionRefusedError("Connection refused") + result = format_error(exception, include_technical=True) + + assert "Technical details:" in result + + +class TestErrorCategories: + """Tests for error category classification.""" + + def test_all_categories_exist(self): + """Test that all expected categories exist.""" + categories = [ + ErrorCategory.CONNECTION, + ErrorCategory.AUTHENTICATION, + ErrorCategory.PERMISSION, + ErrorCategory.NOT_FOUND, + ErrorCategory.TIMEOUT, + ErrorCategory.VERSION, + ErrorCategory.CONFIGURATION, + ErrorCategory.RATE_LIMIT, + ErrorCategory.SERVER, + ErrorCategory.UNKNOWN, + ] + + for category in categories: + assert category.value is not None + + +class TestRealWorldScenarios: + """Integration-style tests for real-world error scenarios.""" + + def test_pieces_os_not_running_scenario(self): + """Test the scenario when Pieces OS is not running.""" + # Simulate the error that occurs when Pieces OS isn't running + exception = ConnectionRefusedError("[Errno 61] Connection refused") + formatted = format_error(exception) + + # Should mention Pieces OS + assert "pieces" in formatted.lower() or "connect" in formatted.lower() + # Should suggest running pieces open + assert "pieces" in formatted.lower() + + def test_websocket_connection_failed_scenario(self): + """Test the WebSocket connection failed scenario from the issue.""" + # This is the exact error type mentioned in the GitHub issue + exception = Exception("WebSocket connection failed: [Errno 61] Connection refused") + formatted = format_error(exception) + + # Verify it produces the expected user-friendly output + assert "❌" in formatted + assert "Possible reasons:" in formatted + assert "Try these solutions:" in formatted + assert "📖 More help:" in formatted + + def test_max_retry_error_scenario(self): + """Test MaxRetryError scenario.""" + exception = Exception("MaxRetryError: Max retries exceeded with url") + formatted = format_error(exception) + + assert "❌" in formatted + assert len(formatted) > 50 # Should have substantial content From 249fc4ea66e3733e8c14a7d0e5910774f41b6b16 Mon Sep 17 00:00:00 2001 From: Sree Siddhartha Date: Sat, 10 Jan 2026 04:23:22 +0530 Subject: [PATCH 2/3] fix: address Copilot review comments - reorder isinstance checks before pattern matching - use getattr for safer errno access - remove unused EXCEPTION_HANDLERS code --- src/pieces/errors/user_friendly.py | 68 +++++++++++++----------------- 1 file changed, 30 insertions(+), 38 deletions(-) diff --git a/src/pieces/errors/user_friendly.py b/src/pieces/errors/user_friendly.py index 0ea7f83e..3c66f109 100644 --- a/src/pieces/errors/user_friendly.py +++ b/src/pieces/errors/user_friendly.py @@ -7,7 +7,7 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Optional, List, Dict, Type, Callable +from typing import Optional, List, Dict import re import socket @@ -365,24 +365,27 @@ def get_user_friendly_message( # Combine exception type and message for matching full_error = f"{exception_type}: {error_message}" - # Try to match against known patterns - config = _match_error_pattern(full_error) + # Check specific exception types FIRST (before pattern matching) + # This ensures we get the most specific handler for known exception types - if config: + if isinstance(exception, ConnectionRefusedError): return UserFriendlyError( - title=config["title"], - reasons=config.get("reasons", []), - solutions=config.get("solutions", []), - help_link=config.get("help_link", default_help_link), - category=config.get("category", ErrorCategory.UNKNOWN), + title="Cannot connect to Pieces OS", + reasons=[ + "Pieces OS may not be running", + "The service port may be blocked", + ], + solutions=[ + "Ensure Pieces OS is running: `pieces open`", + "Restart Pieces OS: `pieces restart`", + ], + help_link="https://docs.pieces.app/products/cli/troubleshooting", + category=ErrorCategory.CONNECTION, original_error=exception, technical_details=full_error, ) - # Handle specific exception types that might not match patterns - if isinstance(exception, ConnectionRefusedError) or ( - isinstance(exception, OSError) and exception.errno in (61, 111, 10061) - ): + if isinstance(exception, OSError) and getattr(exception, "errno", None) in (61, 111, 10061): return UserFriendlyError( title="Cannot connect to Pieces OS", reasons=[ @@ -451,6 +454,20 @@ def get_user_friendly_message( technical_details=full_error, ) + # Try to match against known patterns for other errors + config = _match_error_pattern(full_error) + + if config: + return UserFriendlyError( + title=config["title"], + reasons=config.get("reasons", []), + solutions=config.get("solutions", []), + help_link=config.get("help_link", default_help_link), + category=config.get("category", ErrorCategory.UNKNOWN), + original_error=exception, + technical_details=full_error, + ) + # Default fallback for unknown errors return UserFriendlyError( title="An unexpected error occurred", @@ -491,28 +508,3 @@ def format_error( """ friendly_error = get_user_friendly_message(exception, default_help_link) return friendly_error.format_error(include_technical) - - -# Exception type to handler mapping for direct type-based lookups -EXCEPTION_HANDLERS: Dict[Type[Exception], Callable[[Exception], UserFriendlyError]] = {} - - -def register_exception_handler( - exception_type: Type[Exception], -) -> Callable[[Callable[[Exception], UserFriendlyError]], Callable[[Exception], UserFriendlyError]]: - """ - Decorator to register a custom handler for a specific exception type. - - Usage: - @register_exception_handler(MyCustomException) - def handle_my_exception(e: MyCustomException) -> UserFriendlyError: - return UserFriendlyError( - title="My custom error", - reasons=["Custom reason"], - solutions=["Custom solution"], - ) - """ - def decorator(handler: Callable[[Exception], UserFriendlyError]) -> Callable[[Exception], UserFriendlyError]: - EXCEPTION_HANDLERS[exception_type] = handler - return handler - return decorator From f3ce0af4f4f01b657e447fb491cfb2fe6e70998b Mon Sep 17 00:00:00 2001 From: Sree Siddhartha Date: Sat, 10 Jan 2026 16:54:54 +0530 Subject: [PATCH 3/3] refactor: use URLs enum instead of hardcoded URLs Replace hardcoded documentation URLs with references to the URLs enum class as requested by maintainer. This ensures URLs are centralized and can be updated in one place. --- src/pieces/errors/user_friendly.py | 50 +++++++++++++++++------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/src/pieces/errors/user_friendly.py b/src/pieces/errors/user_friendly.py index 3c66f109..0a67bb53 100644 --- a/src/pieces/errors/user_friendly.py +++ b/src/pieces/errors/user_friendly.py @@ -11,6 +11,8 @@ import re import socket +from pieces.urls import URLs + class ErrorCategory(Enum): """Categories of errors for classification.""" @@ -110,7 +112,7 @@ def __str__(self) -> str: "Check if Pieces OS is starting: `pieces status`", "Restart Pieces OS: `pieces restart`", ], - "help_link": "https://docs.pieces.app/products/cli/troubleshooting", + "help_link": URLs.CLI_HELP_DOCS.value, "category": ErrorCategory.CONNECTION, }, @@ -126,7 +128,7 @@ def __str__(self) -> str: "Check your network connection", "Try restarting Pieces OS: `pieces restart`", ], - "help_link": "https://docs.pieces.app/products/cli/troubleshooting", + "help_link": URLs.CLI_HELP_DOCS.value, "category": ErrorCategory.CONNECTION, }, @@ -142,7 +144,7 @@ def __str__(self) -> str: "Check your network connection", "Restart Pieces OS: `pieces restart`", ], - "help_link": "https://docs.pieces.app/products/cli/troubleshooting", + "help_link": URLs.CLI_HELP_DOCS.value, "category": ErrorCategory.TIMEOUT, }, @@ -158,7 +160,7 @@ def __str__(self) -> str: "Check if Pieces OS is running: `pieces status`", "Check your network connection", ], - "help_link": "https://docs.pieces.app/products/cli/troubleshooting", + "help_link": URLs.CLI_HELP_DOCS.value, "category": ErrorCategory.CONNECTION, }, @@ -174,7 +176,7 @@ def __str__(self) -> str: "Sign in to Pieces: `pieces login`", "If already signed in, try signing out and back in: `pieces logout` then `pieces login`", ], - "help_link": "https://docs.pieces.app/products/cli/commands#login", + "help_link": URLs.CLI_LOGIN_DOCS.value, "category": ErrorCategory.AUTHENTICATION, }, @@ -189,7 +191,7 @@ def __str__(self) -> str: "Verify you're signed in with the correct account: `pieces login`", "Check if you have the necessary permissions", ], - "help_link": "https://docs.pieces.app/products/cli/commands#login", + "help_link": URLs.CLI_LOGIN_DOCS.value, "category": ErrorCategory.PERMISSION, }, @@ -206,7 +208,7 @@ def __str__(self) -> str: "Check the spelling of the item name or ID", "Create the resource if it doesn't exist", ], - "help_link": "https://docs.pieces.app/products/cli/commands#list", + "help_link": URLs.CLI_LIST_DOCS.value, "category": ErrorCategory.NOT_FOUND, }, @@ -223,7 +225,7 @@ def __str__(self) -> str: "Update Pieces OS from https://pieces.app", "Check versions: `pieces version`", ], - "help_link": "https://docs.pieces.app/products/meet-pieces/fundamentals", + "help_link": URLs.DOCS_INSTALLATION.value, "category": ErrorCategory.VERSION, }, @@ -238,7 +240,7 @@ def __str__(self) -> str: "Wait a few moments before trying again", "Reduce the frequency of requests", ], - "help_link": "https://docs.pieces.app/products/cli/troubleshooting", + "help_link": URLs.CLI_HELP_DOCS.value, "category": ErrorCategory.RATE_LIMIT, }, @@ -255,7 +257,7 @@ def __str__(self) -> str: "Restart Pieces OS: `pieces restart`", "Check Pieces status at https://status.pieces.app", ], - "help_link": "https://docs.pieces.app/products/cli/troubleshooting", + "help_link": URLs.CLI_HELP_DOCS.value, "category": ErrorCategory.SERVER, }, @@ -272,7 +274,7 @@ def __str__(self) -> str: "Run with appropriate permissions", "Close other applications that may be using the file", ], - "help_link": "https://docs.pieces.app/products/cli/troubleshooting", + "help_link": URLs.CLI_HELP_DOCS.value, "category": ErrorCategory.PERMISSION, }, @@ -289,7 +291,7 @@ def __str__(self) -> str: "Check configuration: `pieces config --show`", "Verify configuration file permissions", ], - "help_link": "https://docs.pieces.app/products/cli/configuration", + "help_link": URLs.CLI_CONFIG_DOCS.value, "category": ErrorCategory.CONFIGURATION, }, @@ -306,7 +308,7 @@ def __str__(self) -> str: "Check MCP status: `pieces mcp status`", "Repair MCP configuration: `pieces mcp repair`", ], - "help_link": "https://docs.pieces.app/products/cli/copilot/chat#pieces-mcp", + "help_link": URLs.CLI_MCP_DOCS.value, "category": ErrorCategory.CONNECTION, }, @@ -323,7 +325,7 @@ def __str__(self) -> str: "Initialize a git repository: `git init`", "Check if there are changes to commit: `git status`", ], - "help_link": "https://docs.pieces.app/products/cli/commands#commit", + "help_link": URLs.CLI_COMMIT_DOCS.value, "category": ErrorCategory.CONFIGURATION, }, } @@ -347,7 +349,7 @@ def _match_error_pattern(error_message: str) -> Optional[Dict]: def get_user_friendly_message( exception: Exception, - default_help_link: str = "https://docs.pieces.app/products/cli/troubleshooting", + default_help_link: str = None, ) -> UserFriendlyError: """ Transform an exception into a user-friendly error message. @@ -368,6 +370,10 @@ def get_user_friendly_message( # Check specific exception types FIRST (before pattern matching) # This ensures we get the most specific handler for known exception types + # Use default help link if not provided + if default_help_link is None: + default_help_link = URLs.CLI_HELP_DOCS.value + if isinstance(exception, ConnectionRefusedError): return UserFriendlyError( title="Cannot connect to Pieces OS", @@ -379,7 +385,7 @@ def get_user_friendly_message( "Ensure Pieces OS is running: `pieces open`", "Restart Pieces OS: `pieces restart`", ], - help_link="https://docs.pieces.app/products/cli/troubleshooting", + help_link=URLs.CLI_HELP_DOCS.value, category=ErrorCategory.CONNECTION, original_error=exception, technical_details=full_error, @@ -396,7 +402,7 @@ def get_user_friendly_message( "Ensure Pieces OS is running: `pieces open`", "Restart Pieces OS: `pieces restart`", ], - help_link="https://docs.pieces.app/products/cli/troubleshooting", + help_link=URLs.CLI_HELP_DOCS.value, category=ErrorCategory.CONNECTION, original_error=exception, technical_details=full_error, @@ -414,7 +420,7 @@ def get_user_friendly_message( "Check your network connection", "Restart Pieces OS: `pieces restart`", ], - help_link="https://docs.pieces.app/products/cli/troubleshooting", + help_link=URLs.CLI_HELP_DOCS.value, category=ErrorCategory.TIMEOUT, original_error=exception, technical_details=full_error, @@ -431,7 +437,7 @@ def get_user_friendly_message( "Check file/folder permissions", "Run with appropriate permissions", ], - help_link="https://docs.pieces.app/products/cli/troubleshooting", + help_link=URLs.CLI_HELP_DOCS.value, category=ErrorCategory.PERMISSION, original_error=exception, technical_details=full_error, @@ -448,7 +454,7 @@ def get_user_friendly_message( "Verify the file path is correct", "Check if the file exists", ], - help_link="https://docs.pieces.app/products/cli/troubleshooting", + help_link=URLs.CLI_HELP_DOCS.value, category=ErrorCategory.NOT_FOUND, original_error=exception, technical_details=full_error, @@ -490,7 +496,7 @@ def get_user_friendly_message( def format_error( exception: Exception, include_technical: bool = False, - default_help_link: str = "https://docs.pieces.app/products/cli/troubleshooting", + default_help_link: str = None, ) -> str: """ Format an exception into a user-friendly error string. @@ -506,5 +512,7 @@ def format_error( Returns: A formatted error message string """ + if default_help_link is None: + default_help_link = URLs.CLI_HELP_DOCS.value friendly_error = get_user_friendly_message(exception, default_help_link) return friendly_error.format_error(include_technical)