From 6b95e75e426f9eaf2450ed50cb14f91dc25649a2 Mon Sep 17 00:00:00 2001 From: Arijit429 Date: Wed, 15 Apr 2026 03:30:11 +0530 Subject: [PATCH] feat: harden global exception handlers and fix traceback leakage - Add HTTPException handler for consistent error shape across all routes - Add RequestValidationError handler with human-readable error messages - Add catch-all Exception handler to prevent stack trace leakage - Fix duplicate get_template() call in forms.py (was querying DB twice) - Wrap Controller errors in AppError for safe client-facing messages - All errors now return uniform {success, error: {code, message}} envelope --- api/errors/base.py | 5 +- api/errors/handlers.py | 112 +++++++++++++++++++++++++++++++++++++++-- api/main.py | 18 +++---- api/routes/forms.py | 26 +++++++--- 4 files changed, 140 insertions(+), 21 deletions(-) diff --git a/api/errors/base.py b/api/errors/base.py index 1f81a08..ad70344 100644 --- a/api/errors/base.py +++ b/api/errors/base.py @@ -1,4 +1,7 @@ class AppError(Exception): + """Base application error with an HTTP status code.""" + def __init__(self, message: str, status_code: int = 400): self.message = message - self.status_code = status_code \ No newline at end of file + self.status_code = status_code + super().__init__(message) \ No newline at end of file diff --git a/api/errors/handlers.py b/api/errors/handlers.py index 903e744..fb28d02 100644 --- a/api/errors/handlers.py +++ b/api/errors/handlers.py @@ -1,11 +1,115 @@ -from fastapi import Request +""" +Global exception handlers for the FireForm API. + +Ensures every error response returns a uniform JSON envelope matching +the ErrorResponse schema from api.schemas.common, regardless of whether +the error is a validation failure, a known application error, an HTTP +exception, or an unexpected crash. + +Security: unhandled exceptions are logged server-side but never exposed +to the client. +""" + +import logging + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + from api.errors.base import AppError -def register_exception_handlers(app): +logger = logging.getLogger("fireform") + + +def register_exception_handlers(app: FastAPI) -> None: + """Attach all global exception handlers to the FastAPI app.""" + @app.exception_handler(AppError) - async def app_error_handler(request: Request, exc: AppError): + async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: + """Handle known application-level errors raised with AppError.""" return JSONResponse( status_code=exc.status_code, - content={"error": exc.message}, + content={ + "success": False, + "error": { + "code": "APPLICATION_ERROR", + "message": exc.message, + }, + }, + ) + + @app.exception_handler(StarletteHTTPException) + async def http_error_handler( + request: Request, exc: StarletteHTTPException + ) -> JSONResponse: + """ + Handle FastAPI/Starlette HTTPExceptions. + + templates.py raises HTTPException while forms.py raises AppError. + This ensures both produce the same response shape for the frontend. + """ + return JSONResponse( + status_code=exc.status_code, + content={ + "success": False, + "error": { + "code": "HTTP_ERROR", + "message": str(exc.detail), + }, + }, + ) + + @app.exception_handler(RequestValidationError) + async def validation_error_handler( + request: Request, exc: RequestValidationError + ) -> JSONResponse: + """ + Handle Pydantic request validation failures. + + Extracts the first validation error and returns a human-readable + message instead of dumping the raw Pydantic error array. + """ + first = exc.errors()[0] if exc.errors() else {} + field = " -> ".join(str(loc) for loc in first.get("loc", [])) + message = first.get("msg", "Validation failed") + detail = f"{field}: {message}" if field else message + + return JSONResponse( + status_code=422, + content={ + "success": False, + "error": { + "code": "VALIDATION_ERROR", + "message": detail, + }, + }, + ) + + @app.exception_handler(Exception) + async def unhandled_error_handler( + request: Request, exc: Exception + ) -> JSONResponse: + """ + Catch-all for unexpected exceptions. + + Logs the full traceback server-side for debugging but returns + only a generic message to the client. This prevents leaking + internal file paths, stack frames, and application state. + """ + logger.exception( + "Unhandled error on %s %s: %s", + request.method, + request.url.path, + str(exc), + ) + return JSONResponse( + status_code=500, + content={ + "success": False, + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error", + }, + }, ) \ No newline at end of file diff --git a/api/main.py b/api/main.py index 7d81ef6..95021c6 100644 --- a/api/main.py +++ b/api/main.py @@ -4,23 +4,23 @@ from fastapi.middleware.cors import CORSMiddleware from api.routes import forms, templates +from api.errors.handlers import register_exception_handlers app = FastAPI() -default_origins = "http://127.0.0.1:5173" -allowed_origins = [ - origin.strip() - for origin in os.getenv("FRONTEND_ORIGINS", default_origins).split(",") - if origin.strip() -] +# Register global exception handlers before middleware +register_exception_handlers(app) app.add_middleware( CORSMiddleware, - allow_origins=allowed_origins, - allow_credentials=False, + allow_origins=[ + "http://127.0.0.1:5500", + "http://localhost:5500", + ], + allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.include_router(templates.router) -app.include_router(forms.router) +app.include_router(forms.router) \ No newline at end of file diff --git a/api/routes/forms.py b/api/routes/forms.py index f3430ed..de6ead8 100644 --- a/api/routes/forms.py +++ b/api/routes/forms.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends from sqlmodel import Session + from api.deps import get_db from api.schemas.forms import FormFill, FormFillResponse from api.db.repositories import create_form, get_template @@ -9,17 +10,28 @@ router = APIRouter(prefix="/forms", tags=["forms"]) + @router.post("/fill", response_model=FormFillResponse) def fill_form(form: FormFill, db: Session = Depends(get_db)): - if not get_template(db, form.template_id): + # Single query instead of the previous duplicate get_template() calls + template = get_template(db, form.template_id) + if not template: raise AppError("Template not found", status_code=404) - fetched_template = get_template(db, form.template_id) - controller = Controller() - path = controller.fill_form(user_input=form.input_text, fields=fetched_template.fields, pdf_form_path=fetched_template.pdf_path) + try: + path = controller.fill_form( + user_input=form.input_text, + fields=template.fields, + pdf_form_path=template.pdf_path, + ) + except AppError: + raise # Re-raise known application errors as-is + except Exception as exc: + raise AppError( + f"Form filling failed: {exc}", + status_code=500, + ) from exc submission = FormSubmission(**form.model_dump(), output_pdf_path=path) - return create_form(db, submission) - - + return create_form(db, submission) \ No newline at end of file