Chào mừng bạn đến với Bài 8 của khoá học Python FastAPI. Bài này về một chủ đề mà developer nào cũng phải làm, nhưng rất ít người làm tốt: xử lý lỗi.
Một API “xử lý lỗi tốt” không phải chỉ là không crash. Nó là API mà khi có lỗi, client biết chính xác chuyện gì đã xảy ra, có phải lỗi của họ không, và phải làm gì để sửa. Còn với developer backend, log phải đủ thông tin để debug nhanh — không phải đi mò từng ngóc ngách.
Trong bài này, chúng ta sẽ đi từ HTTPException cơ bản (đã gặp ở các bài trước) đến các pattern xử lý lỗi chuyên nghiệp: custom exception hierarchy, global exception handlers, format lỗi thống nhất theo chuẩn RFC 7807, và cách xử lý các tình huống thực tế trong production.
Tiếp tục hoàn thiện TaskAPI! 💪
1. Ba loại lỗi bạn phải xử lý
Trước khi đi vào kỹ thuật, cần phân biệt rõ ba loại lỗi:
- 1. Lỗi của client (4xx): client gửi request sai — thiếu field, sai định dạng, không có quyền, tài nguyên không tồn tại. Đây là lỗi “mong đợi” — API phải trả về thông báo rõ ràng để client sửa.
- 2. Lỗi của server (5xx): code của bạn bị bug, database down, service bên ngoài lỗi. Client không làm gì sai. API phải log đầy đủ chi tiết để debug, nhưng không được leak chi tiết nội bộ ra response.
- 3. Lỗi nghiệp vụ (business errors): nằm ở vùng xám — có thể là 4xx (vd: “không đủ tiền để rút”) hoặc 422 (vd: “không thể set task từ done về pending”). Cần format nhất quán, có error code để client xử lý theo logic.
Mọi thứ chúng ta học trong bài này xoay quanh việc xử lý đúng ba loại lỗi này.
2. HTTPException — Ôn lại và dùng đúng cách
HTTPException là công cụ cơ bản nhất. Bạn đã gặp nó nhiều lần:
from fastapi import FastAPI, HTTPException, status
app = FastAPI()
@app.get("/tasks/{task_id}")
def get_task(task_id: int):
task = _tasks_db.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Task không tồn tại",
headers={"X-Error-Code": "TASK_NOT_FOUND"},
)
return task
Response client nhận được:
{
"detail": "Task không tồn tại"
}
HTTPException ổn cho các trường hợp đơn giản. Nhưng khi API lớn lên, bạn sẽ gặp các vấn đề: format response lỗi không nhất quán, khó thêm error code cho client xử lý, khó log thêm context, khó tái sử dụng thông báo lỗi. Đó là lúc cần các kỹ thuật nâng cao hơn.
3. Cấu trúc response lỗi thống nhất
Trước khi custom hoá, hãy quyết định: format lỗi của API sẽ trông như thế nào? Đây là điều quan trọng nhất — một khi đã public API, bạn khó thay đổi.
Có nhiều cách, nhưng mình khuyên dùng format tương tự RFC 7807 (Problem Details for HTTP APIs) — chuẩn được nhiều big tech áp dụng:
{
"type": "TASK_NOT_FOUND",
"title": "Task không tồn tại",
"status": 404,
"detail": "Task với id=42 không tồn tại hoặc đã bị xoá",
"instance": "/api/v1/tasks/42",
"request_id": "7f3a8b2c-...",
"errors": [
{
"field": "title",
"message": "Tối thiểu 1 ký tự"
}
]
}
Giải thích từng field:
- type: error code máy đọc được (client dùng để xử lý logic).
- title: thông báo ngắn cho user.
- status: HTTP status code (tiện cho client log).
- detail: chi tiết cụ thể của lỗi này.
- instance: path của request bị lỗi.
- request_id: ID để tra log khi debug.
- errors: array cho validation errors chi tiết từng field.
4. Custom Exception Hierarchy
Đầu tiên, xây dựng “cây” exceptions cho business logic của app. Đây là foundation của mọi thứ:
# app/exceptions.py
from typing import Optional, Any
class AppException(Exception):
"""Base exception cho mọi lỗi của app"""
status_code: int = 500
error_code: str = "INTERNAL_ERROR"
title: str = "Lỗi không xác định"
def __init__(
self,
detail: Optional[str] = None,
extra: Optional[dict] = None,
):
self.detail = detail or self.title
self.extra = extra or {}
super().__init__(self.detail)
# ===== 4xx: lỗi client =====
class NotFoundError(AppException):
status_code = 404
error_code = "NOT_FOUND"
title = "Không tìm thấy tài nguyên"
class TaskNotFoundError(NotFoundError):
error_code = "TASK_NOT_FOUND"
title = "Task không tồn tại"
def __init__(self, task_id: int):
super().__init__(
detail=f"Task với id={task_id} không tồn tại",
extra={"task_id": task_id},
)
class UnauthorizedError(AppException):
status_code = 401
error_code = "UNAUTHORIZED"
title = "Chưa xác thực"
class ForbiddenError(AppException):
status_code = 403
error_code = "FORBIDDEN"
title = "Không có quyền truy cập"
class ConflictError(AppException):
status_code = 409
error_code = "CONFLICT"
title = "Xung đột dữ liệu"
class DuplicateTaskTitleError(ConflictError):
error_code = "DUPLICATE_TASK_TITLE"
title = "Tiêu đề task đã tồn tại"
def __init__(self, title: str):
super().__init__(
detail=f"Đã có task với tiêu đề '{title}'",
extra={"duplicate_title": title},
)
# ===== Business rule errors =====
class BusinessRuleError(AppException):
status_code = 422
error_code = "BUSINESS_RULE_VIOLATION"
title = "Vi phạm ràng buộc nghiệp vụ"
class InvalidStatusTransitionError(BusinessRuleError):
error_code = "INVALID_STATUS_TRANSITION"
title = "Không thể chuyển trạng thái"
def __init__(self, from_status: str, to_status: str):
super().__init__(
detail=f"Không thể chuyển task từ {from_status} sang {to_status}",
extra={"from": from_status, "to": to_status},
)
# ===== 5xx: lỗi server =====
class ServiceUnavailableError(AppException):
status_code = 503
error_code = "SERVICE_UNAVAILABLE"
title = "Dịch vụ tạm thời không khả dụng"
class ExternalAPIError(AppException):
status_code = 502
error_code = "EXTERNAL_API_ERROR"
title = "Lỗi từ dịch vụ bên ngoài"
Tại sao phải dùng exception hierarchy thay vì HTTPException ở mọi nơi? Vài lý do quan trọng:
- Tách biệt business logic với HTTP details: service layer có thể
raise TaskNotFoundError(42)mà không cần biết về HTTP, status code hay JSON format. Có thể test service layer mà không cần FastAPI. - Dễ tìm kiếm và thống kê: tìm mọi chỗ raise
DuplicateTaskTitleErrorđể biết khi nào logic này được trigger. VớiHTTPException(409, "...")thì không tìm được.
Dễ dàng catch theo nhóm: có thể catch NotFoundError để xử lý tất cả lỗi “không tìm thấy” cùng cách.
5. Global Exception Handlers
Bây giờ đến phần “thần kỳ”: gắn tất cả custom exceptions vào FastAPI thông qua exception handlers. Bạn định nghĩa handler MỘT LẦN, mọi endpoint trong app tự động dùng chung.
# app/error_handlers.py
import logging
import traceback
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.exceptions import AppException
logger = logging.getLogger("taskapi")
def _error_response(
request: Request,
status_code: int,
error_code: str,
title: str,
detail: str,
errors: list = None,
extra: dict = None,
) -> JSONResponse:
"""Helper tạo response lỗi theo format thống nhất"""
body = {
"type": error_code,
"title": title,
"status": status_code,
"detail": detail,
"instance": str(request.url.path),
"request_id": request.headers.get("X-Request-ID", "unknown"),
}
if errors:
body["errors"] = errors
if extra:
body["extra"] = extra
return JSONResponse(status_code=status_code, content=body)
def register_exception_handlers(app: FastAPI):
"""Đăng ký toàn bộ exception handlers"""
# Handler cho custom AppException (và mọi subclass)
@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException):
# Log warning cho 4xx, error cho 5xx
log_fn = logger.error if exc.status_code >= 500 else logger.warning
log_fn(
f"[{exc.error_code}] {exc.detail} "
f"at {request.method} {request.url.path}",
extra={"error_extra": exc.extra},
)
return _error_response(
request=request,
status_code=exc.status_code,
error_code=exc.error_code,
title=exc.title,
detail=exc.detail,
extra=exc.extra if exc.extra else None,
)
# Handler cho lỗi validation (Pydantic)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request,
exc: RequestValidationError,
):
errors = []
for err in exc.errors():
# loc là tuple, vd: ("body", "title") hoặc ("query", "page")
location = ".".join(str(x) for x in err["loc"][1:])
errors.append({
"field": location,
"message": err["msg"],
"type": err["type"],
})
logger.info(
f"Validation error at {request.url.path}: {len(errors)} fields"
)
return _error_response(
request=request,
status_code=422,
error_code="VALIDATION_ERROR",
title="Dữ liệu không hợp lệ",
detail="Một hoặc nhiều field không đạt yêu cầu",
errors=errors,
)
# Handler cho HTTPException (FastAPI built-in)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(
request: Request,
exc: StarletteHTTPException,
):
# Map status code → error_code
error_code_map = {
401: "UNAUTHORIZED",
403: "FORBIDDEN",
404: "NOT_FOUND",
405: "METHOD_NOT_ALLOWED",
429: "TOO_MANY_REQUESTS",
}
return _error_response(
request=request,
status_code=exc.status_code,
error_code=error_code_map.get(exc.status_code, "HTTP_ERROR"),
title=str(exc.detail),
detail=str(exc.detail),
)
# Handler cho MỌI exception khác (chưa được handle)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
# Log đầy đủ traceback để debug
logger.exception(
f"Unhandled exception at {request.method} {request.url.path}: "
f"{type(exc).__name__}: {exc}"
)
# KHÔNG BAO GIỜ leak chi tiết nội bộ ra client
return _error_response(
request=request,
status_code=500,
error_code="INTERNAL_SERVER_ERROR",
title="Lỗi hệ thống",
detail="Đã có lỗi xảy ra. Vui lòng liên hệ support với request_id.",
)
Đăng ký trong main.py:
# app/main.py
from fastapi import FastAPI
from app.error_handlers import register_exception_handlers
from app.routers import tasks
app = FastAPI(title="TaskAPI")
register_exception_handlers(app)
app.include_router(tasks.router, prefix="/api/v1")
6. Sử dụng trong code thực tế
Giờ code service và router trở nên cực kỳ sạch. Service layer không biết gì về HTTP:
# app/services/task_service.py
from app.exceptions import (
TaskNotFoundError,
DuplicateTaskTitleError,
InvalidStatusTransitionError,
)
_VALID_TRANSITIONS = {
"pending": ["in_progress", "done"],
"in_progress": ["pending", "done"],
"done": [], # done là trạng thái cuối, không chuyển đi đâu nữa
}
class TaskService:
def __init__(self, db):
self.db = db
def get_task(self, task_id: int) -> dict:
task = self.db.get(task_id)
if not task:
raise TaskNotFoundError(task_id)
return task
def create_task(self, data: dict) -> dict:
if self._title_exists(data["title"]):
raise DuplicateTaskTitleError(data["title"])
return self._save(data)
def change_status(self, task_id: int, new_status: str) -> dict:
task = self.get_task(task_id)
current = task["status"]
if new_status not in _VALID_TRANSITIONS.get(current, []):
raise InvalidStatusTransitionError(current, new_status)
task["status"] = new_status
return task
Router cũng sạch không kém — không có try/except lộn xộn:
# app/routers/tasks.py
from fastapi import APIRouter, Depends
from app.services.task_service import TaskService
from app.models.task import TaskCreate, TaskResponse
router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.get("/{task_id}", response_model=TaskResponse)
def get_task(
task_id: int,
service: TaskService = Depends(get_task_service),
):
# Nếu task không tồn tại, service raise TaskNotFoundError
# Exception handler tự xử lý, trả về 404 đúng format
return service.get_task(task_id)
@router.post("/", response_model=TaskResponse, status_code=201)
def create_task(
task: TaskCreate,
service: TaskService = Depends(get_task_service),
):
return service.create_task(task.model_dump())
@router.patch("/{task_id}/status", response_model=TaskResponse)
def change_status(
task_id: int,
new_status: str,
service: TaskService = Depends(get_task_service),
):
return service.change_status(task_id, new_status)
Không có HTTPException(404, ...), không có try/except cồng kềnh. Router chỉ làm một việc: gọi service và trả kết quả. Business logic ở service. HTTP translation ở exception handlers. Rất sạch.
7. Validation Errors — Format đẹp hơn
Mặc định khi Pydantic validation fail, client nhận format hơi rối:
{
"detail": [
{
"type": "string_too_short",
"loc": ["body", "title"],
"msg": "String should have at least 1 character",
"input": "",
"ctx": {"min_length": 1}
}
]
}
Với handler ở phần trên, client nhận được:
{
"type": "VALIDATION_ERROR",
"title": "Dữ liệu không hợp lệ",
"status": 422,
"detail": "Một hoặc nhiều field không đạt yêu cầu",
"instance": "/api/v1/tasks",
"request_id": "7f3a8b2c-...",
"errors": [
{
"field": "title",
"message": "String should have at least 1 character",
"type": "string_too_short"
},
{
"field": "priority",
"message": "Input should be 'low', 'medium' or 'high'",
"type": "enum"
}
]
}
Dễ hiểu hơn, dễ hiển thị UI theo từng field hơn. Frontend dev sẽ rất cảm ơn bạn.
8. Localization — Thông báo đa ngôn ngữ
Pydantic validation messages mặc định bằng tiếng Anh. Nếu API phục vụ user Việt, nên dịch hoặc custom:
# app/i18n.py
_ERROR_MESSAGES = {
"string_too_short": "Độ dài tối thiểu là {min_length} ký tự",
"string_too_long": "Độ dài tối đa là {max_length} ký tự",
"greater_than": "Phải lớn hơn {gt}",
"less_than_equal": "Phải nhỏ hơn hoặc bằng {le}",
"missing": "Field bắt buộc",
"int_parsing": "Phải là số nguyên",
"enum": "Giá trị không hợp lệ, phải là một trong: {expected}",
}
def translate_validation_error(err: dict) -> str:
err_type = err["type"]
template = _ERROR_MESSAGES.get(err_type)
if not template:
return err["msg"]
ctx = err.get("ctx", {})
try:
return template.format(**ctx)
except (KeyError, IndexError):
return err["msg"]
Gắn vào validation handler:
from app.i18n import translate_validation_error
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
errors = []
for err in exc.errors():
location = ".".join(str(x) for x in err["loc"][1:])
errors.append({
"field": location,
"message": translate_validation_error(err), # <-- tiếng Việt
"type": err["type"],
})
...
9. Xử lý lỗi từ service bên ngoài
Khi app của bạn gọi API khác (payment gateway, email service…), lỗi có thể đến từ mạng, timeout, hoặc chính dịch vụ đó trả lỗi. Cần xử lý gọn gàng:
import httpx
from app.exceptions import ExternalAPIError, ServiceUnavailableError
async def send_notification(user_email: str, message: str):
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.post(
"https://api.notifications.example.com/send",
json={"to": user_email, "message": message},
)
response.raise_for_status()
return response.json()
except httpx.TimeoutException:
# Log với đầy đủ context, raise lỗi friendly với client
logger.error(f"Notification timeout for {user_email}")
raise ServiceUnavailableError(
detail="Dịch vụ notification đang phản hồi chậm",
)
except httpx.HTTPStatusError as e:
logger.error(
f"Notification API returned {e.response.status_code}: "
f"{e.response.text}"
)
raise ExternalAPIError(
detail="Dịch vụ notification trả lỗi",
extra={"upstream_status": e.response.status_code},
)
except httpx.RequestError as e:
logger.exception(f"Cannot reach notification API: {e}")
raise ServiceUnavailableError(
detail="Không thể kết nối dịch vụ notification",
)
Ba nguyên tắc quan trọng:
Luôn đặt timeout cho HTTP client — nếu không, một service chết có thể treo toàn bộ API của bạn. Log đầy đủ chi tiết lỗi gốc (status, body, stack trace) ở server. Che giấu chi tiết nội bộ khỏi client — họ không cần biết bạn đang gọi API nào, chỉ cần biết “không gọi được dịch vụ notification”.
10. Retry với tenacity
Nhiều lỗi network là tạm thời — retry có thể giải quyết. Thư viện tenacity làm điều này cực kỳ đẹp:
pip install tenacity
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
)
import httpx
@retry(
stop=stop_after_attempt(3), # Tối đa 3 lần
wait=wait_exponential(multiplier=1, min=1, max=10), # 1s, 2s, 4s, 8s...
retry=retry_if_exception_type((
httpx.TimeoutException,
httpx.ConnectError,
)), # Chỉ retry với lỗi network
)
async def fetch_user_data(user_id: int) -> dict:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"https://api.example.com/users/{user_id}")
response.raise_for_status()
return response.json()
Chú ý: chỉ retry với lỗi tạm thời (timeout, connection error). Đừng retry với 400 (lỗi của request) hay 401 (auth sai) — retry 100 lần cũng vẫn sai.
11. Circuit Breaker — Ngắt mạch khi service chết
Khi một service bên ngoài chết, nếu bạn cứ retry hoài, bạn sẽ: tốn resources vô ích, làm service đó chết lâu hơn, làm API của bạn chậm theo. Circuit breaker giải quyết điều này — khi thấy service fail nhiều lần, tạm thời “ngắt mạch” và fail ngay lập tức trong một khoảng thời gian:
pip install purgatory
from purgatory import AsyncCircuitBreakerFactory
circuitbreaker = AsyncCircuitBreakerFactory(
default_threshold=5, # Sau 5 lỗi liên tiếp...
default_ttl=30, # ...ngắt mạch 30 giây
)
async def call_external_service(user_id: int):
async with await circuitbreaker.get_breaker("notifications") as breaker:
async with breaker.context():
return await fetch_user_data(user_id)
Circuit breaker là pattern nâng cao — không phải app nào cũng cần. Dùng khi bạn có nhiều dependency bên ngoài và cần đảm bảo app không sập dây chuyền khi một service chết.
12. Logging lỗi có cấu trúc
Log là thứ bạn chỉ quan tâm khi có sự cố — và lúc đó là quá muộn để viết lại cho tốt. Đầu tư ngay từ đầu:
# app/logging_config.py
import logging
import sys
import json
from datetime import datetime
class JSONFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
log_data = {
"timestamp": datetime.utcnow().isoformat() + "Z",
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"module": record.module,
"line": record.lineno,
}
# Thêm exception info nếu có
if record.exc_info:
log_data["exception"] = self.formatException(record.exc_info)
# Thêm extra data nếu logger được gọi với extra={...}
for key, value in record.__dict__.items():
if key not in {
"name", "msg", "args", "created", "filename", "funcName",
"levelname", "levelno", "lineno", "module", "msecs",
"message", "pathname", "process", "processName",
"relativeCreated", "thread", "threadName", "exc_info",
"exc_text", "stack_info",
}:
log_data[key] = value
return json.dumps(log_data, ensure_ascii=False)
def setup_logging(level: str = "INFO"):
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JSONFormatter())
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(level)
# Giảm độ nhiều của thư viện bên thứ ba
logging.getLogger("uvicorn.access").setLevel("WARNING")
logging.getLogger("httpx").setLevel("WARNING")
Log JSON có cấu trúc cho phép tool như ELK, Datadog, CloudWatch parse được — query kiểu “tìm mọi log có error_code=TASK_NOT_FOUND trong 1 giờ qua” trở thành cực kỳ đơn giản.
13. Xử lý lỗi database
Lỗi database đặc biệt nguy hiểm vì chúng thường leak thông tin nhạy cảm (schema, SQL queries). Xử lý cẩn thận:
from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError
from app.exceptions import (
ConflictError,
ServiceUnavailableError,
AppException,
)
class DatabaseError(AppException):
status_code = 500
error_code = "DATABASE_ERROR"
title = "Lỗi cơ sở dữ liệu"
@app.exception_handler(IntegrityError)
async def integrity_error_handler(request, exc):
"""Lỗi ràng buộc DB (unique, foreign key...)"""
logger.warning(f"Integrity error: {exc}", extra={"query": str(exc.statement)})
# Check loại lỗi từ message (PostgreSQL, MySQL có format khác nhau)
msg = str(exc.orig).lower()
if "unique" in msg or "duplicate" in msg:
raise ConflictError(detail="Dữ liệu đã tồn tại")
if "foreign key" in msg:
raise ConflictError(detail="Vi phạm ràng buộc khoá ngoại")
# Lỗi integrity khác
raise DatabaseError(detail="Vi phạm ràng buộc cơ sở dữ liệu")
@app.exception_handler(OperationalError)
async def operational_error_handler(request, exc):
"""DB down, timeout, connection lost..."""
logger.error(f"DB operational error: {exc}")
raise ServiceUnavailableError(
detail="Cơ sở dữ liệu tạm thời không khả dụng"
)
@app.exception_handler(SQLAlchemyError)
async def sqlalchemy_error_handler(request, exc):
"""Mọi lỗi SQLAlchemy khác"""
logger.exception(f"Unhandled DB error: {exc}")
raise DatabaseError(detail="Lỗi cơ sở dữ liệu")
Chú ý: không bao giờ trả raw error message của DB ra response — nó có thể chứa tên bảng, tên cột, hoặc cả SQL query gây leak thông tin hệ thống.
14. Timeout cho mọi thứ
Đây là nguyên tắc mà mình học được “bằng máu” trong production:
Mọi I/O operation đều PHẢI có timeout. Không có ngoại lệ.
# HTTP client
async with httpx.AsyncClient(timeout=5.0) as client:
...
# Database query (với SQLAlchemy)
engine = create_engine(
DATABASE_URL,
connect_args={"connect_timeout": 5},
pool_pre_ping=True,
pool_recycle=3600,
)
# Redis
redis_client = Redis(
host="localhost",
socket_timeout=3,
socket_connect_timeout=3,
)
# Uvicorn timeout cho request xử lý quá lâu
# uvicorn app.main:app --timeout-keep-alive 30
Lý do: một dependency chậm không được phép biến thành một app chết. Timeout đảm bảo app của bạn fail nhanh và rõ ràng thay vì “treo lưng chừng”.
15. Sentry — Error monitoring cho production
Log file tốt, nhưng không ai đọc log chủ động. Với production, bạn cần công cụ tự động báo khi có lỗi — phổ biến nhất là Sentry:
pip install "sentry-sdk[fastapi]"
# app/main.py
import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration
from app.config import settings
if settings.sentry_dsn:
sentry_sdk.init(
dsn=settings.sentry_dsn,
environment=settings.environment, # production, staging...
traces_sample_rate=0.1, # Sample 10% request để performance monitoring
integrations=[FastApiIntegration()],
)
# Và thế là xong - mọi unhandled exception sẽ tự động gửi lên Sentry
Sentry cho bạn: email/Slack alert khi có lỗi, stack trace đầy đủ với local variables, group các lỗi giống nhau lại (để không bị spam), performance monitoring.
16. Test exception handling
Đừng quên test cả happy path lẫn error path:
# tests/test_tasks.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_get_nonexistent_task_returns_404():
response = client.get("/api/v1/tasks/99999")
assert response.status_code == 404
body = response.json()
assert body["type"] == "TASK_NOT_FOUND"
assert body["status"] == 404
assert "99999" in body["detail"]
def test_create_task_with_invalid_data_returns_422():
response = client.post(
"/api/v1/tasks",
json={"title": "", "priority": "super-high"},
)
assert response.status_code == 422
body = response.json()
assert body["type"] == "VALIDATION_ERROR"
assert len(body["errors"]) >= 2
fields = {e["field"] for e in body["errors"]}
assert "title" in fields
assert "priority" in fields
def test_duplicate_title_returns_409():
client.post("/api/v1/tasks", json={"title": "Unique Task"})
response = client.post("/api/v1/tasks", json={"title": "Unique Task"})
assert response.status_code == 409
assert response.json()["type"] == "DUPLICATE_TASK_TITLE"
Chúng ta sẽ đi sâu về testing ở Bài 12.
17. Checklist xử lý lỗi production-ready
Trước khi deploy lên production, đảm bảo API của bạn có:
✅ Exception hierarchy rõ ràng, tách biệt client error và server error.
✅ Global exception handlers cho custom exceptions, validation errors, HTTPException, và mọi exception chưa handle.
✅ Response format thống nhất với error code, request ID, và timestamp.
✅ Validation errors trả về chi tiết từng field.
✅ Log có cấu trúc (JSON) với đầy đủ context.
✅ Timeout cho mọi I/O (HTTP, DB, Redis, file).
✅ Retry với exponential backoff cho các gọi không critical.
✅ Không leak chi tiết nội bộ ra client (stack trace, SQL, internal paths).
✅ Error monitoring tool (Sentry hoặc tương đương) trên production.
✅ Test cho các error scenarios.
18. Bài tập
Để củng cố, thử các bài tập sau:
- Thêm exception
TaskLimitExceededError— user chỉ được tạo tối đa 100 task. - Implement full exception hierarchy và tests.
- Viết middleware inject request ID vào mọi log (sử dụng
contextvarsđể gắn request ID vào logging context tự động). - Tạo endpoint
POST /tasks/{id}/statusvới business rule: không thể chuyển từ “done” về “pending” (raiseInvalidStatusTransitionError). - Test edge case: task đã hết hạn không thể đánh dấu pending.
- Implement rate limiting middleware trả về
429 Too Many Requestsvới format lỗi thống nhất và headerRetry-After. - Thêm test cho các error cases của TaskAPI hiện tại và đảm bảo mọi response lỗi đều đúng format RFC 7807.
Tổng kết
- Bài này đi từ
HTTPExceptioncơ bản đến xử lý lỗi cấp production-ready: - Ba loại lỗi — client, server, business — và cách xử lý từng loại khác nhau.
- Exception hierarchy tách business logic ra khỏi HTTP details.
- Global exception handlers tập trung logic, endpoint trở nên cực kỳ sạch.
- Response format thống nhất theo RFC 7807 cho mọi loại lỗi.
- Validation errors đẹp và có cấu trúc với thông báo i18n.
- Xử lý lỗi từ external services: timeout, retry, circuit breaker.
- Structured logging với JSON format cho observability.
- Error monitoring với Sentry cho production.
Những pattern này không chỉ áp dụng cho FastAPI — chúng là nguyên tắc thiết kế API chung. Một khi bạn làm quen, mọi project sau này đều sẽ có phần xử lý lỗi chuyên nghiệp ngay từ đầu.
Ở Bài 9 tiếp theo, chúng ta sẽ đi vào một chủ đề bắt buộc cho mọi API thực tế: xác thực và ủy quyền. Bạn sẽ học cách implement authentication với JWT, OAuth2, password hashing, role-based access control, và các best practices bảo mật — những kiến thức thiết yếu để API của bạn sẵn sàng cho người dùng thật.
