@@ -58,6 +58,8 @@ class EndpointMetadata:
5858 deprecated : bool
5959 path_params_regex : dict [str , str ]
6060 path_params_descriptions : dict [str , str ]
61+ catch_hathor_exceptions : bool
62+ max_body_size : int
6163
6264
6365# Global registry of endpoint metadata
@@ -152,6 +154,17 @@ def _parse_rate_limit(rl: dict[str, Any]) -> RateLimitConfig:
152154 return RateLimitConfig (rate = rl ['rate' ], burst = rl ['burst' ], delay = rl ['delay' ])
153155
154156
157+ def _sanitize_validation_error (e : Any ) -> str :
158+ """Format a Pydantic ValidationError into a safe, user-facing message.
159+
160+ Strips model internals and only includes field paths and messages.
161+ """
162+ return '; ' .join (
163+ f"{ '.' .join (str (loc ) for loc in err ['loc' ])} : { err ['msg' ]} "
164+ for err in e .errors ()
165+ )
166+
167+
155168def api_endpoint (
156169 * ,
157170 path : str ,
@@ -169,6 +182,8 @@ def api_endpoint(
169182 deprecated : bool = False ,
170183 path_params_regex : dict [str , str ] | None = None ,
171184 path_params_descriptions : dict [str , str ] | None = None ,
185+ catch_hathor_exceptions : bool = True ,
186+ max_body_size : int = 1_000_000 ,
172187) -> Callable [[F ], F ]:
173188 """Decorator to register an endpoint with OpenAPI metadata and auto-validate/serialize.
174189
@@ -204,6 +219,8 @@ def api_endpoint(
204219 deprecated: Whether this endpoint is deprecated
205220 path_params_regex: Regex patterns for path parameters
206221 path_params_descriptions: Descriptions for path parameters
222+ catch_hathor_exceptions: Whether to catch HathorError and return ErrorResponse (default True)
223+ max_body_size: Maximum request body size in bytes (default 1MB)
207224 """
208225 def decorator (func : F ) -> F :
209226 # Parse rate limit configs
@@ -226,8 +243,21 @@ def decorator(func: F) -> F:
226243 deprecated = deprecated ,
227244 path_params_regex = path_params_regex or {},
228245 path_params_descriptions = path_params_descriptions or {},
246+ catch_hathor_exceptions = catch_hathor_exceptions ,
247+ max_body_size = max_body_size ,
229248 )
230249
250+ # Check for duplicate registrations
251+ key = (metadata .path , metadata .method )
252+ for existing in _endpoint_registry :
253+ if (existing .path , existing .method ) == key :
254+ if existing .operation_id == metadata .operation_id :
255+ # Same endpoint decorated again (e.g. module re-executed by pytest --doctest-modules)
256+ return func
257+ raise ValueError (
258+ f"Duplicate endpoint registration: { metadata .method } { metadata .path } "
259+ )
260+
231261 # Resolve imports once per decorated function, not per request
232262 import json as _json
233263
@@ -236,6 +266,7 @@ def decorator(func: F) -> F:
236266 from twisted .web .server import NOT_DONE_YET as _NOT_DONE_YET
237267
238268 from hathor .api_util import set_cors
269+ from hathor .exception import HathorError
239270 from hathor .utils .api import ErrorResponse as _LegacyErrorResponse
240271
241272 @functools .wraps (func )
@@ -262,16 +293,31 @@ def wrapper(self: Any, request: Request, *args: Any, **kwargs: Any) -> Any:
262293 request .setResponseCode (400 )
263294 return error .json_dumpb ()
264295 body_bytes = request .content .read ()
296+ if len (body_bytes ) > max_body_size :
297+ error = ErrorResponse (error = f'Request body too large (max { max_body_size } bytes)' )
298+ request .setResponseCode (413 )
299+ return error .json_dumpb ()
265300 body_data = _json .loads (body_bytes )
266301 body = request_model .model_validate (body_data )
267- except (ValidationError , _json .JSONDecodeError , UnicodeDecodeError ) as e :
268- error = ErrorResponse (error = str (e ))
302+ except ValidationError as e :
303+ error = ErrorResponse (error = _sanitize_validation_error (e ))
304+ request .setResponseCode (400 )
305+ return error .json_dumpb ()
306+ except (_json .JSONDecodeError , UnicodeDecodeError ):
307+ error = ErrorResponse (error = 'Request body is not valid JSON' )
269308 request .setResponseCode (400 )
270309 return error .json_dumpb ()
271310 kwargs ['body' ] = body
272311
273312 # Call the actual handler
274- result = func (self , request , * args , ** kwargs )
313+ try :
314+ result = func (self , request , * args , ** kwargs )
315+ except HathorError as e :
316+ if not catch_hathor_exceptions :
317+ raise
318+ error = ErrorResponse (error = str (e ))
319+ request .setResponseCode (getattr (e , 'status_code' , 400 ))
320+ return error .json_dumpb ()
275321
276322 # Auto-serialize response
277323 if isinstance (result , Deferred ):
0 commit comments