Skip to main content

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

StatusWhen
400Bad request, invalid input
401Missing or invalid JWT
403Valid token, insufficient role
404Entity not found
409Conflict (duplicate username, etc.)
422Pydantic validation failure (automatic)
500Unexpected 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

  1. Never return 500 without logging — always logger.error(...) before raising 500
  2. Never expose internal error details in 500 responses — use generic messages
  3. Use correct status codes — 404 for not found, 400 for bad input, never 500 for client errors
  4. Do not silence exceptions — if you catch and don't re-raise, log at ERROR level
  5. Check entity existence before mutation — return 404 before attempting update/delete on missing records