@@ -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,18 @@ 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+ raise ValueError (
255+ f"Duplicate endpoint registration: { metadata .method } { metadata .path } "
256+ )
257+
231258 # Resolve imports once per decorated function, not per request
232259 import json as _json
233260
@@ -236,6 +263,7 @@ def decorator(func: F) -> F:
236263 from twisted .web .server import NOT_DONE_YET as _NOT_DONE_YET
237264
238265 from hathor .api_util import set_cors
266+ from hathor .exception import HathorError
239267 from hathor .utils .api import ErrorResponse as _LegacyErrorResponse
240268
241269 @functools .wraps (func )
@@ -262,16 +290,31 @@ def wrapper(self: Any, request: Request, *args: Any, **kwargs: Any) -> Any:
262290 request .setResponseCode (400 )
263291 return error .json_dumpb ()
264292 body_bytes = request .content .read ()
293+ if len (body_bytes ) > max_body_size :
294+ error = ErrorResponse (error = f'Request body too large (max { max_body_size } bytes)' )
295+ request .setResponseCode (413 )
296+ return error .json_dumpb ()
265297 body_data = _json .loads (body_bytes )
266298 body = request_model .model_validate (body_data )
267- except (ValidationError , _json .JSONDecodeError , UnicodeDecodeError ) as e :
268- error = ErrorResponse (error = str (e ))
299+ except ValidationError as e :
300+ error = ErrorResponse (error = _sanitize_validation_error (e ))
301+ request .setResponseCode (400 )
302+ return error .json_dumpb ()
303+ except (_json .JSONDecodeError , UnicodeDecodeError ):
304+ error = ErrorResponse (error = 'Request body is not valid JSON' )
269305 request .setResponseCode (400 )
270306 return error .json_dumpb ()
271307 kwargs ['body' ] = body
272308
273309 # Call the actual handler
274- result = func (self , request , * args , ** kwargs )
310+ try :
311+ result = func (self , request , * args , ** kwargs )
312+ except HathorError as e :
313+ if not catch_hathor_exceptions :
314+ raise
315+ error = ErrorResponse (error = str (e ))
316+ request .setResponseCode (getattr (e , 'status_code' , 400 ))
317+ return error .json_dumpb ()
275318
276319 # Auto-serialize response
277320 if isinstance (result , Deferred ):
0 commit comments