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 khiint()không parse được chuỗi.else: chạy khitrykhông ném exception. Code “đường suôn sẻ” nên đặt ở đây thay vì nhét vàotry.finally: luôn chạy, kể cả khi córeturnhoặ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 BaseExceptionhayexcept: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:
- Code gọi có thể bắt
PaymentErrorđể xử lý mọi lỗi thanh toán, hoặc bắt riêngInsufficientFundsErrorkhi cần. - 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. - 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ằngError, 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 đươnglogger.error(..., exc_info=True). Hàm này chỉ nên gọi bên trongexcept.raisetrần ném lại đúng exception hiện tại và giữ nguyên traceback. Đừng viếtraise 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ầnwarning, 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ớistr(exception).exc_info.valuelà đố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ữ
tryhẹp, đưa code tiếp theo vàoelse, dọn dẹp bằngfinallyhoặcwith. - 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àraisetrần để ném lại. - Dùng
ExceptionGroupvà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.