Coverage for src/lexigram/graphql/core/error_formatter.py: 96%

45 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 04:37 +0800

1"""Error formatting for GraphQL responses. 

2 

3This module provides error masking and formatting based on configuration. 

4""" 

5 

6from __future__ import annotations 

7 

8import traceback 

9from typing import Any 

10 

11from lexigram.graphql.config import ErrorConfig 

12from lexigram.graphql.exceptions import GraphQLError 

13 

14 

15def _is_safe_error(error: Exception) -> bool: 

16 """Check if an error is safe to show to users. 

17 

18 Args: 

19 error: The error to check. 

20 

21 Returns: 

22 True if the error message can be safely shown to users. 

23 """ 

24 # Check for safe attribute on GraphQLError subclasses 

25 if isinstance(error, GraphQLError): 

26 return getattr(error, "safe", False) 

27 return False 

28 

29 

30class ErrorFormatter: 

31 """Format GraphQL errors for client consumption. 

32 

33 Applies masking rules based on ErrorConfig to prevent 

34 internal errors from leaking to clients in production. 

35 """ 

36 

37 def __init__(self, config: ErrorConfig): 

38 """Initialize the error formatter. 

39 

40 Args: 

41 config: Error configuration. 

42 """ 

43 self._config = config 

44 

45 def format_error( 

46 self, 

47 error: Exception, 

48 ) -> dict[str, Any]: 

49 """Format an error for the response. 

50 

51 Args: 

52 error: The exception to format (GraphQLError or Strawberry ErrorExtension). 

53 

54 Returns: 

55 Formatted error dictionary. 

56 """ 

57 # Handle Strawberry's ErrorExtension type 

58 if hasattr(error, "message"): 

59 message = str(error.message) 

60 elif isinstance(error, GraphQLError): 

61 message = error.message 

62 else: 

63 message = str(error) 

64 

65 result: dict[str, Any] = {"message": message} 

66 

67 # Add extensions for error code if available 

68 if isinstance(error, GraphQLError): 

69 extensions: dict[str, Any] = {} 

70 

71 if hasattr(error, "code") and error.code: 

72 extensions["code"] = error.code 

73 

74 # Add details if available 

75 if hasattr(error, "details") and error.details: 

76 extensions["details"] = error.details 

77 

78 # Add hint if available 

79 if hasattr(error, "hint") and error.hint: 

80 extensions["hint"] = error.hint 

81 

82 if extensions: 

83 result["extensions"] = extensions 

84 elif hasattr(error, "extensions") and error.extensions: 

85 result["extensions"] = error.extensions 

86 

87 # Mask internal errors in production (unless error is marked as safe) 

88 if self._config.mask_errors and not _is_safe_error(error): 

89 result["message"] = "Internal server error" 

90 # Clear extensions that might leak info 

91 if "extensions" in result: 

92 result["extensions"] = {"code": "INTERNAL_ERROR"} 

93 

94 # Include stacktrace only in debug mode 

95 if self._config.include_stacktrace and self._config.debug_mode: 

96 if "extensions" not in result: 

97 result["extensions"] = {} 

98 result["extensions"]["stacktrace"] = traceback.format_exception( 

99 type(error), 

100 error, 

101 error.__traceback__, 

102 ) 

103 

104 return result 

105 

106 def format_errors( 

107 self, 

108 errors: list[Exception], 

109 ) -> list[dict[str, Any]]: 

110 """Format multiple errors for the response. 

111 

112 Args: 

113 errors: List of exceptions to format. 

114 

115 Returns: 

116 List of formatted error dictionaries. 

117 """ 

118 return [self.format_error(error) for error in errors] 

119 

120 

121# Default error formatter factory 

122def create_error_formatter(config: ErrorConfig | None = None) -> ErrorFormatter: 

123 """Create an ErrorFormatter with the given config. 

124 

125 Args: 

126 config: Error configuration. Uses defaults if None. 

127 

128 Returns: 

129 Configured ErrorFormatter. 

130 """ 

131 return ErrorFormatter(config or ErrorConfig()) 

132 

133 

134__all__ = [ 

135 "ErrorFormatter", 

136 "create_error_formatter", 

137]