Skip to content

Commit ed7fa8c

Browse files
Merge pull request #58 from Mohammadreza-kh94/feature/grpc-exception-interceptor
Feature/grpc exception interceptor
2 parents 8a75475 + b073491 commit ed7fa8c

18 files changed

Lines changed: 421 additions & 127 deletions

archipy/configs/base_config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
StarRocksSQLAlchemyConfig,
3232
)
3333
from archipy.configs.environment_type import EnvironmentType
34+
from archipy.models.types import LanguageType
3435

3536
"""
3637
@@ -171,6 +172,7 @@ def settings_customise_sources(
171172
STARROCKS_SQLALCHEMY: StarRocksSQLAlchemyConfig = StarRocksSQLAlchemyConfig()
172173
POSTGRES_SQLALCHEMY: PostgresSQLAlchemyConfig = PostgresSQLAlchemyConfig()
173174
SQLITE_SQLALCHEMY: SQLiteSQLAlchemyConfig = SQLiteSQLAlchemyConfig()
175+
LANGUAGE: LanguageType = LanguageType.FA
174176

175177
def customize(self) -> None:
176178
"""Customize configuration after loading.

archipy/helpers/decorators/retry.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from typing import Any, TypeVar, cast
55

66
from archipy.models.errors import ResourceExhaustedError
7-
from archipy.models.types.language_type import LanguageType
87

98
# Define a type variable for the return type of the decorated function
109
F = TypeVar("F", bound=Callable[..., Any])
@@ -16,7 +15,6 @@ def retry_decorator(
1615
retry_on: tuple[type[Exception], ...] | None = None,
1716
ignore: tuple[type[Exception], ...] | None = None,
1817
resource_type: str | None = None,
19-
lang: LanguageType = LanguageType.FA,
2018
) -> Callable[[F], F]:
2119
"""A decorator that retries a function when it raises an exception.
2220
@@ -75,7 +73,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:
7573
time.sleep(delay)
7674
continue
7775
return result
78-
raise ResourceExhaustedError(resource_type=resource_type, lang=lang)
76+
raise ResourceExhaustedError(resource_type=resource_type)
7977

8078
return cast(F, wrapper)
8179

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Exception handling interceptors for gRPC services."""
2+
3+
from .server_interceptor import AsyncGrpcServerExceptionInterceptor, GrpcServerExceptionInterceptor
4+
5+
__all__ = [
6+
"GrpcServerExceptionInterceptor",
7+
"AsyncGrpcServerExceptionInterceptor",
8+
]
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
from collections.abc import Callable
2+
3+
import grpc
4+
from pydantic import ValidationError
5+
6+
from archipy.helpers.interceptors.grpc.base.server_interceptor import (
7+
BaseAsyncGrpcServerInterceptor,
8+
BaseGrpcServerInterceptor,
9+
MethodName,
10+
)
11+
from archipy.helpers.utils.base_utils import BaseUtils
12+
from archipy.models.errors import InternalError, InvalidArgumentError
13+
from archipy.models.errors.base_error import BaseError
14+
15+
16+
class GrpcServerExceptionInterceptor(BaseGrpcServerInterceptor):
17+
"""A sync gRPC server interceptor for centralized exception handling.
18+
19+
This interceptor catches all exceptions thrown by gRPC service methods and
20+
converts them to appropriate gRPC errors, eliminating the need for repetitive
21+
try-catch blocks in each service method.
22+
"""
23+
24+
def intercept(
25+
self, method: Callable, request: object, context: grpc.ServicerContext, method_name_model: MethodName
26+
) -> object:
27+
"""Intercepts a sync gRPC server call and handles exceptions.
28+
29+
Args:
30+
method: The sync gRPC method being intercepted.
31+
request: The request object passed to the method.
32+
context: The context of the sync gRPC call.
33+
method_name_model: The parsed method name containing package, service, and method components.
34+
35+
Returns:
36+
object: The result of the intercepted gRPC method.
37+
38+
Note:
39+
This method will not return anything if an exception is handled,
40+
as the exception handling will abort the gRPC context.
41+
"""
42+
try:
43+
# Execute the gRPC method
44+
result = method(request, context)
45+
46+
except ValidationError as validation_error:
47+
BaseUtils.capture_exception(validation_error)
48+
self._handle_validation_error(validation_error, context)
49+
50+
except BaseError as base_error:
51+
BaseUtils.capture_exception(base_error)
52+
base_error.abort_grpc_sync(context)
53+
54+
except Exception as unexpected_error:
55+
BaseUtils.capture_exception(unexpected_error)
56+
self._handle_unexpected_error(unexpected_error, context, method_name_model)
57+
else:
58+
return result
59+
60+
@staticmethod
61+
def _handle_validation_error(validation_error: ValidationError, context: grpc.ServicerContext) -> None:
62+
"""Handle Pydantic validation errors.
63+
64+
Args:
65+
validation_error: The validation error to handle.
66+
context: The gRPC context to abort.
67+
"""
68+
# Format validation errors for better debugging
69+
validation_details = BaseUtils.format_validation_errors(validation_error, include_type=True)
70+
71+
InvalidArgumentError(
72+
argument_name="request_validation",
73+
additional_data={"validation_errors": validation_details, "error_count": len(validation_error.errors())},
74+
).abort_grpc_sync(context)
75+
76+
@staticmethod
77+
def _handle_unexpected_error(
78+
error: Exception, context: grpc.ServicerContext, method_name_model: MethodName
79+
) -> None:
80+
"""Handle unexpected errors by converting them to internal errors.
81+
82+
Args:
83+
error: The unexpected error to handle.
84+
context: The gRPC context to abort.
85+
method_name_model: The method name information for better error tracking.
86+
"""
87+
# Capture the exception for monitoring
88+
InternalError(
89+
additional_data={
90+
"original_error": str(error),
91+
"error_type": type(error).__name__,
92+
"service": method_name_model.service,
93+
"method": method_name_model.method,
94+
"package": method_name_model.package,
95+
}
96+
).abort_grpc_sync(context)
97+
98+
@staticmethod
99+
def _format_validation_errors(validation_error: ValidationError) -> list[dict[str, str]]:
100+
"""Format Pydantic validation errors into a structured format.
101+
102+
Args:
103+
validation_error: The validation error to format.
104+
105+
Returns:
106+
A list of formatted validation error details.
107+
108+
Note:
109+
This method is deprecated. Use BaseUtils.format_validation_errors instead.
110+
"""
111+
return BaseUtils.format_validation_errors(validation_error, include_type=True)
112+
113+
114+
class AsyncGrpcServerExceptionInterceptor(BaseAsyncGrpcServerInterceptor):
115+
"""An async gRPC server interceptor for centralized exception handling.
116+
117+
This interceptor catches all exceptions thrown by gRPC service methods and
118+
converts them to appropriate gRPC errors, eliminating the need for repetitive
119+
try-catch blocks in each service method.
120+
"""
121+
122+
async def intercept(
123+
self, method: Callable, request: object, context: grpc.aio.ServicerContext, method_name_model: MethodName
124+
) -> object:
125+
"""Intercepts an async gRPC server call and handles exceptions.
126+
127+
Args:
128+
method: The async gRPC method being intercepted.
129+
request: The request object passed to the method.
130+
context: The context of the async gRPC call.
131+
method_name_model: The parsed method name containing package, service, and method components.
132+
133+
Returns:
134+
object: The result of the intercepted gRPC method.
135+
136+
Note:
137+
This method will not return anything if an exception is handled,
138+
as the exception handling will abort the gRPC context.
139+
"""
140+
try:
141+
# Execute the gRPC method
142+
result = await method(request, context)
143+
144+
except ValidationError as validation_error:
145+
BaseUtils.capture_exception(validation_error)
146+
await self._handle_validation_error(validation_error, context)
147+
148+
except BaseError as base_error:
149+
BaseUtils.capture_exception(base_error)
150+
await base_error.abort_grpc_async(context)
151+
152+
except Exception as unexpected_error:
153+
BaseUtils.capture_exception(unexpected_error)
154+
await self._handle_unexpected_error(unexpected_error, context, method_name_model)
155+
else:
156+
return result
157+
158+
@staticmethod
159+
async def _handle_validation_error(validation_error: ValidationError, context: grpc.aio.ServicerContext) -> None:
160+
"""Handle Pydantic validation errors.
161+
162+
Args:
163+
validation_error: The validation error to handle.
164+
context: The gRPC context to abort.
165+
"""
166+
# Format validation errors for better debugging
167+
validation_details = BaseUtils.format_validation_errors(validation_error, include_type=True)
168+
169+
await InvalidArgumentError(
170+
argument_name="request_validation",
171+
additional_data={"validation_errors": validation_details, "error_count": len(validation_error.errors())},
172+
).abort_grpc_async(context)
173+
174+
@staticmethod
175+
async def _handle_unexpected_error(
176+
error: Exception, context: grpc.aio.ServicerContext, method_name_model: MethodName
177+
) -> None:
178+
"""Handle unexpected errors by converting them to internal errors.
179+
180+
Args:
181+
error: The unexpected error to handle.
182+
context: The gRPC context to abort.
183+
method_name_model: The method name information for better error tracking.
184+
"""
185+
# Capture the exception for monitoring
186+
await InternalError(
187+
additional_data={
188+
"original_error": str(error),
189+
"error_type": type(error).__name__,
190+
"service": method_name_model.service,
191+
"method": method_name_model.method,
192+
"package": method_name_model.package,
193+
}
194+
).abort_grpc_async(context)
195+
196+
@staticmethod
197+
def _format_validation_errors(validation_error: ValidationError) -> list[dict[str, str]]:
198+
"""Format Pydantic validation errors into a structured format.
199+
200+
Args:
201+
validation_error: The validation error to format.
202+
203+
Returns:
204+
A list of formatted validation error details.
205+
206+
Note:
207+
This method is deprecated. Use BaseUtils.format_validation_errors instead.
208+
"""
209+
return BaseUtils.format_validation_errors(validation_error, include_type=True)

archipy/helpers/utils/app_utils.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ def create_error_response(exception: BaseError) -> JSONResponse:
3939
BaseUtils.capture_exception(exception)
4040
# Default to internal server error if status code is not set
4141
status_code = (
42-
exception.http_status_code_value if exception.http_status_code_value else HTTPStatus.INTERNAL_SERVER_ERROR.value
42+
exception.http_status_code_value
43+
if exception.http_status_code_value
44+
else HTTPStatus.INTERNAL_SERVER_ERROR.value
4345
)
4446
return JSONResponse(status_code=status_code, content=exception.to_dict())
4547

@@ -85,16 +87,8 @@ async def validation_exception_handler(
8587
Returns:
8688
JSONResponse: A JSON response containing the validation error details.
8789
"""
88-
# Using list comprehension instead of append for better performance
8990
BaseUtils.capture_exception(exception)
90-
errors: list[dict[str, str]] = [
91-
{
92-
"field": ".".join(str(x) for x in error["loc"]),
93-
"message": error["msg"],
94-
"value": str(error.get("input", "")),
95-
}
96-
for error in exception.errors()
97-
]
91+
errors = BaseUtils.format_validation_errors(exception)
9892
return JSONResponse(
9993
status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
10094
content={"error": "VALIDATION_ERROR", "detail": errors},

archipy/helpers/utils/base_utils.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import re
22

3+
from pydantic import ValidationError
4+
35
from archipy.helpers.utils.datetime_utils import DatetimeUtils
46
from archipy.helpers.utils.error_utils import ErrorUtils
57
from archipy.helpers.utils.file_utils import FileUtils
@@ -20,6 +22,32 @@ class BaseUtils(ErrorUtils, DatetimeUtils, PasswordUtils, JWTUtils, TOTPUtils, F
2022
This class inherits from various utility classes to provide a centralized place for common utility methods.
2123
"""
2224

25+
@staticmethod
26+
def format_validation_errors(
27+
validation_error: ValidationError, *, include_type: bool = False
28+
) -> list[dict[str, str]]:
29+
"""Formats Pydantic validation errors into a structured format.
30+
31+
Args:
32+
validation_error (ValidationError): The validation error to format.
33+
include_type (bool): Whether to include the error type in the output. Defaults to False.
34+
35+
Returns:
36+
list[dict[str, str]]: A list of formatted validation error details.
37+
"""
38+
formatted_errors = []
39+
for error in validation_error.errors():
40+
error_dict = {
41+
"field": ".".join(str(x) for x in error["loc"]),
42+
"message": error["msg"],
43+
"value": str(error.get("input", "")),
44+
}
45+
if include_type:
46+
error_dict["type"] = error["type"]
47+
formatted_errors.append(error_dict)
48+
49+
return formatted_errors
50+
2351
@staticmethod
2452
def sanitize_iranian_landline_or_phone_number(landline_or_phone_number: str) -> str:
2553
"""Sanitizes an Iranian landline or mobile phone number by removing non-numeric characters and standardizing the format.
@@ -85,6 +113,7 @@ def validate_iranian_landline_number(cls, landline_number: str) -> None:
85113
@classmethod
86114
def validate_iranian_national_code_pattern(cls, national_code: str) -> None:
87115
"""Validates an Iranian National ID number using the official algorithm.
116+
88117
To see how the algorithm works, see http://www.aliarash.com/article/codemeli/codemeli.htm
89118
90119
The algorithm works by:

archipy/helpers/utils/password_utils.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@ def verify_password(password: str, stored_password: str, auth_config: AuthConfig
6666
def validate_password(
6767
password: str,
6868
auth_config: AuthConfig | None = None,
69-
lang: LanguageType = LanguageType.FA,
7069
) -> None:
7170
"""Validates a password against the password policy.
7271
@@ -97,7 +96,7 @@ def validate_password(
9796
errors.append(f"Password must contain at least one special character: {configs.SPECIAL_CHARACTERS}")
9897

9998
if errors:
100-
raise InvalidPasswordError(requirements=errors, lang=lang)
99+
raise InvalidPasswordError(requirements=errors)
101100

102101
@staticmethod
103102
def generate_password(auth_config: AuthConfig | None = None) -> str:
@@ -146,7 +145,7 @@ def validate_password_history(
146145
new_password: str,
147146
password_history: list[str],
148147
auth_config: AuthConfig | None = None,
149-
lang: LanguageType = LanguageType.FA,
148+
lang: LanguageType | None = None,
150149
) -> None:
151150
"""Validates a new password against the password history.
152151

0 commit comments

Comments
 (0)