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ới HTTPException(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}/status với business rule: không thể chuyển từ “done” về “pending” (raise InvalidStatusTransitionError).
  • Test edge case: task đã hết hạn không thể đánh dấu pending.
  • Implement rate limiting middleware trả về 429 Too Many Requests với format lỗi thống nhất và header Retry-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ừ HTTPException cơ 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.

Leave a Reply

Discover more from Bệ Phóng Việt

Subscribe now to keep reading and get access to the full archive.

Continue reading