مدیریت خطا در پایتون
مدیریت خطا در پایتون فقط try و except نیست؛ تفاوت بین برنامهای که در تولید زنده میماند و اسکریپتی که وسط کار سقوط میکند، در همین الگوهاست. از استثن
سالها پیش، در یک پروژهی پردازش سفارش، برنامهای نوشته بودم که هر شب، فایل CSV سفارشهای روز را میخواند و در دیتابیس ذخیره میکرد. هفتهی اول بینقص کار کرد. هفتهی دوم، یک شب فایل CSV ناقص آمد و برنامه با یک KeyError ساده، کل کار را رها کرد. صبح که آمدم، فهمیدم نصف سفارشها پردازش نشده و مشتری از نیمهشب پیام داده بود. آن شب برایم روشن شد که مدیریت خطا در پایتون مهارتی نیست که بعد از تسلط بر زبان یاد بگیرید؛ یک تصمیم معماری است که از همان خط اول باید گرفته شود. در این مقاله، همان الگوهایی را که در پروژههای واقعی بهکار بردهام مرور میکنم: از تفاوت خطا و استثنا تا استثناهای سفارشی، context manager، logging و anti-patternهایی که در بازبینی کد دیگران زیاد دیدهام.
خطا و استثنا: تفاوت را بشناسید
اگر با مفاهیم پایهی پایتون آشنا نیستید، اول آموزش پایتون از صفر را بخوانید. اما فرض کنیم پایتون را میشناسید و میخواهید بحث مدیریت خطا را درست شروع کنید. اولین تفکیک مهم این است: خطای نحوی (Syntax Error) با استثنا (Exception) متفاوت است.
خطای نحوی یعنی کد شما ساختار معتبری ندارد و مفسر پایتون حتی نمیتواند آن را اجرا کند. مثلاً فراموش کردن یک دونقطهی ساده. این نوع خطاها را نمیتوان با try except مدیریت کرد؛ باید در همان مرحلهی نوشتن کد رفع شوند.
استثنا، اما داستان دیگری دارد: کد شما از نظر نحوی درست است، ولی در زمان اجرا، شرایطی پیش میآید که نمیتواند ادامه دهد. مثلاً فایلی که میخواهید بخوانید وجود ندارد، عددی بر صفر تقسیم میشود، یا کلیدی در دیکشنری نیست. اینها همان چیزهایی هستند که مدیریت خطا با آنها سر و کار دارد. اگر با خطاهای خاصی روبرو شدهاید، دو مقالهی رفع خطای TypeError در پایتون و رفع خطای ValueError در پایتون نمونههای عملی از مدیریت این دو را نشان میدهند.
خطای نحوی، دردی است که در لحظهی نوشتن حس میکنید؛ استثنا، دردی است که ماه بعد، ساعت سه بامداد، مشتری به شما خبر میدهد. تفاوت این دو، دقیقاً جایی است که مدیریت خطا معنا پیدا میکند.
try و except: پایهای که باید درست بگذارید
ساختار پایهی مدیریت استثنا در پایتون:
try:
result = 10 / 0
except ZeroDivisionError:
print("Cannot divide by zero")
result = None
این سینتکس ساده است، ولی سه تصمیم مهم در همین چند خط وجود دارد که در پروژههای واقعی، تفاوت زیادی میسازند:
- چه چیزی را داخل try میگذارید؟ قاعدهی من: فقط عملیاتی که ممکن است خطا بدهد. اگر کدهای نامرتبط هم داخل try بگذارید، خطاهای غیرمنتظره را به اشتباه میگیرید.
- چه نوع استثنایی را میگیرید؟ هرگز
except:خالی نگذارید. اگر نوع استثنا را مشخص نکنید، خطاهای غیرمنتظره (مثلKeyboardInterrupt) را هم میگیرید و دیباگ را غیرممکن میکنید. - در بلوک except چه میکنید؟ اگر فقط
passبگذارید، خطا را خفه کردهاید. باید یا لاگ کنید، یا مقدار جایگزین بدهید، یا استثنا را دوباره پرتاب کنید.
یک نکتهی مهم که در پروژههای واقعی به آن رسیدهام: بلوک try را تا حد امکان کوچک نگه دارید. اگر ده خط کد را داخل یک try بگذارید، وقتی خطایی رخ دهد، نمیدانید کدام خط مقصر است. هر عملیات پرخطر را در try جداگانه بگذارید — یا حداقل خطای دقیقتری از روی traceback بخواهید.
چند except و ترتیب اهمیت
وقتی یک بلوک کد میتواند چند نوع خطا بدهد، میتوانید چندین except داشته باشید:
try:
with open("data.txt", "r", encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError:
log_error("File does not exist")
data = {}
except PermissionError:
log_error("Permission denied")
data = {}
except json.JSONDecodeError as e:
log_error(f"Invalid JSON: {e}")
data = {}
ترتیب exceptها مهم است. پایتون اولین exceptی را که با نوع استثنا مطابقت داشته باشد، اجرا میکند و بقیه را نادیده میگیرد. یعنی اگر ابتدا except Exception: بگذارید و بعد except FileNotFoundError:، آن دومی هیچوقت اجرا نمیشود — چون FileNotFoundError زیرمجموعهی Exception است.
یک قاعدهی عملی: از استثناهای خاص به استثناهای عمومی بروید. یعنی ابتدا FileNotFoundError، سپس OSError، و در آخر اگر واقعاً لازم بود، Exception. در تجربهی من، except Exception را فقط در دو جا مجاز میدانم: در بالاترین سطح یک اسکریپت برای ثبت خطای نهایی و ادامهی کار، یا در پوششهای server-like که نباید با یک خطا سقوط کنند.
else و finally: دو بلوک فراموششده
بسیاری از توسعهدهندهها فقط try و except را میشناسند. ولی پایتون دو بلوک دیگر هم دارد که در پروژههای واقعی بارها بهکارم آمده:
else: کدی که فقط وقتی خطایی نبود اجرا میشود
try:
data = load_from_api()
except ConnectionError:
log_error("API unreachable")
data = []
else:
# فقط اگر try بدون خطا تمام شد
save_to_cache(data)
چرا این بلوک مفید است؟ چون اجازه میدهد کدی که خودش ممکن است خطا بدهد را از داخل try بیرون بکشید. اگر save_to_cache را داخل try بگذارید و خودش خطا بدهد، except ConnectionError نمیگیردش ولی بلوکهای دیگر ممکن است گیر بیفتند. با else، تفکیک تمیزی بین «عملیات پرخطر» و «پردازش بعد از موفقیت» دارید.
finally: کدی که در هر شرایطی اجرا میشود
lock = acquire_lock()
try:
do_critical_work()
finally:
release_lock(lock)
بلوک finally در هر شرایطی اجرا میشود: هم وقتی استثنا رخ میدهد، هم وقتی خطایی نیست، حتی اگر داخل try یک return داشته باشید. این ویژگی، آن را برای آزادسازی منابع حیاتی میکند: بستن اتصال دیتابیس، باز کردن قفل فایل، برگرداندن منابع به حالت قبلی.
نکتهی مهم: در پایتون مدرن، برای مدیریت منابع، معمولاً context manager (که در ادامه میآید) انتخاب بهتری از finally است. ولی در بعضی موارد که نمیتوانید ساختار with بسازید، finally ابزار درستی است.
raise: پرتاب استثنای خودتان
گاهی خودتان باید استثنا پرتاب کنید. وقتی تابعی متوجه شرایطی میشود که نمیتواند ادامه دهد، بهجای برگرداندن مقدار عجیب (مثل -1 یا None)، استثنا پرتاب کنید:
def calculate_discount(price, percentage):
if price < 0:
raise ValueError("Price cannot be negative")
if not 0 <= percentage <= 100:
raise ValueError("Percentage must be between 0 and 100")
return price * (1 - percentage / 100)
مزیت این رویکرد، در پروژههای واقعی چند برابر است:
- شفافیت: فراخوان فوراً میداند چیزی اشتباه است، نه اینکه با مقدار بیمعنی جلو برود.
- تستپذیری: میتوانید با
pytest.raisesبررسی کنید که تابع در شرایط نادرست استثنا میدهد. - جلوگیری از خطاهای آبشاری: وقتی مقدار اشتباه جلوتر برود، در جایی دورتر به خطای گیجکننده تبدیل میشود. پرتاب در نقطهی منشأ، اشکالزدایی را سادهتر میکند.
در مورد انتخاب نوع استثنا، قاعدهی من ساده است: اگر شرایط شبیه یک پارامتر نامعتبر است، ValueError. اگر نوع نامناسبی وارد شده، TypeError. اگر عمل غیرمجازی تلاش میشود، PermissionError یا استثنای سفارشی. در بخش بعدی، استثناهای سفارشی را جداگانه بررسی میکنم. اگر با خطاهای داخلی پایتون مواجه شدهاید، فهرست کاملتری در رفع خطای TypeError در پایتون و رفع خطای RuntimeError در پایتون آمده است.
تابعی که بهجای برگرداندن -1 در شرایط خطا، استثنا پرتاب میکند، تابعی است که به فراخوان خود احترام میگذارد.
استثناهای سفارشی برای پروژههای بزرگ
در پروژههای بزرگ، استفاده از استثناهای داخلی پایتون، بعد از مدتی کافی نیست. اگر تابعی ValueError پرتاب کند، فراخوان نمیداند که این خطا مربوط به اعتبارسنجی کاربر است یا خطای داخلی محاسبه. راهحل، تعریف استثناهای اختصاصی پروژه است:
class AppError(Exception):
"""Base class for all application errors."""
pass
class ValidationError(AppError):
"""Raised when user input fails validation."""
pass
class NotFoundError(AppError):
"""Raised when a requested resource does not exist."""
pass
class PaymentError(AppError):
"""Raised when a payment fails."""
pass
و در کد کاربردی:
def get_user(user_id):
user = db.query(user_id)
if user is None:
raise NotFoundError(f"User {user_id} not found")
return user
def process_order(order):
try:
user = get_user(order.user_id)
except NotFoundError:
raise ValidationError(f"Order references unknown user")
مزایای این الگو که در پروژههای واقعی تجربه کردهام:
- فیلتر کردن استثناها: لایهی بالایی میتواند
except AppError:بزند و همهی خطاهای برنامه را بگیرد، ولی خطاهای سیستم (مثلMemoryError) را رد کند. - رفتار اختصاصی: میتوانید به هر استثنا، فیلدهای اضافی اضافه کنید. مثلاً
PaymentErrorباtransaction_idوcode. - مستندسازی: فهرست استثناهای پروژه، خودش یک نقشهی ذهنی از خطاهای ممکن پروژه است — ورودی، منبع، پرداخت و ...
- پیامرسانی به کاربر: لایهی نمایش، میتواند بر اساس نوع استثنا، پیام مناسب کاربر را نشان دهد، نه پیام خام پایتون.
اگر با مفاهیم شیگرایی در پایتون راحت نیستید، آموزش شیگرایی در PHP مفاهیم پایه را میرساند و با کمی تطبیق، در پایتون هم مستقیم استفاده میشود. تفاوت اینجاست که در پایتون، استثناها نوعاً سبکتر و انعطافپذیرترند.
زنجیرهی استثناها با raise from
در پروژههای واقعی، گاهی یک استثنا باعث استثنای دیگری میشود. مثلاً وقتی json.loads روی دادهی خراب اجرا میشود، JSONDecodeError پرتاب میکند، و شما میخواهید آن را به استثنای دامنهی خودتان تبدیل کنید:
import json
class ConfigError(Exception):
pass
def load_config(path):
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
raise ConfigError(f"Invalid config: {path}") from e
عبارت raise ... from e باعث میشود پایتون، هر دو استثنا را در traceback نگه دارد. فراخوان میبیند که یک ConfigError دریافت کرده، ولی با یک نگاه به traceback، میفهمد که علت اصلی، JSONDecodeError بوده. در پروژههای واقعی، این ویژگی در دیباگ، تفاوت بین «ده دقیقه پیدا کردن خطا» و «چند ساعت سرچ» است.
تفاوت این دو حالت را ببینید:
# بدون from: ارتباط استثناها گم میشود
raise ConfigError("Invalid config")
# با from: زنجیره حفظ میشود
raise ConfigError("Invalid config") from e
یک نکتهی ظریف: اگر میخواهید استثنای قبلی را کاملاً خفه کنید (که معمولاً توصیه نمیشود)، میتوانید از raise X from None استفاده کنید. این کار در بعضی موارد که استثنای زیرین اطلاعات حساس دارد، مفید است. ولی در پروژههای عادی، همیشه از from e استفاده کنید.
Context Manager: مدیریت خطا در منابع
مدیریت درست منابع (فایل، اتصال دیتابیس، قفل) در پروژههای واقعی، بخش بزرگی از مدیریت خطا است. Context manager با with، این کار را خودکار میکند:
with open("data.txt", "r", encoding="utf-8") as f:
content = f.read()
در پشت صحنه، پایتون متدهای __enter__ و __exit__ را روی شیء صدا میزند. اگر خطایی در بلوک رخ دهد، __exit__ صدا میشود و میتواند استثنا را مدیریت یا دوباره پرتاب کند.
میتوانید context manager سفارشی هم بسازید. مثال واقعی از یک پروژه: context manager برای اندازهگیری زمان اجرای یک بلوک کد:
import time
from contextlib import contextmanager
@contextmanager
def timer(label):
start = time.perf_counter()
try:
yield
finally:
elapsed = time.perf_counter() - start
print(f"{label}: {elapsed:.3f}s")
with timer("Data processing"):
process_data()
یا مثلاً برای برگرداندن خودکار یک حالت قبلی:
@contextmanager
def suppress_exceptions(*exceptions):
try:
yield
except exceptions as e:
log_info(f"Suppressed: {e}")
with suppress_exceptions(FileNotFoundError):
Path("maybe.txt").unlink()
در پروژههای واقعی، این الگو بسیار مفید است. مثلاً در یک پردازش دستهای، اگر یکی از آیتمها خطا داد، نمیخواهید کل پردازش متوقف شود:
with suppress_exceptions(ValueError, KeyError):
process_item(item)
این نوع مدیریت خطا، برنامه را مقاوم میکند بدون اینکه کد را شلوغ کند. اگر با کار با فایل در پایتون آشنا نیستید، کار با فایلها در پایتون همین مفهوم را در بستر منابع نشان میدهد.
logging بهجای print
در پروژههای واقعی، print برای دیباگ خوب است، ولی برای مدیریت خطا ناکافی است. سه مشکل اصلی print:
- سطحبندی ندارد — نمیتوانید بین «اطلاعات» و «خطای بحرانی» تفاوت بگذارید.
- به مقصد متمرکز نمیرود — در سرور، پیامهای
printگم میشوند. - در چند پردازش همزمان، خروجیها مخلوط میشوند.
راهحل، استفاده از logging استاندارد پایتون است:
import logging
logging.basicConfig(
filename="app.log",
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
try:
process_order(order)
except PaymentError as e:
logger.error("Payment failed: %s", e, exc_info=True)
notify_admin(e)
except Exception as e:
logger.exception("Unexpected error")
raise
پنج سطح لاگ که در پروژههای واقعی بهکار میبرم:
- DEBUG: جزئیات دقیق، فقط در محیط توسعه فعال.
- INFO: اتفاقات عادی — مثلاً «کاربر وارد شد».
- WARNING: چیزی که ممکن است مشکل باشد ولی هنوز کار میکند — مثلاً «فایل کش یافت نشد، دوباره ساخته میشود».
- ERROR: خطایی که یک عملیات را شکست داده ولی برنامه زنده است.
- CRITICAL: خطای بحرانی که برنامه را متوقف میکند.
نکتهی مهم در exc_info=True: این پارامتر، traceback را هم در لاگ ثبت میکند. در دیباگ، همین یک جزئیات، تفاوت بین «فهمیدن سریع خطا» و «جستجوی ساعتی» را میسازد. اگر با رفع خطای FileNotFoundError در پایتون روبرو شدهاید، میبینید که لاگ دقیق، چقدر تشخیص را سریع میکند.
در پروژههای واقعی،loggingبرای پاسخ به سؤال «دو هفته پیش، چه اتفاقی افتاد؟».
الگوهای عملی در پروژههای واقعی
سه الگویی که در پروژههای خودم زیاد استفاده میکنم و نتیجه داده:
۱) Retry با تأخیر نمایی
برای خطاهای گذرا (مثل قطعی شبکه یا خطای موقت API)، تلاش دوباره با تأخیر نمایی جواب میدهد:
import time
from functools import wraps
def retry(times=3, delay=1, exceptions=(Exception,)):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(times):
try:
return func(*args, **kwargs)
except exceptions:
if attempt == times - 1:
raise
time.sleep(delay * (2 ** attempt))
return wrapper
return decorator
@retry(times=3, exceptions=(ConnectionError, TimeoutError))
def fetch_from_api(url):
...
این الگو در پروژههای اسکرپینگ و اتصال به APIهای خارجی، بارها از فاجعه جلوگیری کرده. اگر با مفهوم اسکرپینگ آشنا نیستید، وب اسکرپینگ با پایتون نمونههای بیشتری از کاربرد این الگو دارد.
۲) Fail Fast در اعتبارسنجی
در ورودی توابع عمومی، همهی اعتبارسنجیها را در ابتدای تابع انجام دهید و در صورت خطا، سریعاً استثنا پرتاب کنید:
def process_order(order_id, amount, currency):
if not isinstance(order_id, int) or order_id <= 0:
raise ValueError("Invalid order_id")
if amount <= 0:
raise ValueError("Amount must be positive")
if currency not in ("IRR", "USD", "EUR"):
raise ValueError(f"Unsupported currency: {currency}")
# منطق اصلی
...
مزیت این الگو: کد اصلی بدون شاخههای شرطی پیچیده تمیز میماند و خطاهای ورودی، در نقطهی منشأ کشف میشوند.
۳) Graceful Degradation
در سرویسهای وابسته، اگر یک قطعه از کار افتاد، برنامه بهجای سقوط، با کیفیت پایینتر ادامه دهد:
def get_recommendations(user_id):
try:
return recommendation_engine.get(user_id)
except Exception as e:
logger.warning("Recommendation engine failed: %s", e)
return get_popular_items() # fallback
این الگو در پروژههای پربازدید، تفاوت بین یک سایت در دسترس و یک سایت خارج از دسترس است.
Anti-patternهایی که در کدها زیاد میبینم
در بازبینی کد پروژههای دیگران، این اشتباهات را زیاد دیدهام:
except:خالی: خطاهای غیرمنتظره مثلKeyboardInterruptرا هم میگیرد و جلوی توقف برنامه را میگیرد. همیشه نوع مشخص کنید.except Exception: pass: خطا را کاملاً خفه میکند. حتی اگر منطقی برای نادیدهگرفتن دارید، حداقل لاگ کنید.- استفاده از
printدر بلوک except: در تولید، پیام به جایی نمیرسد. ازlogger.errorاستفاده کنید. - پرتاب استثنای عمومی:
raise Exception("...")فراخوان را در بلاتکلیفی میگذارد. از استثنای مشخص یا سفارشی استفاده کنید. - try بزرگ: وقتی ۵۰ خط را در یک try میگذارید، خطا در کدام خط بوده؟ try را کوچک نگه دارید.
- نادیده گرفتن استثناهای بازگشتی: اگر
finallyهم خطا بدهد، پایتون آن را جایگزین استثنای اصلی میکند و شما اطلاعات اصلی را از دست میدهید. مراقب باشید. - blur کردن علت اصلی: پرتاب استثنا بدون
from e، traceback را مخدوش میکند. همیشه زنجیره را حفظ کنید. - استفاده از exception برای جریان عادی برنامه: اگر انتظار دارید کلیدی در دیکشنری نباشد، از
dict.getاستفاده کنید، نهtry except KeyError. استثنا برای شرایط استثنایی است، نه کنترل جریان.
یک توصیهی عملی از تجربه: در کدبازبینی، همیشه روی بلوکهای except تمرکز کنید. اگر میبینید بلوکی فقط pass دارد یا print، آن یک سیگنال قرمز است که نویسنده در آن نقطه، خطا را جدی نگرفته. در نود درصد موارد، این نقطه در آینده به یک باگ تولیدی تبدیل میشود.
اگر با خطاهای خاصی روبرو هستید، مقالات زیر مسیر دقیقتری را نشان میدهند: رفع خطای ValueError، رفع خطای KeyError، رفع خطای AttributeError و رفع خطای ImportError. فهرست کامل خطاهای پایتون در مقالات همین دستهبندی در سایت آمده است.
سخن آخر
مدیریت خطا در پایتون، از یک try except ساده شروع میشود ولی در پروژههای واقعی، به یک تصمیم معماری تبدیل میشود که در همهی لایهها اثر میگذارد. سه نکتهی مهم که در این مقاله به آنها رسیدیم: اول، استثناها را در نقطهی منشأ پرتاب کنید و در نقطهی مناسب بگیرید — گرفتن همهی خطاها در جای اشتباه، فقط دیباگ را سختتر میکند؛ دوم، استثناهای سفارشی، ابزار اصلی برای تمایز بین خطاهای دامنه و خطاهای سیستمی هستند — در پروژههای بزرگ، نبودشان به آشفتگی میرسد؛ سوم، logging نهفقط برای دیباگ، بلکه برای پاسخ به سؤال «چه اتفاقی افتاد؟» در آینده است — این عادت، تفاوت بین یک برنامهی حرفهای و یک اسکریپت آماتور را میسازد.
اگر امروز میخواهید شروع کنید، سه کار کوچک پیشنهاد میکنم: در یکی از توابع پروژهی فعلیتان، بلوک exceptها را بازبینی کنید و هر pass را با لاگ یا مقدار جایگزین پر کنید؛ برای خطاهای دامنه، یک استثنای سفارشی بسازید؛ و یک context manager کوچک برای یکی از عملیات تکراری پروژهتان بنویسید. همین سه کار، کیفیت کد شما را در نگاه اول بالا میبرد. اگر تجربهای از مدیریت خطا در پروژههای خودتان دارید — مخصوصاً اگر با خطای غیرمنتظرهای روبرو شدهاید که برای رفعش راهحل خلاقانهای پیدا کردهاید — در دیدگاهها بنویسید؛ همین نکتههای میدانی، برای خوانندهی بعدی از هر مستند رسمی ارزشمندتر است. 🛡️