Xử lý ngoại lệ trong Python đúng cách

1. Tổng quan

Python khuyến khích phong cách EAFP: cứ làm, nếu lỗi thì xử lý. Vì vậy exception xuất hiện ở khắp nơi trong code Python. Dùng sai cách sẽ gây ra lỗi bị nuốt mất, traceback khó đọc và log trùng lặp.

Bài này đi qua các kỹ thuật cốt lõi: try/except/else/finally, bắt đúng loại exception, tự định nghĩa hệ thống exception, nối exception bằng raise ... from, ghi log, ExceptionGroup và cách viết test. Đọc xong, bạn sẽ có một bộ quy tắc rõ ràng để áp dụng ngay vào dự án. Các ví dụ dùng Python 3.11 trở lên.

2. Cấu trúc try/except/else/finally

Một khối xử lý ngoại lệ đầy đủ có bốn phần:

def parse_quantity(raw: str) -> int:
    try:
        quantity = int(raw)
    except ValueError:
        print(f"Số lượng không hợp lệ: {raw!r}")
        return 0
    else:
        print("Parse thành công")
        return quantity
    finally:
        print("Kết thúc parse")


print(parse_quantity("3"))
print(parse_quantity("ba"))

Vai trò của từng phần:

  • try: chỉ chứa đoạn code có thể ném exception, ở đây là int(raw).
  • except ValueError: chạy khi int() không parse được chuỗi.
  • else: chạy khi try không ném exception. Code “đường suôn sẻ” nên đặt ở đây thay vì nhét vào try.
  • finally: luôn chạy, kể cả khi có return hoặc exception. Phần này hợp để dọn dẹp tài nguyên.

Kết quả:

Parse thành công
Kết thúc parse
3
Số lượng không hợp lệ: 'ba'
Kết thúc parse
0

Bạn để ý rằng finally chạy trước khi giá trị trả về được in ra. Python thực thi finally ngay trước khi rời hàm.

2.1. Vì sao nên dùng else

So sánh hai cách viết sau:

# Không tốt: try bao quá rộng
try:
    quantity = int(raw)
    total = quantity * unit_price  # nếu unit_price sai kiểu thì sao?
except ValueError:
    total = 0

Nếu unit_price là một chuỗi, phép nhân không ném ValueError. Tuy vậy, nhiều phép tính khác có thể ném ValueError từ chỗ bạn không ngờ tới, và lỗi đó sẽ bị coi nhầm là “số lượng sai”. Hãy giữ try càng hẹp càng tốt. Phần còn lại đưa vào else.

3. Bắt exception cụ thể, đừng bắt tất cả

Lỗi phổ biến nhất là dùng except: trần hoặc except Exception: rồi bỏ qua:

import time

def fetch_order_status(order_id: str) -> str:
    try:
        time.sleep(10)  # giả lập gọi API chậm
        return "PAID"
    except:  # rất tệ
        return "UNKNOWN"

except: trần bắt luôn cả KeyboardInterrupt và SystemExit. Khi bạn bấm Ctrl+C, chương trình không dừng mà trả về "UNKNOWN". Hệ thống exception của Python được phân tầng như sau:

BaseException
 ├── SystemExit
 ├── KeyboardInterrupt
 ├── GeneratorExit
 └── Exception
      ├── ValueError
      ├── KeyError
      ├── OSError
      └── ...

Quy tắc:

  • Bắt loại hẹp nhất mà bạn thực sự xử lý được, ví dụ KeyError, TimeoutError.
  • Chỉ dùng except Exception ở “biên” của ứng dụng: vòng lặp worker, handler HTTP cấp cao nhất. Khi dùng, luôn ghi log.
  • Không bao giờ dùng except BaseException hay except: trừ khi bạn ném lại ngay.

Bạn có thể bắt nhiều loại trong một nhánh bằng tuple:

def read_price(prices: dict[str, str], sku: str) -> float:
    try:
        return float(prices[sku])
    except (KeyError, ValueError) as exc:
        print(f"Không đọc được giá của {sku}: {type(exc).__name__}")
        return 0.0


print(read_price({"A1": "12.5", "B2": "abc"}, "B2"))
print(read_price({"A1": "12.5"}, "Z9"))
Không đọc được giá của B2: ValueError
0.0
Không đọc được giá của Z9: KeyError
0.0

4. Tự định nghĩa exception cho domain

Với nghiệp vụ riêng, bạn nên tạo một exception gốc cho module, rồi kế thừa từ nó:

class PaymentError(Exception):
    """Lỗi gốc của module thanh toán."""


class InsufficientFundsError(PaymentError):
    def __init__(self, order_id: str, balance: float, amount: float) -> None:
        super().__init__(
            f"Đơn {order_id}: số dư {balance} không đủ để trả {amount}"
        )
        self.order_id = order_id
        self.balance = balance
        self.amount = amount


class CardDeclinedError(PaymentError):
    pass


class PaymentService:
    def __init__(self, balance: float) -> None:
        self.balance = balance

    def charge(self, order_id: str, amount: float) -> None:
        if amount > self.balance:
            raise InsufficientFundsError(order_id, self.balance, amount)
        self.balance -= amount

Cách dùng:

service = PaymentService(balance=100)

try:
    service.charge("ORD-42", 250)
except InsufficientFundsError as exc:
    print(exc)
    print("Thiếu:", exc.amount - exc.balance)
except PaymentError:
    print("Lỗi thanh toán khác")
Đơn ORD-42: số dư 100 không đủ để trả 250
Thiếu: 150

Cách tổ chức này có ba lợi ích:

  1. Code gọi có thể bắt PaymentError để xử lý mọi lỗi thanh toán, hoặc bắt riêng InsufficientFundsError khi cần.
  2. Exception mang theo dữ liệu có cấu trúc (order_id, balance, amount) thay vì chỉ một chuỗi. Nơi xử lý không phải parse message.
  3. Lỗi của bạn tách biệt khỏi lỗi hệ thống như ValueError, nên không bị bắt nhầm.

Lưu ý: Kế thừa từ Exception, không kế thừa từ BaseException. Tên lớp nên kết thúc bằng Error, theo quy ước PEP 8.

5. Nối exception với raise ... from

Khi chuyển một lỗi cấp thấp thành lỗi domain, bạn nên giữ lại nguyên nhân gốc:

import json


class ConfigError(Exception):
    pass


def load_config(text: str) -> dict:
    try:
        return json.loads(text)
    except json.JSONDecodeError as exc:
        raise ConfigError("File cấu hình không hợp lệ") from exc


load_config("{port: 8080}")

Traceback sẽ hiển thị cả hai lỗi:

json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  ...
ConfigError: File cấu hình không hợp lệ

from exc gán lỗi gốc vào thuộc tính __cause__. Người debug thấy ngay lỗi JSON nằm ở cột nào.

Nếu bạn bỏ from exc, Python vẫn nối lỗi qua __context__, nhưng thông báo sẽ là “During handling of the above exception, another exception occurred”. Câu này ngụ ý handler của bạn bị lỗi, trong khi thực tế không phải vậy. Hãy luôn viết from một cách tường minh.

Khi lỗi gốc chỉ là chi tiết nội bộ không có ích, dùng from None để ẩn nó:

def get_customer_tier(tiers: dict[str, str], customer_id: str) -> str:
    try:
        return tiers[customer_id]
    except KeyError:
        raise LookupError(f"Không tìm thấy khách hàng {customer_id}") from None

6. Dọn dẹp tài nguyên bằng context manager

Với file, connection hay lock, with an toàn hơn try/finally viết tay:

from pathlib import Path

def count_orders(path: Path) -> int:
    with path.open(encoding="utf-8") as f:
        return sum(1 for line in f if line.strip())

File luôn được đóng, kể cả khi vòng lặp ném exception.

Khi bạn thực sự muốn bỏ qua một loại lỗi, contextlib.suppress nói rõ ý định hơn try/except: pass:

from contextlib import suppress
from pathlib import Path

with suppress(FileNotFoundError):
    Path("cache/orders.tmp").unlink()

Đọc dòng này, người khác hiểu ngay là “xóa file nếu có, không có thì thôi”.

7. Ghi log và ném lại exception

Trong khối except, dùng logger.exception() để ghi kèm traceback đầy đủ:

import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("orders")


def process_order(service: PaymentService, order_id: str, amount: float) -> bool:
    try:
        service.charge(order_id, amount)
    except InsufficientFundsError:
        logger.warning("Đơn %s bị từ chối do thiếu tiền", order_id)
        return False
    except PaymentError:
        logger.exception("Lỗi thanh toán không xác định cho đơn %s", order_id)
        raise
    return True

Các điểm cần chú ý:

  • logger.exception(...) tương đương logger.error(..., exc_info=True). Hàm này chỉ nên gọi bên trong except.
  • raise trần ném lại đúng exception hiện tại và giữ nguyên traceback. Đừng viết raise exc, vì traceback sẽ bị thêm một dòng trỏ vào chính chỗ này.
  • Lỗi đã biết và nằm trong dự kiến (InsufficientFundsError) chỉ cần warning, không cần traceback.

Lưu ý: Tránh mẫu “log rồi ném lại” ở mọi tầng. Một lỗi sẽ xuất hiện năm lần trong log và gây nhiễu. Chỉ ghi log ở nơi thực sự xử lý lỗi, hoặc ở biên ngoài cùng của ứng dụng.

8. Xử lý nhiều lỗi cùng lúc với ExceptionGroup

Từ Python 3.11, bạn có thể ném nhiều exception cùng lúc. Cách này hữu ích khi validate dữ liệu hoặc chạy nhiều task song song:

def validate_order(order: dict) -> None:
    errors: list[Exception] = []
    if order.get("quantity", 0) <= 0:
        errors.append(ValueError("quantity phải lớn hơn 0"))
    if not order.get("customer"):
        errors.append(KeyError("customer"))
    if errors:
        raise ExceptionGroup("Đơn hàng không hợp lệ", errors)


try:
    validate_order({"quantity": 0})
except* ValueError as group:
    for e in group.exceptions:
        print("Giá trị sai:", e)
except* KeyError as group:
    for e in group.exceptions:
        print("Thiếu trường:", e)
Giá trị sai: quantity phải lớn hơn 0
Thiếu trường: 'customer'

Khác với except thường, nhiều nhánh except* có thể cùng chạy. Mỗi nhánh nhận một ExceptionGroup con chỉ chứa các lỗi khớp loại. Nếu còn lỗi chưa được nhánh nào xử lý, chúng sẽ tự động được ném tiếp.

asyncio.TaskGroup cũng dùng cơ chế này. Khi nhiều task con thất bại, bạn nhận được một ExceptionGroup.

9. Test hành vi ngoại lệ với pytest

Cài pytest:

pip install pytest

Viết test cho PaymentService và load_config:

# test_payment.py
import pytest

from payment import (
    ConfigError,
    InsufficientFundsError,
    PaymentService,
    load_config,
)


def test_charge_raises_when_balance_is_not_enough():
    service = PaymentService(balance=100)

    with pytest.raises(InsufficientFundsError, match="ORD-42") as exc_info:
        service.charge("ORD-42", 250)

    assert exc_info.value.amount == 250
    assert service.balance == 100  # số dư không bị trừ


def test_load_config_keeps_original_cause():
    with pytest.raises(ConfigError) as exc_info:
        load_config("{port: 8080}")

    assert exc_info.value.__cause__ is not None
    assert type(exc_info.value.__cause__).__name__ == "JSONDecodeError"

Giải thích:

  • pytest.raises(...) khiến test thất bại nếu không có exception, hoặc exception sai loại.
  • match= nhận một regex và so khớp với str(exception).
  • exc_info.value là đối tượng exception thật. Nhờ đó bạn kiểm tra được các thuộc tính như amount, __cause__.
  • Test thứ nhất còn kiểm tra trạng thái sau lỗi: số dư phải giữ nguyên. Đây là phần hay bị quên.

Chạy test:

pytest -q
..                                                    [100%]
2 passed in 0.02s

10. Những lỗi hay gặp

Nuốt exception im lặng.

try:
    service.charge(order_id, amount)
except Exception:
    pass  # đơn hàng "thành công" dù chưa trừ tiền

Lỗi kiểu này gần như không thể debug. Nếu thực sự cần bỏ qua, hãy dùng suppress với loại exception cụ thể và ghi chú lý do.

return trong finally.

def risky() -> str:
    try:
        raise RuntimeError("mất dữ liệu")
    finally:
        return "ok"  # exception biến mất!

return, break hoặc continue trong finally sẽ hủy exception đang lan truyền. Từ Python 3.14, trình thông dịch đưa ra SyntaxWarning cho trường hợp này. Đừng bao giờ viết như vậy.

Ném Exception chung chung. raise Exception("lỗi") buộc người gọi phải bắt Exception, tức là bắt cả mọi thứ khác. Hãy dùng exception có sẵn phù hợp (ValueError, TypeError) hoặc exception domain.

Dùng exception cho luồng thông thường trong vòng lặp nóng. EAFP là phong cách Pythonic. Nhưng nếu trường hợp “lỗi” xảy ra phần lớn thời gian, dict.get() hoặc một phép kiểm tra if vừa nhanh hơn vừa rõ ràng hơn:

# Tốt hơn khi key thường xuyên vắng mặt
discount = discounts.get(customer_id, 0)

assert để validate input. assert bị tắt khi chạy python -O. Hãy dùng raise ValueError(...) cho dữ liệu đến từ bên ngoài.

11. Kết luận

Xử lý ngoại lệ đúng cách trong Python xoay quanh vài nguyên tắc đơn giản:

  • Giữ try hẹp, đưa code tiếp theo vào else, dọn dẹp bằng finally hoặc with.
  • Bắt loại exception cụ thể nhất. Chỉ bắt Exception ở biên ứng dụng và luôn ghi log.
  • Tạo hệ thống exception riêng cho domain, mang theo dữ liệu có cấu trúc.
  • Dùng raise ... from exc để giữ nguyên nhân gốc, và raise trần để ném lại.
  • Dùng ExceptionGroup và except* khi có nhiều lỗi xảy ra đồng thời.
  • Viết test với pytest.raises để kiểm tra cả loại lỗi lẫn trạng thái sau lỗi.

Áp dụng những quy tắc này, traceback của bạn sẽ dễ đọc hơn, log gọn hơn, và lỗi không còn bị nuốt mất một cách âm thầm.

pythonexceptionerror-handlingpytestbest-practices