Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions backend/app/errors/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from .codes import ErrorCode
from .exceptions import AppError, BadRequestError, ConflictError, NotFoundError
from .handlers import register_exception_handlers
from .schemas import ErrorResponse

__all__ = [
"ErrorCode",
"AppError",
"BadRequestError",
"ConflictError",
"NotFoundError",
"ErrorResponse",
"register_exception_handlers",
]
13 changes: 13 additions & 0 deletions backend/app/errors/codes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from enum import Enum


class ErrorCode(str, Enum):
CATEGORY_EXISTS = "CATEGORY_EXISTS"
CATEGORY_NOT_FOUND = "CATEGORY_NOT_FOUND"
PRODUCT_NOT_FOUND = "PRODUCT_NOT_FOUND"
IMAGE_NOT_FOUND = "IMAGE_NOT_FOUND"
VALIDATION_ERROR = "VALIDATION_ERROR"
BAD_REQUEST = "BAD_REQUEST"
NOT_FOUND = "NOT_FOUND"
CONFLICT = "CONFLICT"
INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR"
70 changes: 70 additions & 0 deletions backend/app/errors/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from typing import Any
from .codes import ErrorCode


class AppError(Exception):
status_code: int
error_code: ErrorCode
message: str
details: dict[str, Any] | None

def __init__(
self,
*,
status_code: int,
error_code: ErrorCode,
message: str,
details: dict[str, Any] | None = None,
) -> None:
self.status_code = status_code
self.error_code = error_code
self.message = message
self.details = details


class NotFoundError(AppError):
def __init__(
self,
*,
error_code: ErrorCode,
message: str,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(
status_code=404,
error_code=error_code,
message=message,
details=details,
)


class BadRequestError(AppError):
def __init__(
self,
*,
error_code: ErrorCode,
message: str,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(
status_code=400,
error_code=error_code,
message=message,
details=details,
)


class ConflictError(AppError):
def __init__(
self,
*,
error_code: ErrorCode,
message: str,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(
status_code=409,
error_code=error_code,
message=message,
details=details,
)
78 changes: 78 additions & 0 deletions backend/app/errors/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from .schemas import ErrorResponse
from .codes import ErrorCode
from .exceptions import AppError


def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
body = ErrorResponse(
code=exc.status_code,
error_code=exc.error_code,
message=exc.message,
details=exc.details,
)
return JSONResponse(
status_code=exc.status_code,
content=body.model_dump(),
)

@app.exception_handler(RequestValidationError)
async def handle_validation_error(
request: Request,
exc: RequestValidationError,
) -> JSONResponse:
errors = []
for error in exc.errors():
error_dict = dict(error)
if "ctx" in error_dict:
ctx = error_dict["ctx"]
if isinstance(ctx, dict):
for key, value in ctx.items():
if isinstance(value, Exception):
ctx[key] = str(value)
error_dict["ctx"] = ctx
errors.append(error_dict)

details = {"errors": errors}
body = ErrorResponse(
code=422,
error_code=ErrorCode.VALIDATION_ERROR,
message="Validation error",
details=details,
)
return JSONResponse(status_code=422, content=body.model_dump())

@app.exception_handler(StarletteHTTPException)
async def handle_http_exception(
request: Request,
exc: StarletteHTTPException,
) -> JSONResponse:
if exc.status_code == 404:
code = ErrorCode.NOT_FOUND
elif exc.status_code == 409:
code = ErrorCode.CONFLICT
elif 400 <= exc.status_code < 500:
code = ErrorCode.BAD_REQUEST
else:
code = ErrorCode.INTERNAL_SERVER_ERROR

body = ErrorResponse(
code=exc.status_code,
error_code=code,
message=str(exc.detail) if exc.detail else "Error",
)
return JSONResponse(status_code=exc.status_code, content=body.model_dump())

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generic Exception handler silently swallows all unexpected errors without logging. This makes debugging production issues difficult. Consider adding logging before returning the generic error response:

import logging
logger = logging.getLogger(__name__)

@app.exception_handler(Exception)
async def handle_unexpected(request: Request, exc: Exception) -> JSONResponse:
    logger.exception("Unexpected error occurred", exc_info=exc)
    # ... rest of handler

@app.exception_handler(Exception)
async def handle_unexpected(request: Request, exc: Exception) -> JSONResponse:
body = ErrorResponse(
code=500,
error_code=ErrorCode.INTERNAL_SERVER_ERROR,
message="Internal server error",
)
return JSONResponse(status_code=500, content=body.model_dump())
10 changes: 10 additions & 0 deletions backend/app/errors/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from typing import Any
from pydantic import BaseModel
from .codes import ErrorCode


class ErrorResponse(BaseModel):
code: int
error_code: ErrorCode
message: str
details: dict[str, Any] | None = None
57 changes: 45 additions & 12 deletions backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from fastapi import FastAPI, Depends, HTTPException, Query
from fastapi import FastAPI, Depends, Query
from fastapi.responses import StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
Expand All @@ -10,6 +10,13 @@
import os

from .db import get_session, create_db_and_tables
from .errors import (
BadRequestError,
ConflictError,
ErrorCode,
NotFoundError,
register_exception_handlers,
)

from .schemas import (
ProductRead,
Expand Down Expand Up @@ -66,6 +73,8 @@ async def lifespan(app: FastAPI):
lifespan=lifespan,
)

register_exception_handlers(app)

# CORS middleware for frontend integration
origins = os.getenv(
"CORS_ALLOW_ORIGINS", "http://localhost:3001,http://127.0.0.1:3001"
Expand All @@ -91,9 +100,9 @@ def create_category(category: CategoryCreate, session: Session = Depends(get_ses
# Check if category already exists
existing_category = crud.get_category_by_name(session, category.name)
if existing_category:
raise HTTPException(
status_code=400,
detail=f"Category with name '{category.name}' already exists",
raise ConflictError(
error_code=ErrorCode.CATEGORY_EXISTS,
message=f"Category with name '{category.name}' already exists",
)

return crud.create_category(session, category)
Expand All @@ -116,7 +125,10 @@ def get_categories(session: Session = Depends(get_session)):
def get_category(category_id: int, session: Session = Depends(get_session)):
category = crud.get_category(session, category_id)
if not category:
raise HTTPException(status_code=404, detail="Category not found")
raise NotFoundError(
error_code=ErrorCode.CATEGORY_NOT_FOUND,
message="Category not found",
)

# Convert to response format with image URLs
products_with_images = []
Expand Down Expand Up @@ -166,7 +178,10 @@ def create_product(product: ProductCreate, session: Session = Depends(get_sessio
# Verify category exists
category = crud.get_category(session, product.category_id)
if not category:
raise HTTPException(status_code=400, detail="Category not found")
raise BadRequestError(
error_code=ErrorCode.CATEGORY_NOT_FOUND,
message="Category not found",
)

created_product = crud.create_product(session, product)

Expand Down Expand Up @@ -361,7 +376,10 @@ def get_product(product_id: int, session: Session = Depends(get_session)):
)
product = session.exec(stmt).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
raise NotFoundError(
error_code=ErrorCode.PRODUCT_NOT_FOUND,
message="Product not found",
)

# Filter active options and sort them
active_options = [opt for opt in product.delivery_options if opt.is_active]
Expand Down Expand Up @@ -420,11 +438,17 @@ def update_product(
if product_update.category_id:
category = crud.get_category(session, product_update.category_id)
if not category:
raise HTTPException(status_code=400, detail="Category not found")
raise BadRequestError(
error_code=ErrorCode.CATEGORY_NOT_FOUND,
message="Category not found",
)

updated_product = crud.update_product(session, product_id, product_update)
if not updated_product:
raise HTTPException(status_code=404, detail="Product not found")
raise NotFoundError(
error_code=ErrorCode.PRODUCT_NOT_FOUND,
message="Product not found",
)

# Convert to response format with image URL
product_dict = {
Expand All @@ -447,7 +471,10 @@ def update_product(
@app.delete("/products/{product_id}")
def delete_product(product_id: int, session: Session = Depends(get_session)):
if not crud.delete_product(session, product_id):
raise HTTPException(status_code=404, detail="Product not found")
raise NotFoundError(
error_code=ErrorCode.PRODUCT_NOT_FOUND,
message="Product not found",
)

return {"message": "Product deleted successfully"}

Expand All @@ -456,10 +483,16 @@ def delete_product(product_id: int, session: Session = Depends(get_session)):
def get_product_image(product_id: int, session: Session = Depends(get_session)):
product = crud.get_product(session, product_id)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
raise NotFoundError(
error_code=ErrorCode.PRODUCT_NOT_FOUND,
message="Product not found",
)

if not product.image_data:
raise HTTPException(status_code=404, detail="No image found for this product")
raise NotFoundError(
error_code=ErrorCode.IMAGE_NOT_FOUND,
message="No image found for this product",
)

# Return image as streaming response
return StreamingResponse(
Expand Down
6 changes: 3 additions & 3 deletions backend/tests/api/test_categories.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def test_create_duplicate_category(client: TestClient):

response = client.post("/categories", json=category_data)
# This should fail due to unique constraint
assert response.status_code in [400, 422]
assert response.status_code == 409


def test_category_with_products(client: TestClient):
Expand Down Expand Up @@ -100,8 +100,8 @@ def test_create_category_duplicate_name(client: TestClient):

# Duplicate should fail
response2 = client.post("/categories", json=category_data)
assert response2.status_code == 400
assert "already exists" in response2.json()["detail"].lower()
assert response2.status_code == 409
assert "already exists" in response2.json()["message"].lower()


def test_create_category_empty_name(client: TestClient):
Expand Down
Loading
Loading