File size: 11,028 Bytes
5301c48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
import functools
import os
import traceback
import uuid
from typing import Any, Dict, Optional, Tuple

from pydantic import BaseModel, Field, ValidationError

from starfish.common.logger import get_logger

logger = get_logger(__name__)

# Simple configuration flag (can be set from app config)
# Default to False for production safety
INCLUDE_TRACEBACK_IN_RESPONSE = os.environ.get("INCLUDE_TRACEBACK_IN_RESPONSE", False)

#############################################
# HTTP Status Codes
#############################################


class HTTPStatus:
    """Standard HTTP status codes."""

    OK = 200
    BAD_REQUEST = 400
    UNAUTHORIZED = 401
    FORBIDDEN = 403
    NOT_FOUND = 404
    UNPROCESSABLE_ENTITY = 422
    INTERNAL_SERVER_ERROR = 500


#############################################
# Error Response Model
#############################################


class ErrorResponse(BaseModel):
    """Standardized error response format for API errors."""

    status: str = "error"
    error_id: str = Field(..., description="Unique identifier for this error occurrence")
    message: str
    error_type: str
    details: Optional[Dict[str, Any]] = None


#############################################
# Exception Classes
#############################################


class StarfishException(Exception):
    """Base exception for all Starfish exceptions."""

    status_code: int = HTTPStatus.INTERNAL_SERVER_ERROR
    default_message: str = "An unexpected error occurred"

    def __init__(self, message: Optional[str] = None, details: Optional[Dict[str, Any]] = None):
        self.message = message or self.default_message
        self.details = details
        self.error_id = str(uuid.uuid4())
        super().__init__(self.message)

    def __str__(self):
        if self.details:
            return f"{self.message} - Details: {self.details}"
        return self.message


class ValidationError(StarfishException):
    """Exception raised for validation errors."""

    status_code = HTTPStatus.UNPROCESSABLE_ENTITY
    default_message = "Validation error"


class PydanticValidationError(ValidationError):
    """Exception raised for Pydantic validation errors.

    This class formats Pydantic validation errors into user-friendly messages
    and preserves the detailed error information for debugging.
    """

    default_message = "Data validation error"

    @staticmethod
    def format_validation_error(error: ValidationError) -> Tuple[str, Dict[str, Any]]:
        """Format a Pydantic ValidationError into a user-friendly message and details.

        Args:
            error: The Pydantic ValidationError to format

        Returns:
            Tuple of (message, details)
        """
        if not hasattr(error, "errors") or not callable(getattr(error, "errors", None)):
            return str(error), {}

        error_details = error.errors()
        if not error_details:
            return "Validation error", {}

        # Format fields with errors
        field_errors = []
        for err in error_details:
            # Get error type and location
            err_type = err.get("type", "unknown")
            loc = err.get("loc", [])

            # Special handling for discriminated unions
            # If first element is a string and subsequent elements exist, might be a discriminated union
            if len(loc) >= 2 and isinstance(loc[0], str) and isinstance(loc[1], str):
                # This might be a discriminated union error like ['vanilla', 'user_input']
                type_name = loc[0]
                field_name = loc[1]

                # Handle errors differently based on type
                if err_type == "missing":
                    field_errors.append(f"Field '{field_name}' is required for '{type_name}' type")
                    continue

            # Standard handling for other errors
            loc_str = ".".join(str(item) for item in loc) if loc else "unknown"
            msg = err.get("msg", "")

            # Create a user-friendly error message based on error type
            if err_type == "missing":
                field_errors.append(f"'{loc_str}' is required")
            elif err_type == "type_error":
                field_errors.append(f"'{loc_str}' has an invalid type")
            elif err_type == "value_error":
                field_errors.append(f"'{loc_str}' has an invalid value")
            elif err_type.startswith("value_error"):
                field_errors.append(f"'{loc_str}' {msg}")
            elif err_type.startswith("type_error"):
                field_errors.append(f"'{loc_str}' {msg}")
            elif err_type == "extra_forbidden":
                field_errors.append(f"'{loc_str}' is not allowed")
            else:
                field_errors.append(f"'{loc_str}': {msg}")

        # Create a combined message
        if len(field_errors) == 1:
            message = f"Validation error: {field_errors[0]}"
        else:
            message = f"Validation errors: {', '.join(field_errors)}"

        return message, {"validation_errors": error_details}

    def __init__(self, validation_error: ValidationError, message: Optional[str] = None, details: Optional[Dict[str, Any]] = None):
        # Format the validation error if no message is provided
        if message is None:
            message, error_details = self.format_validation_error(validation_error)

            # Merge error details with provided details
            if details is None:
                details = error_details
            else:
                details = {**details, **error_details}

        super().__init__(message=message, details=details)


class ParserError(StarfishException):
    """Base exception for all parser-related errors."""

    status_code = HTTPStatus.UNPROCESSABLE_ENTITY
    default_message = "Parser error"


class JsonParserError(ParserError):
    """Exception raised when JSON parsing fails."""

    default_message = "JSON parsing error"


class SchemaValidationError(ParserError):
    """Exception raised when data doesn't conform to schema."""

    default_message = "Schema validation error"

    def __str__(self):
        if self.details and "errors" in self.details:
            errors_text = "\n".join([f"- {err}" for err in self.details["errors"]])
            return f"{self.message}:\n{errors_text}"
        return super().__str__()


class PydanticParserError(ParserError):
    """Exception raised when Pydantic parsing or validation fails."""

    default_message = "Pydantic parsing error"


#############################################
# Error Handling Functions
#############################################


def format_error(exc: Exception, include_traceback: bool = INCLUDE_TRACEBACK_IN_RESPONSE) -> Tuple[ErrorResponse, int]:
    """Format an exception into a standardized error response.

    Args:
        exc: The exception to format
        include_traceback: Whether to include traceback in the response details

    Returns:
        Tuple of (error_response, status_code)
    """
    # Get traceback for logging (always) - may optionally include in response
    tb_str = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))

    # Check for exception chaining
    cause = getattr(exc, "__cause__", None)
    cause_tb = None
    if cause:
        cause_tb = "".join(traceback.format_exception(type(cause), cause, cause.__traceback__))
        logger.error(f"Original exception: {type(cause).__name__}: {str(cause)}")
        logger.error(f"Original traceback: {cause_tb}")

    # Log the current exception
    logger.error(f"Exception: {type(exc).__name__}: {str(exc)}")
    logger.error(f"Traceback: {tb_str}")

    # Handle Starfish exceptions
    if isinstance(exc, StarfishException):
        error_id = getattr(exc, "error_id", str(uuid.uuid4()))
        status_code = exc.status_code
        details = exc.details or {}

        # Only add traceback to details if requested
        if include_traceback:
            details["traceback"] = tb_str
            if cause_tb:
                details["original_traceback"] = cause_tb

        return ErrorResponse(error_id=error_id, message=exc.message, error_type=type(exc).__name__, details=details if details else None), status_code

    # Handle Pydantic validation errors
    elif isinstance(exc, ValidationError):
        error_id = str(uuid.uuid4())
        status_code = HTTPStatus.UNPROCESSABLE_ENTITY
        details = {"validation_errors": exc.errors()}

        if include_traceback:
            details["traceback"] = tb_str
            if cause_tb:
                details["original_traceback"] = cause_tb

        return ErrorResponse(error_id=error_id, message="Validation error", error_type="ValidationError", details=details), status_code

    # Handle all other exceptions
    else:
        error_id = str(uuid.uuid4())
        status_code = HTTPStatus.INTERNAL_SERVER_ERROR
        details = {}

        if include_traceback:
            details["traceback"] = tb_str
            if cause_tb:
                details["original_traceback"] = cause_tb

        return ErrorResponse(
            error_id=error_id, message=str(exc) or "An unexpected error occurred", error_type=type(exc).__name__, details=details if details else None
        ), status_code


#############################################
# Utility Decorators
#############################################


def handle_exceptions(return_value=None):
    """Decorator to handle exceptions in both async and sync functions.

    This decorator can be used with any function to catch exceptions,
    log them, and return a default value instead of raising.

    Args:
        return_value: The value to return if an exception occurs

    Returns:
        Decorated function with exception handling
    """

    def decorator(func):
        # Import asyncio here to avoid dependency if not needed
        try:
            import asyncio

            is_async_available = True
        except ImportError:
            is_async_available = False

        # Handle async functions
        if is_async_available and asyncio.iscoroutinefunction(func):

            @functools.wraps(func)
            async def async_wrapper(*args, **kwargs):
                try:
                    return await func(*args, **kwargs)
                except Exception as exc:
                    # Format and log the error but don't raise
                    format_error(exc, include_traceback=True)
                    return return_value

            return async_wrapper

        # Handle synchronous functions
        else:

            @functools.wraps(func)
            def sync_wrapper(*args, **kwargs):
                try:
                    return func(*args, **kwargs)
                except Exception as exc:
                    # Format and log the error but don't raise
                    format_error(exc, include_traceback=True)
                    return return_value

            return sync_wrapper

    return decorator