Error Handling (Python / FastAPI)
HTTP Errors — Use HTTPException
from fastapi import HTTPException
# GOOD — descriptive, correct status
raise HTTPException(status_code=404, detail="Exam not found")
raise HTTPException(status_code=403, detail="Admin access required")
raise HTTPException(status_code=400, detail="Container already exists")
# BAD — wrong status, vague message
raise HTTPException(status_code=500, detail="Error")
Standard Status Codes
| Status | When |
|---|---|
| 400 | Bad request, invalid input |
| 401 | Missing or invalid JWT |
| 403 | Valid token, insufficient role |
| 404 | Entity not found |
| 409 | Conflict (duplicate username, etc.) |
| 422 | Pydantic validation failure (automatic) |
| 500 | Unexpected error — log and return generic message |
Database Not Found Pattern
# In API endpoint
result = await db.execute(select(Exam).where(Exam.id == exam_id))
exam = result.scalar_one_or_none()
if not exam:
raise HTTPException(status_code=404, detail=f"Exam with id {exam_id} not found")
LXD / External Service Errors
Wrap external service calls and convert to appropriate HTTP errors:
try:
container = await asyncio.to_thread(lxc_manager.create_container, name, image)
except Exception as e:
logger.error(f"Failed to create LXD container {name}: {e}")
raise HTTPException(status_code=500, detail="Failed to create container")
Logging at Error Site
Log errors at the point where you catch them (endpoint or service level), not deep in the call stack.
# GOOD — log with context, then raise
try:
result = await execute_script_via_ssh(container_name, script)
except Exception as e:
logger.error(f"Verification script failed for attempt {attempt_id}: {e}")
raise HTTPException(status_code=500, detail="Verification failed")
# BAD — silently swallow or re-raise without logging
try:
result = await execute_script_via_ssh(container_name, script)
except Exception as e:
raise # No context, no log
Pydantic Validation (Automatic)
FastAPI automatically returns 422 for invalid request bodies. No extra handling needed:
class ExamCreate(BaseModel):
title: str
time_limit_minutes: int = Field(gt=0, le=240)
passing_score: int = Field(ge=0, le=100)
Invalid input → 422 with field-level error details automatically.
Rules
- Never return 500 without logging — always
logger.error(...)before raising 500 - Never expose internal error details in 500 responses — use generic messages
- Use correct status codes — 404 for not found, 400 for bad input, never 500 for client errors
- Do not silence exceptions — if you catch and don't re-raise, log at ERROR level
- Check entity existence before mutation — return 404 before attempting update/delete on missing records