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
144 changes: 144 additions & 0 deletions backend/app/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
from enum import Enum
from fastapi import HTTPException, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
import logging

logger = logging.getLogger("ai_assistant.exceptions")


class ErrorCode(str, Enum):
INVALID_TOKEN = "invalid_token"
AUTHENTICATION_REQUIRED = "authentication_required"
INVALID_CREDENTIALS = "invalid_credentials"
EMAIL_ALREADY_EXISTS = "email_already_exists"
HISTORY_NOT_FOUND = "history_not_found"
FAVORITE_NOT_FOUND = "favorite_not_found"
SHARED_RESULT_NOT_FOUND = "shared_result_not_found"
UNSUPPORTED_FILE_TYPE = "unsupported_file_type"
PAYLOAD_TOO_LARGE = "payload_too_large"
RATE_LIMITED = "rate_limited"
VALIDATION_ERROR = "validation_error"
INTERNAL_SERVER_ERROR = "internal_server_error"
BAD_REQUEST = "bad_request"
FORBIDDEN = "forbidden"
ALREADY_SUBSCRIBED = "already_subscribed"
SUBSCRIPTION_NOT_FOUND = "subscription_not_found"


class APIException(HTTPException):
def __init__(
self,
status_code: int,
error_code: ErrorCode,
detail: str,
headers: dict | None = None,
):
super().__init__(status_code=status_code, detail=detail, headers=headers)
self.error_code = error_code


def map_http_exception_to_code(status_code: int, detail: str) -> str:
detail_lower = detail.lower()

# 401 Unauthorized
if status_code == 401:
if "authentication required" in detail_lower:
return ErrorCode.AUTHENTICATION_REQUIRED
if "invalid token" in detail_lower or "user not found" in detail_lower:
return ErrorCode.INVALID_TOKEN
if "invalid credentials" in detail_lower:
return ErrorCode.INVALID_CREDENTIALS
return "unauthorized"

# 403 Forbidden
if status_code == 403:
if "invalid unsubscribe token" in detail_lower:
return ErrorCode.INVALID_TOKEN
return ErrorCode.FORBIDDEN

# 404 Not Found
if status_code == 404:
if "history" in detail_lower:
return ErrorCode.HISTORY_NOT_FOUND
if "favorite" in detail_lower:
return ErrorCode.FAVORITE_NOT_FOUND
if "shared result" in detail_lower or "share" in detail_lower:
return ErrorCode.SHARED_RESULT_NOT_FOUND
if "subscription" in detail_lower:
return ErrorCode.SUBSCRIPTION_NOT_FOUND
return "not_found"

# 409 Conflict
if status_code == 409:
if "already subscribed" in detail_lower:
return ErrorCode.ALREADY_SUBSCRIBED
if "email already exists" in detail_lower:
return ErrorCode.EMAIL_ALREADY_EXISTS
return "conflict"

# 413 Content Too Large / Payload Too Large
if status_code == 413:
return ErrorCode.PAYLOAD_TOO_LARGE

# 415 Unsupported Media Type
if status_code == 415:
return ErrorCode.UNSUPPORTED_FILE_TYPE

# 429 Too Many Requests
if status_code == 429:
return ErrorCode.RATE_LIMITED

# 400 Bad Request
if status_code == 400:
if "only .zip" in detail_lower:
return ErrorCode.UNSUPPORTED_FILE_TYPE
return ErrorCode.BAD_REQUEST

# 500 Internal Server Error
if status_code == 500:
return ErrorCode.INTERNAL_SERVER_ERROR

return (
ErrorCode.BAD_REQUEST if status_code < 500 else ErrorCode.INTERNAL_SERVER_ERROR
)


async def api_exception_handler(request: Request, exc: APIException):
return JSONResponse(
status_code=exc.status_code,
content={
"error": exc.error_code,
"detail": exc.detail,
},
headers=exc.headers,
)


async def http_exception_handler(request: Request, exc: HTTPException):
error_code = map_http_exception_to_code(exc.status_code, exc.detail)
return JSONResponse(
status_code=exc.status_code,
content={
"error": error_code,
"detail": exc.detail,
},
headers=exc.headers,
)


async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = exc.errors()
details = []
for err in errors:
loc = " -> ".join(str(location_part) for location_part in err["loc"])
details.append(f"{loc}: {err['msg']}")
detail_str = "; ".join(details)

return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"error": ErrorCode.VALIDATION_ERROR,
"detail": detail_str,
},
)
43 changes: 33 additions & 10 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
FastAPI application with advanced middleware, rate limiting, and full analysis engine.
"""

from fastapi import FastAPI, Request
from fastapi import FastAPI, Request, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
Expand All @@ -14,6 +15,13 @@
import logging
from contextlib import asynccontextmanager

from .exceptions import (
APIException,
api_exception_handler,
http_exception_handler,
validation_exception_handler,
)

from .routers import (
analyze,
auth,
Expand Down Expand Up @@ -71,7 +79,9 @@ async def lifespan(app: FastAPI):
await database.init_db()
print("🚀 QyverixAI backend starting…")
# Static info gauge so dashboards can pin version / provider labels.
initialise_app_info(version="3.0.0", ai_provider=os.getenv("AI_PROVIDER", "rule-based"))
initialise_app_info(
version="3.0.0", ai_provider=os.getenv("AI_PROVIDER", "rule-based")
)
start_scheduler()
yield
stop_scheduler()
Expand All @@ -88,6 +98,10 @@ async def lifespan(app: FastAPI):
lifespan=lifespan,
)

app.add_exception_handler(APIException, api_exception_handler)
app.add_exception_handler(HTTPException, http_exception_handler)
app.add_exception_handler(RequestValidationError, validation_exception_handler)

# ── Middleware ────────────────────────────────────────────────────────────────
app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware(
Expand All @@ -114,7 +128,12 @@ async def add_process_time_header(request: Request, call_next):
remaining = RATE_LIMIT

# Apply rate limiting to analysis endpoints only
if request.url.path in ("/explanation/", "/debugging/", "/suggestions/", "/analyze/"):
if request.url.path in (
"/explanation/",
"/debugging/",
"/suggestions/",
"/analyze/",
):
remaining = check_rate_limit(ip)
if remaining < 0:
elapsed = (time.perf_counter() - start) * 1000
Expand All @@ -125,7 +144,8 @@ async def add_process_time_header(request: Request, call_next):
return JSONResponse(
status_code=429,
content={
"detail": f"Rate limit exceeded. Max {RATE_LIMIT} requests/minute."
"error": "rate_limited",
"detail": f"Rate limit exceeded. Max {RATE_LIMIT} requests/minute.",
},
headers=headers,
)
Expand All @@ -150,16 +170,16 @@ async def add_cache_header(request: Request, call_next):

# ── Routers ───────────────────────────────────────────────────────────────────
app.include_router(explanation.router, prefix="/explanation", tags=["Explanation"])
app.include_router(debugging.router, prefix="/debugging", tags=["Debugging"])
app.include_router(debugging.router, prefix="/debugging", tags=["Debugging"])
app.include_router(suggestions.router, prefix="/suggestions", tags=["Suggestions"])
app.include_router(analyze.router, prefix="/analyze", tags=["Full Analysis"])
app.include_router(subscribe.router, prefix="/subscribe", tags=["Subscription"])
app.include_router(history.router, prefix="/history", tags=["History"])
app.include_router(analyze.router, prefix="/analyze", tags=["Full Analysis"])
app.include_router(subscribe.router, prefix="/subscribe", tags=["Subscription"])
app.include_router(history.router, prefix="/history", tags=["History"])
app.include_router(auth.router)
app.include_router(chat.router)
app.include_router(share.router)
app.include_router(user_data.router)
app.include_router(upload_file.router, prefix="/upload", tags=['Upload File'] )
app.include_router(upload_file.router, prefix="/upload", tags=["Upload File"])


# Operational endpoints: /healthz/live, /healthz/ready, /metrics
Expand Down Expand Up @@ -238,5 +258,8 @@ async def global_exception_handler(request: Request, exc: Exception):
logging.exception("Unhandled error")
return JSONResponse(
status_code=500,
content={"detail": "Internal server error. Please try again."},
content={
"error": "internal_server_error",
"detail": "Internal server error. Please try again.",
},
)
9 changes: 9 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
validate_stored_result_json,
)


class CodeRequest(BaseModel):
code: str
language: str | None = None
Expand Down Expand Up @@ -240,6 +241,7 @@ class ReadinessResponse(BaseModel):
status: str
checks: dict[str, dict[str, Any]]


class ShareCreateRequest(BaseModel):
action: str = Field("share", min_length=3, max_length=50)
code: str = Field(..., min_length=1, max_length=settings.max_code_chars)
Expand Down Expand Up @@ -365,16 +367,23 @@ class ExplanationResponse(BaseModel):
cyclomatic_complexity: int
complexity_risk: str


class SuggestionsResponse(BaseModel):
suggestions: list[Suggestion]
overall_score: int
grade: str
next_step: str


class AnalyzeResponse(BaseModel):
provider: str
model: str
explanation: ExplanationResponse
debugging: DebuggingResponse
suggestions: SuggestionsResponse
analysis_time_ms: float | None = None


class ErrorResponse(BaseModel):
error: str
detail: str
5 changes: 4 additions & 1 deletion backend/tests/test_auth_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,16 +98,19 @@ def test_signup_duplicate_email_returns_409(client):
duplicate_response = client.post("/auth/signup", json=payload)
assert duplicate_response.status_code == 409
assert "already exists" in duplicate_response.json()["detail"].lower()
assert duplicate_response.json()["error"] == "email_already_exists"


def test_me_rejects_missing_and_invalid_token(client):
missing_token_response = client.get("/auth/me")
assert missing_token_response.status_code == 401
assert "authentication required" in missing_token_response.json()["detail"].lower()
assert missing_token_response.json()["error"] == "authentication_required"

invalid_token_response = client.get(
"/auth/me",
headers={"Authorization": "Bearer not-a-real-token"},
)
assert invalid_token_response.status_code == 401
assert "invalid token" in invalid_token_response.json()["detail"].lower()
assert "invalid token" in invalid_token_response.json()["detail"].lower()
assert invalid_token_response.json()["error"] == "invalid_token"
Loading