یک سرویس تحلیل تصویر داشتم که دقیقاً در ساعات اوج ترافیک، بدون هیچ الگوی قابل پیش‌بینی crash می‌کرد. Traceback چیز عجیبی نشان نمی‌داد: یک OSError بدون زیرکلاس مشخص، با کد errno که در هر crash اندکی فرق می‌کرد. آن تجربه اولین باری بود که فهمیدم OSError نه یک خطای منفرد، بلکه یک چتر برای ده‌ها خطای سیستمعامل است و تا زمانی که نفهمم کدام errno پشتش پنهان است، دیباگ صرفاً حدس‌زدن خواهد بود. این مقاله، حاصل همان تجربه و ده‌ها پروژهٔ بعدی است؛ از آموزش پایتون از صفر تا سرویس‌های async که در آن‌ها همین خطا با رفتار کاملاً متفاوتی ظاهر می‌شود.

OSError چیست و چرا چنین جایگاهی در پایتون دارد؟

OSError در پایتون، کلاس پایهٔ تمام خطاهایی است که از تعامل با سیستمعامل بیرون می‌آید. هر بار که کد پایتونی یک عملیات سیستمی انجام می‌دهد — باز کردن فایل، ساخت پوشه، اتصال شبکه، ساخت سوکت، حذف فایل، اجرای فرآیند فرزند — سیستمعامل می‌تواند با یک کد خطا پاسخ دهد. پایتون این کدها را از لایهٔ C دریافت می‌کند و به یک ساختار آبجکت‌محور با نام OSError تبدیل می‌کند.

این کلاس از نظر تاریخی، پیش از پایتون ۳٫۳ با نام‌های متفرقه‌ای مثل IOError، EnvironmentError، WindowsError و socket.error شناخته می‌شد. PEP 3151 این آشفتگی را مرتب کرد: همه زیر یک سلسله‌مراتب واحد قرار گرفتند و زیرکلاس‌های معنادار مثل FileNotFoundError و PermissionError معرفی شدند. امروز اگر کدی با except IOError بنویسید، پایتون آن را به‌عنوان OSError می‌پذیرد و همچنان کار می‌کند؛ اما این رویکرد قدیمی دیگر توصیه نمی‌شود.

نکتهٔ کلیدی که در ذهن باید بماند: OSError ذاتاً «خطای سیستمعامل» است، نه «خطای برنامه». یعنی حتی اگر کد منطقی شما بی‌نقص باشد، باز هم می‌تواند این خطا را ببیند؛ چون دنیای بیرون از پایتون (دیسک، شبکه، هسته، منابع سرور) همواره قابل‌پیش‌بینی نیست. این تفکیک، پایهٔ تمام دیباگ‌های سالم است. برای درک چارچوب گسترده‌تر این حوزه، مدیریت خطا در پایتون را پیشنهاد می‌کنم.

OSError یک «متهم» نیست؛ یک «خبرنگار» است. این سیستمعامل است که خبر می‌دهد یک عملیات ممکن نشد. کار شما این است که بفهمید چرا و در کدام لایه.

درخت وارثت: OSError و زیرکلاس‌هایش

در پایتون ۳، ساختار ارث‌بری خطاهای سیستمعامل به این شکل است:

BaseException
 └── Exception
      └── OSError
           ├── BlockingIOError
           ├── ChildProcessError
           ├── ConnectionError
           │    ├── BrokenPipeError
           │    ├── ConnectionAbortedError
           │    ├── ConnectionRefusedError
           │    └── ConnectionResetError
           ├── FileExistsError
           ├── FileNotFoundError
           ├── InterruptedError
           ├── IsADirectoryError
           ├── NotADirectoryError
           ├── PermissionError
           ├── ProcessLookupError
           ├── TimeoutError
           └── (سایر خطاهای عمومی سیستمعامل)

هر کدام از این زیرکلاس‌ها، یک errno مشخص را نمایندگی می‌کنند و هنگام ظاهر شدن، OSError عمومی را با زیرکلاس دقیق‌تر جایگزین می‌کنند. این یعنی اگر فقط except OSError بنویسید، همه‌شان را می‌گیرید؛ ولی اگر except FileNotFoundError بنویسید، فقط خطاهای نبود فایل را مدیریت می‌کنید.

در عمل، توسعه‌دهندگان تازه‌کار تمایل دارند except OSError بنویسند و این باعث می‌شود تمام زمینه‌های دیباگ از دست برود. توصیهٔ من: تا زمانی که دقیقاً نمی‌دانید چه چیزی را می‌خواهید مدیریت کنید، همیشه زیرکلاس دقیق‌تر را بگیرید. اگر پایتون زیرکلاس دقیقی را تشخیص ندهد (روی برخی سیستمعامل‌های خاص)، OSError خام بالا می‌آید و همان‌جا باید به errno رجوع کنید.

مکانیزم errno: پیام واقعی سیستمعامل

هستهٔ سیستمعامل، هنگام رد شدن یک عملیات، یک عدد صحیح برمی‌گرداند که در هدر errno.h در زبان C تعریف شده است. این اعداد در سیستم‌های مختلف می‌توانند متفاوت باشند، ولی روی POSIX استاندارد نسبتاً یکسان هستند. جدول زیر مهم‌ترین کدها را نشان می‌دهد:

errnoعدد (Linux)معنا
EACCES13دسترسی رد شد (مجوز ندارد)
EEXIST17فایل یا پوشه از قبل وجود دارد
ENOENT2فایل یا مسیر پیدا نشد
ENOTDIR20مسیر یک پوشه نیست ولی انتظار می‌رفت
EISDIR21مسیر یک پوشه است ولی فایل انتظار می‌رفت
EAGAIN11منبع در حال حاضر در دسترس نیست
ECONNREFUSED111اتصال شبکه رد شد
ETIMEDOUT110زمان عملیات شبکه به پایان رسید
EMFILE24تعداد فایل‌های باز به سقف رسیده
ENOSPC28فضای دیسک تمام شده

پایتون این عدد را درون شیء استثنا نگه می‌دارد و با دو صفت قابل دسترسی است: e.errno که خود عدد است، و e.strerror که ترجمهٔ متنی سیستمعامل است. الگوی استانداردی که در پروژه‌های خودم برای لاگ‌گیری استفاده می‌کنم:

import errno
import logging

try:
    open("/var/log/app.log", "a")
except OSError as e:
    logging.error(
        "os error: errno=%s (%s), path=%s",
        e.errno,
        errno.errorcode.get(e.errno, "UNKNOWN"),
        getattr(e, "filename", None),
    )

این کد، هم عدد خام را ذخیره می‌کند، هم نام نمادین (EACCES، ENOENT و…) و هم مسیر فایل را. وقتی این سه با هم لاگ شوند، دیباگ در محیط تولید چند برابر سریع‌تر می‌شود. مفهوم عمیق‌تر این مکانیزم در مستندات تاریخی errno.h توضیح داده شده است.

نکتهٔ مهم: در ویندوز، عدد errno استاندارد متفاوت است و برخی مقادیر با لینوکس همپوشانی ندارند. به همین دلیل، هرگز بر اساس عدد ثابت شرط نگذارید؛ همیشه از نمادهای errno استفاده کنید:

if e.errno == errno.ENOENT:
    ...
elif e.errno == errno.EACCES:
    ...

هفت زیرکلاس مهم و کاربردشان

در ادامه، مهم‌ترین زیرکلاس‌های OSError را با مثال‌های واقعی بررسی می‌کنیم. هر زیرکلاس، یک الگوی مشخص از خطا را نمایندگی می‌کند و راه‌حل اختصاصی خودش را می‌طلبد.

FileNotFoundError

خطای «فایل پیدا نشد». ریشه‌های رایج: مسیر اشتباه، فایل حذف‌شده، یا مسیر نسبی که به نقطهٔ دیگری اشاره می‌کند. توجه داشته باشید که در سناریوهای داکری، این خطا ممکن است از یک mount اشتباه بیاید. تحلیل تفصیلی این خطا در خطای FileNotFoundError در پایتون آمده است.

PermissionError

خطای مجوز. در سیستم‌های POSIX ریشهٔ آن معمولاً کاربر اجراکننده یا بیت‌های مجوز است. در ویندوز، اغلب ناشی از قفل انحصاری فایل. این خطا را در خطای PermissionError در پایتون به‌تفصیل بررسی کرده‌ام.

ConnectionError و زیرکلاس‌هایش

این خانواده برای خطاهای شبکه به‌کار می‌رود: ConnectionRefusedError هنگام رد اتصال، ConnectionResetError هنگام قطع ناگهانی توسط طرف مقابل، ConnectionAbortedError هنگام لغو اتصال، و BrokenPipeError هنگام نوشتن روی لوله‌ای که بسته شده است. هر کدام از این‌ها الگوی دیباگ متفاوتی دارد و درک آن‌ها کلید نگهداری سرویس‌های شبکه‌ای است.

TimeoutError

خطای اتمام مهلت. در socket، asyncio و کتابخانه‌های HTTP پایتون، این خطا نشان می‌دهد که طرف مقابل در بازهٔ تعیین‌شده پاسخ نداده است. تشخیص «خطای شبکه» از «سرور کند» از طریق همین خطا ممکن می‌شود.

FileExistsError

هنگام ساخت فایل یا پوشه‌ای که از قبل وجود دارد. در پایتون مدرن، معمولاً با پارامتر exist_ok=True مدیریت می‌شود:

from pathlib import Path
Path("/var/lib/app/data").mkdir(parents=True, exist_ok=True)

IsADirectoryError و NotADirectoryError

خطاهای متقارن در تداخل نوع فایل و پوشه. IsADirectoryError وقتی ظاهر می‌شود که با open() روی یک پوشه کار می‌کنید؛ NotADirectoryError وقتی که بخشی از مسیر، پوشه نیست ولی انتظار می‌رفت. این دو خطا در سناریوهای ورودی کاربر بسیار رایج‌اند.

BlockingIOError و InterruptedError

این دو در برنامه‌های غیرمسدودکننده (non-blocking) و سیگنال‌محور ظاهر می‌شوند. اگر با select، asyncio یا socket.setblocking(False) کار می‌کنید، این خطاها نشانهٔ رفتار طبیعی سیستم‌اند نه باگ. رفع آن‌ها معمولاً با حلقهٔ retry انجام می‌شود نه با اصلاح مجوز یا مسیر.

سناریوهای واقعی که این خطا را می‌سازند

در پروژه‌های واقعی، OSError معمولاً از یکی از این پنج الگو ظاهر می‌شود. شناختن الگو، تشخیص را چند برابر سریع‌تر می‌کند.

الگوی اول: تداخل چند کاربر و مجوزها

کد روی ماشین توسعه‌دهنده با کاربر شخصی اجرا می‌شود ولی روی سرور با کاربر سرویس مثل www-data. فایل‌هایی که در زمان توسعه با کاربر شخصی ساخته شده‌اند، روی سرور قابل خواندن یا نوشتن نیستند و خطای EACCES به‌شکل PermissionError ظاهر می‌شود.

الگوی دوم: مسیرهای نسبی و cwd

اسکریپتی که با cron یا systemd اجرا می‌شود، ممکن است cwd متفاوتی داشته باشد و مسیرهای نسبی به نقطهٔ غیرمنتظره‌ای اشاره کنند. نتیجه: FileNotFoundError یا NotADirectoryError. راه‌حل: در کد، همیشه مسیرها را از متغیر محیطی بگیرید و آن‌ها را به مسیر مطلق تبدیل کنید.

الگوی سوم: سقف منابع سرور

وقتی تعداد فایل‌های باز به سقف ulimit می‌رسد، سیستم با EMFILE پاسخ می‌دهد و پایتون آن را به‌شکل OSError عمومی نمایش می‌دهد. این حالت در سرویس‌های پربازدید یا اسکریپت‌هایی که فایل‌ها را می‌بندند، شایع است. تشخیص با lsof -p | wc -l انجام می‌شود.

الگوی چهارم: قطع ناگهانی شبکه

در سرویس‌های توزیع‌شده، هر قطع اتصال به‌شکل ConnectionResetError، BrokenPipeError یا TimeoutError ظاهر می‌شود. اگر این خطاها مدیریت نشوند، کل درخواست کاربر شکست می‌خورد، در حالی که یک retry ساده می‌تواند مسئله را حل کند.

الگوی پنجم: پر شدن دیسک یا فضای موقت

وقتی فضای دیسک به پایان می‌رسد، سیستم با ENOSPC پاسخ می‌دهد و پایتون آن را به OSError عمومی تبدیل می‌کند. این خطا معمولاً روی /tmp یا پوشهٔ آپلود رخ می‌دهد و پیش از هر دیباگ کدی، باید فضای دیسک با df -h بررسی شود.

در پروژه‌های مرتبط با اتصال به دیتابیس، ترکیب این خطاها با رفتارهای خاص درایورها دیده می‌شود؛ مقالهٔ اتصال پایتون به MySQL نکات مکمل را پوشش می‌دهد.

OSError یک پیام واحد نیست؛ یک مکالمه است. سیستمعامل می‌گوید «نشد»، شما باید بپرسید «چرا؟» و این پرسش، از errno شروع می‌شود.

روش‌شناسی تشخیص در پنج گام

در برخورد با OSError، صبر و ترتیب، از هر جستجوی اینترنتی ارزشمندترند. پروتکل زیر را در پروژه‌های خودم اجرا می‌کنم و در ۹۰٪ پرونده‌ها، در گام سوم یا چهارم مقصر مشخص می‌شود.

گام اول: خواندن دقیق پیام و errno

پایتون اطلاعات بسیار مفیدی در خود استثنا می‌گذارد:

try:
    # عملیات مشکوک
    ...
except OSError as e:
    print("errno:", e.errno)
    print("strerror:", e.strerror)
    print("filename:", getattr(e, "filename", None))
    print("filename2:", getattr(e, "filename2", None))

ویژگی filename2 در عملیات‌های دو-فایلی مثل os.rename استفاده می‌شود و مسیر دوم را نشان می‌دهد. اگر این را بلد نباشید، گاهی به دنبال فایلی می‌گردید که مقصر نبوده است.

گام دوم: بازتولید در محیط کنترل‌شده

سعی کنید خطا را با حداقل کد بازتولید کنید. اگر خطا در محیط تولید رخ می‌دهد ولی در محیط توسعه نه، تفاوت‌های محیطی (کاربر، cwd، متغیرهای محیطی، نسخه کتابخانه) را تک‌تک مقایسه کنید. ابزار pip freeze در کنار env، نقشهٔ خوبی می‌دهد.

گام سوم: بررسی لایهٔ سیستمعامل

این مرحله جایی است که اکثر توسعه‌دهندگان آن را نادیده می‌گیرند. سه ابزار ضروری داریم:

$ ps -o user= -p $(pgrep -f "python3 app.py" | head -n1)
$ ls -l /path/to/file
$ df -h /path
$ lsof -p $(pgrep -f "python3 app.py" | head -n1) | wc -l

این چهار دستور، کاربر، مجوزها، فضای دیسک و تعداد فایل‌های باز را بررسی می‌کنند. چهار فرضیهٔ اصلی در OSError با همین چهار دستور تأیید یا رد می‌شوند.

گام چهارم: ردیابی سیستمی با strace

در لینوکس، strace ابزار نهایی است:

$ strace -f -e trace=openat,connect,rename python3 app.py 2>&1 | grep -E "= -1|E[A-Z]+"

هر خطی که با = -1 تمام می‌شود، یعنی سیستمعامل خطا داده. همین خطوط، ریشهٔ واقعی را نشان می‌دهند. برای سرویس‌های گرافیکی یا مبتنی بر سوکت، پارامترهای trace را با openat,connect,accept,sendto,recvfrom گسترش دهید.

گام پنجم: جداسازی لایه‌ها

اگر در گام‌های قبل، خطا در سطح سیستمعامل تأیید شد، حالا نوبت جداسازی است: آیا خطا از کد خودتان می‌آید یا از کتابخانه‌های ثالث؟ یک راه سریع:

import traceback

try:
    some_library_call()
except OSError:
    traceback.print_exc()

در لاگ، دو لایه فریم را ببینید: آیا خطا از فریم کد شما می‌آید یا از درون کتابخانه؟ اگر از کتابخانه، اغلب با به‌روزرسانی همان کتابخانه یا تنظیم پارامترهایش حل می‌شود. برای مطالعهٔ بیشتر دربارهٔ خطاهای مرتبط در زمینه واردکردن ماژول‌ها، خطای ImportError در پایتون و رفع ModuleNotFoundError در پایتون را ببینید.

الگوهای کدی برای رفع امن

پس از تشخیص، انتخاب راه‌حل باید بر اساس اصل «کم‌هزینه‌ترین تغییر ساختاری» باشد. الگوهای زیر، تجربهٔ سال‌ها دیباگ و بازنویسی کدهای تولیدی است.

الگوی اول: گرفتن زیرکلاس دقیق

به‌جای گرفتن OSError به‌شکل کلی، زیرکلاس‌ها را جدا مدیریت کنید:

from pathlib import Path

try:
    data = Path("config.json").read_text(encoding="utf-8")
except FileNotFoundError:
    data = "{}"
except PermissionError as e:
    raise SystemExit(
        f"cannot read config.json; check ownership and chmod. errno={e.errno}"
    )

این کد، دو سناریو را از هم جدا می‌کند و اجازه می‌دهد هر کدام رفتار مناسب خودش را داشته باشد.

الگوی دوم: retry هوشمند برای خطاهای گذرا

خطاهای EAGAIN، ETIMEDOUT، ECONNRESET، EINTR گذرا هستند و با تلاش مجدد حل می‌شوند. الگوی پیشنهادی من با backoff:

import errno
import time

RETRYABLE = {
    errno.EAGAIN,
    errno.EINTR,
    errno.ETIMEDOUT,
    errno.ECONNRESET,
    errno.ECONNREFUSED,
}

def retry(func, attempts=5, base=0.2):
    last = None
    for i in range(attempts):
        try:
            return func()
        except OSError as e:
            if e.errno not in RETRYABLE:
                raise
            last = e
            time.sleep(base * (2 ** i))
    raise last

نکتهٔ کلیدی: retry فقط برای خطاهای گذرا معنا دارد. اگر ENOENT یا EACCES را retry کنید، فقط عمر سرور را تلف می‌کنید.

الگوی سوم: مدیریت گرافیکی خطاها با exception groups

در پایتون ۳٫۱۱ به بعد، امکان گروه‌بندی خطاها وجود دارد:

try:
    with open("a.txt") as a, open("b.txt") as b:
        pass
except* OSError as eg:
    for e in eg.exceptions:
        print(e.errno, getattr(e, "filename", None))

این الگو در سیستم‌هایی که چند فایل را به‌طور موازی باز می‌کنند، بسیار مفید است.

الگوی چهارم: ساخت پوشه‌های پیش‌نیاز در زمان import

بسیاری از OSErrorها به این دلیل رخ می‌دهند که پوشهٔ والد وجود ندارد. راه‌حل:

from pathlib import Path

for d in (
    "/var/lib/myapp/data",
    "/var/lib/myapp/uploads",
    "/var/log/myapp",
):
    Path(d).mkdir(parents=True, exist_ok=True)

اگر روی سرورهای اشتراکی هستید که کاربر اجازهٔ ساخت پوشه در /var را ندارد، این پوشه‌ها را در Path.home() بسازید.

الگوی پنجم: مدیریت منطقی فایل‌های موقت

برای جلوگیری از تعارض در /tmp مشترک، پارامتر dir= را صریح تعیین کنید:

import tempfile
from pathlib import Path

with tempfile.NamedTemporaryFile(
    mode="w+b",
    dir=str(Path.home() / "tmp"),
    delete=False,
) as tmp:
    tmp.write(b"data")
    tmp_path = tmp.name

پوشهٔ ~/tmp را در ابتدای برنامه بسازید و مجوز 700 برایش تعیین کنید. این یک رویکرد استاندارد در بسیاری از سرویس‌های تولیدی است.

در پروژه‌هایی که با فایل‌ها سروکار زیادی دارند، آشنایی با الگوهای context manager و مدیریت خودکار منابع، بخش بزرگی از این خطاها را حذف می‌کند. مقالهٔ کار با فایل‌ها در پایتون این حوزه را به‌تفصیل پوشش می‌دهد.

OSError در کدهای async و شبکه

در برنامه‌های async پایتون، OSError رفتار ویژه‌ای دارد که درک آن برای هر کسی که با asyncio، aiohttp یا سرویس‌های پیام‌رسان کار می‌کند ضروری است.

گروه‌های خطای asyncio

در asyncio، هر OSError که در یک Task رخ دهد، ممکن است به‌شکل متفاوتی منتشر شود. اگر تسک به‌درستی await نشود، خطا در لاگ asyncio ظاهر می‌شود ولی stack trace کوتاه و گمراه‌کننده خواهد بود. راه‌حل: همیشه تسک‌ها را در try/except بپیچید یا از asyncio.gather(..., return_exceptions=True) استفاده کنید.

خطاهای گذرای شبکه

در شبکه، خطاهای زیر بیشترین شیوع را دارند:

  • ConnectionRefusedError — سرویس مقصد بالا نیست
  • ConnectionResetError — طرف مقابل به‌طور ناگهانی قطع کرده
  • BrokenPipeError — نوشتن روی لوله‌ای که بسته شده
  • TimeoutError — طرف مقابل کند یا بی‌پاسخ است

هرکدام از این‌ها الگوی retry متفاوتی دارد. ConnectionRefusedError را معمولاً با backoff نمایی retry می‌کنند، ولی ConnectionResetError را اغلب با بازسازی اتصال از صفر. اشتباه گرفتن این دو، عمر منابع را هدر می‌دهد.

مدیریت منابع در کنار خطاها

در سرویس‌های شبکه‌ای، هر OSError ممکن است به معنای نشت منابع باشد: سوکت‌های بسته‌نشده، فایل‌های باز مانده، تسک‌های معلق. الگوی async with که در مقالهٔ ساخت API با پایتون به‌کار رفته است، این مدیریت را تضمین می‌کند.

مکانیزم signal و EINTR

در لینوکس، سیگنال‌ها می‌توانند فراخوانی‌های سیستمی را قطع کنند و InterruptedError با errno.EINTR ایجاد کنند. پایتون ۳٫۵ به بعد این خطا را برای خیلی از توابع، خودکار retry می‌کند، ولی برای select، poll، socket.send و چند تابع دیگر همچنان نیاز به مدیریت دستی دارد. الگوی درست:

import errno
import socket

sock = socket.socket()

while True:
    try:
        sock.connect(("example.com", 80))
        break
    except InterruptedError:
        continue
    except OSError as e:
        if e.errno == errno.EINTR:
            continue
        raise

OSError در Docker و محیط‌های کانتینری

محیط‌های کانتینری مثل Docker یک لایهٔ اضافه از پیچیدگی را وارد می‌کنند: مجوزهای میزبان، مجوزهای داخل کانتینر و نوع mount. بسیاری از OSErrorها در این محیط‌ها از عدم تطابق UID و GID می‌آید.

mount و مجوزها

وقتی پوشه‌ای از میزبان با -v داخل کانتینر mount می‌شود، مجوزها از میزبان اعمال می‌شوند. اگر UID میزبان ۱۰۰۰ باشد و داخل کانتینر کاربر root، نوشتن روی فایل‌های میزبان ممکن است با EACCES رد شود. راه‌حل اصولی:

FROM python:3.12-slim
RUN groupadd -g 1000 app && useradd -u 1000 -g 1000 -m app
USER app
WORKDIR /home/app

و در زمان اجرا:

$ docker run --user 1000:1000 -v /host/data:/home/app/data myapp

این الگو، تفاوت‌های UID را حذف می‌کند.

محدودیت منابع در کانتینر

در Docker، سقف تعداد فایل‌های باز nofile می‌تواند پیش‌فرض پایین باشد و در سرویس‌های پرترافیک، EMFILE ظاهر شود. تنظیم آن:

$ docker run --ulimit nofile=65535:65535 myapp

فضای موقت و دیسک

در Docker، پوشهٔ /tmp معمولاً کوتاه‌عمر است و اگر سرویس به فضای موقت نیاز داشته باشد، بهترین راه تعریف یک tmpfs جداگانه است:

$ docker run --tmpfs /tmp:rw,size=512m,mode=1777 myapp

این کار، ENOSPC را از فضای اصلی جدا می‌کند و به دیسک میزبان آسیب نمی‌زند.

خطاهای مشابه در سطح بالاتر هم دیده می‌شوند؛ به‌ویژه وقتی با سرورهای عمومی سروکار دارید و به مجوز یا مسیرهای سیستمی دست می‌زنید، مقالهٔ خطای RuntimeError در پایتون می‌تواند نشانه‌های مکمل را روشن کند.

ملاحظات امنیتی در برخورد با خطاهای سیستمعامل

راه‌حل‌های سریع برای OSError می‌توانند امنیت را به خطر بیندازند. چند قاعده‌ای که در پروژه‌های خودم هرگز نقض نمی‌کنم:

یک: هرگز برای حل PermissionError، برنامه را با sudo یا root اجرا نکنید. این کار باعث می‌شود اگر آسیب‌پذیری‌ای در کد باشد، کل سیستم در معرض خطر قرار بگیرد.

دو: مسیرهایی که از ورودی کاربر می‌آیند را هرگز بدون اعتبارسنجی باز نکنید. حملهٔ path traversal می‌تواند با ترکیب ../../ شما را به فایل‌های حساس ببرد. الگوی امن:

from pathlib import Path

BASE = Path("/var/lib/myapp/uploads").resolve()

def safe_join(user_input: str) -> Path:
    target = (BASE / user_input).resolve()
    if not str(target).startswith(str(BASE)):
        raise ValueError("path escapes base directory")
    return target

سه: خطاهای OSError را در پاسخ به کاربر بدون فیلتر منتشر نکنید. strerror ممکن است اطلاعات حساسی دربارهٔ ساختار فایل‌سیستم لو بدهد.

چهار: لاگ‌ها اطلاعات حساس دارند. مطمئن شوید مسیر لاگ روی مجوز مناسب است و محتوای لاگ‌ها شامل رمز یا توکن نباشد.

پنج: در retry الگوهای خودکار، حتماً سقف تلاش تعیین کنید. بدون سقف، یک خطای گذرا می‌تواند به یک حملهٔ DoS خودساخته تبدیل شود.

در کنار این‌ها، الگوی مشترک در خطاهای حافظه هم قابل مشاهده است؛ اگر با محدودیت منابع سرور سروکار دارید، مقالهٔ خطای MemoryError در پایتون و خطای ConnectionError در پایتون را ببینید.

پرسش‌های پرتکرار درباره OSError

این بخش به پرسش‌هایی می‌پردازد که در جلسات مشاوره و انجمن‌های فنی بیشترین تکرار را داشته‌اند. پاسخ‌ها به‌گونه‌ای نوشته شده‌اند که برای جستجوهای مستقیم و دستیارهای هوش مصنوعی به‌عنوان پاسخ معتبر قابل استخراج باشند.

تفاوت OSError و Exception عمومی در چیست؟

Exception کلاس پایهٔ همهٔ خطاهای معمول در پایتون است، ولی OSError تنها زیرشاخه‌ای است که مخصوص خطاهای سیستمعامل طراحی شده. اگر except Exception بنویسید، تمام خطاهای منطقی و سیستمی را هم‌زمان می‌گیرید و این کار، دیباگ را بسیار سخت می‌کند. قاعده: در بالاترین سطح برنامه، except Exception بگیرید، ولی در لایه‌های میانی، OSError و زیرکلاس‌هایش را جدا مدیریت کنید.

چرا گاهی OSError بدون زیرکلاس دقیق ظاهر می‌شود؟

سه دلیل ممکن است. اول، سیستمعامل errnoای برگردانده که پایتون معادل زیرکلاس دقیقی برایش تعریف نکرده است (مثلاً برخی کدهای خاص macOS یا BSD). دوم، خطا از یک کتابخانهٔ ثالث می‌آید که خودش OSError را دستی می‌سازد. سوم، در برخی نسخه‌های پایتون، نرمال‌سازی errno به زیرکلاس، با تاخیر انجام می‌شود. برای هر سه، e.errno را در لاگ بگیرید.

آیا می‌توان OSError را در سطح package catch کرد؟

بله، ولی توصیه نمی‌شود. اگر در سطح package، except OSError بگذارید، تمام زیرکلاس‌های معنادار زیر یک چتر پنهان می‌شوند و تشخیص بعدی مشکل می‌شود. بهتر است در هر لایه، فقط همان دسته‌ای را بگیرید که منطقاً در آن لایه قابل مدیریت است.

چطور تشخیص دهم خطا از کد خودم یا از سیستمعامل می‌آید؟

سه نشانه. اول، e.filename معمولاً مسیری را نشان می‌دهد که در کد شما به آن ارجاع داده شده. اگر این مسیر را نمی‌شناسید، خطا از کتابخانه است. دوم، traceback.print_exc() را در لاگ ببینید؛ اولین فریم مربوط به کد شما، نقطهٔ شروع خطاست. سوم، در strace، خطای = -1 دقیقاً همان جایی است که سیستمعامل رد کرده.

آیا retry کردن همه OSErrorها اشتباه است؟

بله، و این یکی از رایج‌ترین اشتباهات در سرویس‌های شبکه‌ای است. فقط خطاهای گذرا (EAGAIN، ETIMEDOUT، ECONNRESET، EINTR) باید retry شوند. خطاهای دائمی مثل ENOENT، EACCES، EISDIR با retry فقط منابع را هدر می‌دهند.

آیا OSError در پایتون ۳ با نسخه‌های قبلی تفاوت دارد؟

بله. پیش از پایتون ۳٫۳، خطاها زیر نام‌های متفاوتی مثل IOError و EnvironmentError منتشر می‌شدند. از ۳٫۳ به بعد، همه زیر OSError جمع شده‌اند و کدهای قدیمی همچنان کار می‌کنند، ولی نام‌های جدید توصیه می‌شوند.

در asyncio چرا گاهی OSError بدون stack trace است؟

در asyncio، خطاهای یک Task اگر await نشوند، در حلقهٔ رویداد باقی می‌مانند و به‌شکل هشدار ظاهر می‌شوند. برای دیدن stack trace کامل، در بالاترین سطح برنامه:

import asyncio

async def main():
    ...

asyncio.run(main())

اگر خطا در تسکی دیگر رخ دهد، پارامتر debug=True در asyncio.run اطلاعات بیشتری می‌دهد.

آیا با تغییر ulimit در سرور، مشکل EMFILE حل می‌شود؟

موقتاً بله، ولی این راه‌حل سطحی است. ریشهٔ EMFILE معمولاً نشت منابع است: فایل‌هایی که بسته نشده‌اند یا سوکت‌هایی که در چرخهٔ خطا رها شده‌اند. ابتدا این نشت را با lsof بررسی و در کد اصلاح کنید، سپس در صورت نیاز ulimit را افزایش دهید.

چطور OSError را در لاگ ساختارمند ثبت کنیم؟

الگوی توصیه‌شده من استفاده از logging با فرمت JSON است:

import json
import logging

def log_oserror(e: OSError) -> None:
    payload = {
        "errno": e.errno,
        "strerror": e.strerror,
        "filename": getattr(e, "filename", None),
    }
    logging.error("os_error %s", json.dumps(payload, ensure_ascii=False))

این ساختار، جستجو در لاگ‌های بزرگ را بسیار کارا می‌کند.

در کتابخانه‌های third-party چه انتظاری از OSError داشته باشیم؟

کتابخانه‌های بالغ معمولاً زیرکلاس دقیق را پرتاب می‌کنند یا حداقل اطلاعات کافی در پیام خطا قرار می‌دهند. اگر کتابخانه‌ای OSError خام و بدون context پرتاب می‌کند، این خودش نشانهٔ ضعف طراحی است. در چنین مواردی، در لایهٔ خودتان با raise ... from e اطلاعات بیشتری اضافه کنید:

try:
    library_call()
except OSError as e:
    raise RuntimeError(
        f"library failed: errno={e.errno}"
    ) from e

این الگو، chain of responsibility را در traceback حفظ می‌کند و دیباگ بعدی را ساده‌تر می‌کند.

درسی که این خطا به معماری کد من داد

OSError، بیشتر از آنکه یک خطای فنی باشد، یک یادآور معماری است. سه اصلی که پس از سال‌ها کار با آن، در طراحی سرویس‌های خودم همواره رعایت می‌کنم:

نخست، مرزها را با سیستمعامل شفاف کنید. هر تعامل با فایل، شبکه یا فرآیند باید در یک لایه مشخص و قابل تست قرار بگیرد. اگر کد تجاری شما مستقیم open() یا socket.connect() صدا می‌زند، OSError وارد منطق اصلی می‌شود و مرزها را به‌هم می‌ریزد.

دوم، خطاها را با context پرتاب کنید، نه خام. هر بار که یک OSError را در لایهٔ خودتان می‌گیرید، به آن مسیر، پارامتر و هدف عملیات را اضافه کنید. سه ماه بعد، وقتی همان خطا در لاگ ظاهر شود، این context نجات‌دهنده است.

سوم، بر بستر واقعی، نه فرض، معماری کنید. سرعت دیسک، تعداد فایل‌های باز، محدودیت‌های شبکه و رفتار سیستمعامل در بار زیاد — این‌ها همه فرض‌های معماری‌اند و باید در محیط واقعی سنجیده شوند. هیچ مقدار threading.active_count() جای تست روی سرور تولیدی را نمی‌گیرد.

در پایان، اگر در پروژهٔ خودتان با حالت خاصی از OSError برخورد کردید که اینجا پوشش داده نشده — مثلاً در ترکیب با Kubernetes، فایل‌سیستم‌های شبکه‌ای NFS یا روی Windows Server با محدودیت‌های خاص — تجربه‌تان را در دیدگاه‌ها بنویسید. به‌ویژه اگر راه‌حل متفاوتی پیدا کرده‌اید که می‌تواند برای خوانندهٔ بعدی مفید باشد. 🧭