ConnectionError در پایتون وقتی پرتاب می‌شود که برنامه نتواند با یک سرویس بیرونی ارتباط شبکه‌ای برقرار کند یا اتصال موجود ناگهان قطع شود؛ یعنی مرز بین برنامه شما و دنیای بیرون، جایی که تأخیر، قطعی و شکست طبیعی است. در ده‌ها پروندهٔ واقعی که دیباگ کرده‌ام، ریشهٔ این خطا همیشه یکی از سه چیز بوده: قطعی گذرا، پیکربندی نادرست timeout یا رفتار نادرست retry. این مقاله حاصل همان تجربه‌هاست؛ از سرویس‌های requests ساده تا pipelineهای asyncio در محیط تولید.

ConnectionError چیست و از کجا می‌آید؟

ConnectionError در پایتون، کلاس پایهٔ تمام خطاهای مربوط به اتصال شبکه‌ای است که از OSError ارث می‌برد. این خطا زمانی پرتاب می‌شود که یک عملیات شبکه‌ای با شکست مواجه شود، ولی نوع دقیق شکست با یکی از زیرکلاس‌های تخصصی مشخص می‌شود. نکتهٔ کلیدی این است که ConnectionError خودش به‌ندرت به‌تنهایی پرتاب می‌شود؛ معمولاً یکی از زیرکلاس‌های آن یعنی ConnectionResetError، ConnectionAbortedError، ConnectionRefusedError یا BrokenPipeError مسئول واقعی است.

ساختار ارث‌بری این خانواده به این شکل است:

BaseException
 └── Exception
      └── OSError
           └── ConnectionError
                ├── BrokenPipeError
                ├── ConnectionAbortedError
                ├── ConnectionRefusedError
                └── ConnectionResetError

تفکیک این چهار زیرکلاس، کلید تشخیص درست است. هر کدام، منبع متفاوتی دارند و راه‌حل متفاوتی می‌طلبند: ConnectionRefusedError یعنی سرویس مقصد بالا نیست یا پورت بسته است؛ ConnectionResetError یعنی سرویس مقصد اتصال را ناگهانی قطع کرد؛ BrokenPipeError یعنی سرویس مقصد بعد از دریافت بخشی از داده، اتصال را بست؛ و ConnectionAbortedError یعنی اتصال توسط میانی شبکه یا سیستم‌عامل لغو شد. مفهوم عمیق‌تر این تفکیک در ویکی‌پدیا ذیل TCP (Transmission Control Protocol) توضیح داده شده است.

ConnectionError یک «پدیدهٔ طبیعی» است، نه یک «باگ». هر سرویس شبکه‌ای که در سطح تولید کار می‌کند، روزانه ده‌ها بار این خطا را تجربه می‌کند — تفاوت در این است که آیا برنامه‌اش آن را مدیریت می‌کند یا نه.

نکتهٔ ظریف: از منظر تاریخچه، پیش از پایتون ۳٫۳ این خطاها با نام‌های socket.error و IOError پرتاب می‌شدند. با PEP 3151، تمام این‌ها زیر چتر OSError و سپس ConnectionError یکپارچه شدند. برای درک چارچوب گسترده‌تر، مدیریت خطا در پایتون و آموزش پایتون از صفر را پیشنهاد می‌کنم.

درخت وارثت و زیرکلاس‌های مهم

هر کدام از چهار زیرکلاس ConnectionError، یک الگوی مشخص از شکست شبکه را نمایندگی می‌کند. شناخت این الگوها، نیمی از تشخیص است. در ادامه، هر کدام را با مثال عملی بررسی می‌کنیم.

ConnectionRefusedError

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

import socket

try:
    s = socket.create_connection(("localhost", 9999))
except ConnectionRefusedError as e:
    print(f"service not available: {e}")

نکتهٔ مهم: ConnectionRefusedError تنها خطایی است که نشانهٔ قطعی «سرویس بالا نیست» می‌دهد. سایر خطاهای این خانواده، نشانهٔ قطعی در حین ارتباط هستند، نه از ابتدا. همین تفاوت، در طراحی retry مهم است: ConnectionRefusedError نیاز به backoff طولانی‌تر دارد چون راه‌اندازی سرویس زمان می‌برد.

ConnectionResetError

این خطا وقتی پرتاب می‌شود که سرویس مقصد اتصال را ناگهانی قطع کند. رایج‌ترین سناریو: یک وب‌سرور با timeout داخلی، اتصال‌های idle را می‌بندد. در محیط production، این خطا در سرویس‌های با connection pool و idle time طولانی بسیار رخ می‌دهد.

import requests

try:
    response = requests.get("https://api.example.com/data", timeout=5)
except requests.exceptions.ConnectionError as e:
    print(f"connection reset: {e}")

در requests، خطاهایی که از سمت socket می‌آیند، در requests.exceptions.ConnectionError بسته‌بندی می‌شوند. تشخیص زیرکلاس اصلی با e.__cause__ یا e.args انجام می‌شود.

BrokenPipeError

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

import socket

s = socket.create_connection(("example.com", 80))
try:
    for chunk in generate_large_payload():
        s.sendall(chunk)
except BrokenPipeError:
    print("server closed connection mid-stream")

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

ConnectionAbortedError

این خطا در ویندوز رایج‌تر است و معمولاً وقتی رخ می‌دهد که یک میانی شبکه (firewall، load balancer، proxy) اتصال را لغو کند. در لینوکس، معادل آن معمولاً ECONNABORTED است.

در پروژه‌های بین‌المللی، این خطا مخصوصاً وقتی رخ می‌دهد که سرویس شما پشت یک CDN یا API Gateway قرار دارد که timeout داخلی کوتاه‌تری از برنامه دارد. یک قاعدهٔ تجربی که یاد گرفته‌ام: همیشه timeout لایه‌های میانی شبکه را حدود ۲۰٪ کوتاه‌تر از timeout برنامه تنظیم کنید، تا برنامه فرصت مدیریت خطا داشته باشد.

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

چرا شکست شبکه، «طبیعی» است نه «استثنا»؟

یکی از بزرگ‌ترین سوءبرداشت‌هایی که در پروژه‌های تولیدی دیده‌ام این است: «اگر شبکه درست باشد، ConnectionError نمی‌گیریم». این برداشت غلط است. در مقیاس تولید، شکست شبکه نه یک «استثنا» بلکه یک «وضعیت طبیعی» است. سه دلیل فنی برای این حرف دارم:

دلیل اول: مقیاس و احتمال

فرض کنید هر فراخوانی شبکه‌ای با احتمال ۰٫۰۱٪ شکست بخورد. اگر سرویس شما روزانه یک میلیون درخواست بفرستد، به‌طور میانگین ۱۰۰ شکست در روز خواهید داشت. این عدد، «استثنا» نیست؛ بخشی از عملیات عادی است.

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

دلیل دوم: رفتار طبیعی TCP

پروتکل TCP، ذاتاً یک پروتکل با مدیریت اتصال است. هر اتصال TCP، یک چرخهٔ عمر دارد: handshake، انتقال داده، و بستن اتصال. در حین این چرخه، چند پدیدهٔ طبیعی می‌تواند باعث ConnectionError شود:

  • idle timeout: سرور اتصال‌های بی‌استفاده را می‌بندد (معمولاً ۶۰ تا ۳۰۰ ثانیه)
  • RST (Reset): سرور یا فایروال، اتصال را ناگهانی قطع می‌کند
  • FIN با تأخیر: سرور بستن اتصال را آغاز می‌کند ولی client هنوز داده می‌فرستد
  • MTU mismatch: اختلاف در حداکثر اندازهٔ بسته، باعث بسته‌های گم‌شده می‌شود

هیچ‌کدام از این‌ها «باگ» نیستند؛ رفتار طبیعی شبکه‌های واقعی هستند. برای مطالعهٔ بیشتر دربارهٔ این رفتارها، برنامه‌نویسی سوکت در پایتون مرجع مکمل خوبی است.

دلیل سوم: تأخیر و timeout

در شبکه‌های واقعی، تأخیر (latency) تغییرپذیر است. یک API که به‌طور معمول در ۱۰۰ میلی‌ثانیه پاسخ می‌دهد، ممکن است در ساعات اوج یا در شرایط اختلال شبکه، چند ثانیه زمان ببرد. اگر برنامه شما timeout نداشته باشد یا timeout آن طولانی باشد، ممکن است در انتظار پاسخ بماند تا سرویس‌عامل اتصال را قطع کند و سپس ConnectionError بگیرید.

در برنامه‌نویسی شبکه، «انتظار برای پاسخ» بدترین سناریو است. همیشه timeout داشته باشید، حتی اگر به‌نظر می‌رسد «سرعت خوبی داریم».

این سه دلیل، بنیان فلسفهٔ طراحی «circuit breaker» و «retry with backoff» در سیستم‌های توزیع‌شده هستند. در ادامه، الگوهای عملی هر کدام را بررسی می‌کنیم.

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

در طول سال‌ها کار با پایتون، ConnectionError را در این هشت الگو دیده‌ام. شناختن هر الگو، تشخیص را چند برابر سریع‌تر می‌کند.

سناریوی اول: فراخوانی API بدون retry

رایج‌ترین الگو: کد به یک API خارجی فراخوانی می‌زند، و در اولین شکست، برنامه متوقف می‌شود. راه‌حل ساده: یک حلقهٔ retry با backoff نمایی. در بخش بعدی، الگوی کامل این retry را می‌بینیم.

سناریوی دوم: نبود timeout در requests

کتابخانهٔ requests به‌طور پیش‌فرض timeout ندارد. یعنی اگر سرویس مقصد کند باشد ولی اتصال TCP برقرار باشد، برنامه شما بی‌نهایت منتظر می‌ماند:

import requests

# اشتباه: بدون timeout
response = requests.get("https://slow-api.example.com")

# درست: با timeout مشخص
response = requests.get("https://slow-api.example.com", timeout=(5, 30))
# 5 ثانیه برای connect، 30 ثانیه برای read

پارامتر timeout در requests به‌صورت tuple گرفته می‌شود: اولین عدد برای connect timeout، دومین برای read timeout. توصیهٔ من: همیشه از tuple استفاده کنید، چون هدف connect و read متفاوت است.

سناریوی سوم: connection pool با idle طولانی

در سرویس‌های طولانی‌مدت، اتصال‌های idle در pool ممکن است توسط سرور بسته شوند. اگر برنامه متوجه نشود و از همان اتصال استفاده کند، ConnectionResetError می‌گیرد. راه‌حل: تنظیم pool_pre_ping در SQLAlchemy یا معادل آن در دیگر کتابخانه‌ها.

سناریوی چهارم: قطعی DNS

گاهی ConnectionError ناشی از مشکل در DNS resolution است. اگر کتابخانه‌ای مثل requests نتواند نام دامنه را به IP تبدیل کند، خطای gaierror می‌دهد که زیرکلاس ConnectionError نیست ولی در requests به ConnectionError تبدیل می‌شود. راه‌حل: کش DNS با TTL مناسب و استفاده از resolver پایدار.

سناریوی پنجم: retry بدون backoff

کد زیر ظاهراً retry دارد ولی در واقع سیستم مقصد را بیشتر تحت فشار می‌گذارد:

# اشتباه: بلافاصله retry
for attempt in range(5):
    try:
        return make_request()
    except ConnectionError:
        continue  # بدون sleep!

راه‌حل: backoff نمایی با jitter. این الگو در بخش بعدی با جزئیات توضیح داده شده است.

سناریوی ششم: lack of circuit breaker

اگر سرویس مقصد به‌طور کلی از دسترس خارج شده باشد، retry کردن مداوم فقط منابع شما را می‌سوزاند. راه‌حل: الگوی circuit breaker که بعد از چند شکست متوالی، موقتاً درخواست‌ها را رد می‌کند.

سناریوی هفتم: thread safety در connection pool

در برنامه‌های چند-ریسمانی، استفادهٔ همزمان چند thread از یک اتصال، می‌تواند باعث ConnectionError شود. راه‌حل: هر thread اتصال خودش را داشته باشد، یا از thread-safe pool استفاده کند.

سناریوی هشتم: قطع اتصال توسط load balancer

در محیط‌های ابری، load balancer ممکن است به‌طور دوره‌ای اتصال‌های idle را ببندد. راه‌حل: keep-alive فعال نگه دارید و timeout برنامه را کوتاه‌تر از idle timeout لایه‌های میانی تنظیم کنید. این الگو در پروژه‌های ساخت API با پایتون زیاد دیده می‌شود.

سناریوهای مشابه در خطاهای مربوط به زمان‌بندی، در خطای TimeoutError در پایتون هم پوشش داده شده است.

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

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

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

نخستین گام، استخراج نوع دقیق شکست است:

import traceback
import logging

try:
    make_network_call()
except ConnectionError as e:
    subclass = type(e).__name__
    cause = type(e.__cause__).__name__ if e.__cause__ else None
    logging.error(
        "connection error: %s; cause=%s; args=%s",
        subclass, cause, e.args
    )
    traceback.print_exc()

این لاگ، هر سه لایهٔ اطلاعات را ثبت می‌کند: نوع exception، cause اصلی (که معمولاً زیرکلاس دقیق socket است) و آرگومان‌ها. تفکیک ConnectionRefusedError از ConnectionResetError در این گام انجام می‌شود.

گام دوم: بررسی سطح دسترسی به سرویس

قبل از هر اقدام کدی، با ابزارهای سیستمی بررسی کنید که سرویس مقصد از دیدگاه شما فعال است:

$ ping api.example.com
$ nc -zv api.example.com 443
$ curl -v --max-time 5 https://api.example.com/health
$ dig api.example.com

این چهار دستور، چهار لایهٔ مختلف را بررسی می‌کنند: مسیر شبکه، پورت TCP، دسترسی HTTP، و DNS. در یکی از پرونده‌های خودم، مشکل در DNS بود که با dig لو رفت.

گام سوم: بررسی TLS handshake

در سرویس‌های HTTPS، گاهی مشکل در لایهٔ TLS است نه شبکه:

$ openssl s_client -connect api.example.com:443 -servername api.example.com

اگر این دستور با خطا بسته شود، مشکل مربوط به گواهی SSL یا پروتکل TLS است، نه شبکه. برای مطالعهٔ بیشتر دربارهٔ خطاهای SSL، بررسی گواهی SSL در پایتون نکات مرتبط را ارائه می‌دهد.

گام چهارم: پایش retry و زمان‌بندی

اگر خطا دوره‌ای رخ می‌دهد، الگوی زمانی را بررسی کنید. اگر خطا همیشه بعد از N دقیقهٔ idle رخ می‌دهد، مسئله idle timeout است. اگر خطا در ساعات اوج رخ می‌دهد، مسئله ظرفیت است. لاگ‌گیری دقیق با timestamp، این الگو را روشن می‌کند:

import logging
import time
from datetime import datetime

logger = logging.getLogger(__name__)

def logged_call():
    start = time.monotonic()
    try:
        return make_network_call()
    except ConnectionError as e:
        elapsed = time.monotonic() - start
        logger.error(
            "connection failed after %.3fs at %s: %s",
            elapsed, datetime.utcnow().isoformat(), type(e).__name__
        )
        raise

گام پنجم: شبیه‌سازی قطعی

در محیط staging، عمداً قطعی شبکه را شبیه‌سازی کنید تا رفتار برنامه در شرایط واقعی را ببینید. ابزارهایی مثل toxiproxy، tc (traffic control) یا حتی قطع سادهٔ سوکت با iptables می‌توانند مفید باشند:

# قطع موقت ترافیک به یک IP خاص
$ sudo iptables -A OUTPUT -d api.example.com -j DROP
# بررسی رفتار برنامه
$ python test_app.py
# بازیابی
$ sudo iptables -D OUTPUT -d api.example.com -j DROP

این رویکرد، ابزار اصلی من در پروژه‌های زیرساختی است. تجربه‌ام: تیم‌هایی که فاز «chaos testing» در pipeline دارند، تقریباً هیچ‌وقت با ConnectionError در تولید غافلگیر نمی‌شوند. برای مطالعهٔ بیشتر، Django REST Framework در پایتون نمونه‌های عملی خوبی از مقاوم‌سازی API ارائه می‌دهد.

الگوی retry هوشمند با backoff

retry بدون backoff، بدترین نوع retry است. اگر درخواست شما در همان ثانیه‌ای که شکست خورد، دوباره فرستاده شود، سرویس مقصد که خودش در وضعیت بحرانی است، تحت فشار بیشتری قرار می‌گیرد. راه‌حل استاندارد: exponential backoff با jitter (لرزش تصادفی).

الگوی پایه: retry با backoff نمایی

import random
import time

def retry_with_backoff(
    func,
    max_attempts=5,
    base_delay=0.5,
    max_delay=30.0,
    exceptions=(ConnectionError,),
):
    last_exception = None
    for attempt in range(max_attempts):
        try:
            return func()
        except exceptions as e:
            last_exception = e
            if attempt == max_attempts - 1:
                break
            # backoff نمایی + jitter
            delay = min(base_delay * (2 ** attempt), max_delay)
            delay += random.uniform(0, delay * 0.1)
            time.sleep(delay)
    raise last_exception

نکات کلیدی این الگو:

  • max_attempts محدود: هرگز حلقهٔ بی‌پایان ننویسید. در سرویس‌های production، ۳ تا ۵ تلاش کافی است.
  • base_delay معقول: مقدار اولیه باید متناسب با ماهیت سرویس باشد. API سریع: ۰٫۱ ثانیه؛ دیتابیس: ۰٫۵ ثانیه.
  • max_delay: سقف تأخیر را تعیین کنید تا در تلاش‌های بعدی بیش از حد منتظر نمانید.
  • jitter: لرزش تصادفی، «thundering herd» را کم می‌کند؛ یعنی وقتی چند client همزمان retry می‌کنند، بار روی سرویس مقصد به‌طور یکنواخت پخش می‌شود.

الگوی پیشرفته: تمایز خطاهای retryable و non-retryable

همهٔ خطاها نباید retry شوند. خطاهای زیر retryable هستند:

  • ConnectionResetError و ConnectionAbortedError — قطعی گذرا
  • TimeoutError — ممکن است ناشی از کندی موقت باشد
  • خطاهای HTTP 5xx — معمولاً ناشی از مشکل سمت سرور

خطاهای زیر retryable نیستند:

  • ConnectionRefusedError — اگر سرویس پایین باشد، retry فقط تأخیر را طولانی می‌کند (مگر با circuit breaker)
  • خطاهای HTTP 4xx — مسئله در درخواست شماست، نه سرویس
  • خطاهای تأیید هویت — retry بی‌فایده است
RETRYABLE = (
    ConnectionResetError,
    ConnectionAbortedError,
    TimeoutError,
)

def safe_call(func):
    try:
        return func()
    except RETRYABLE:
        # منطق retry
        raise
    except ConnectionRefusedError:
        # علامت قطعی، نه retry
        raise

استفاده از کتابخانهٔ tenacity

برای پروژه‌های production، کتابخانهٔ tenacity الگوی کامل و انعطاف‌پذیری می‌دهد:

from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential_jitter,
    retry_if_exception_type,
)

@retry(
    stop=stop_after_attempt(5),
    wait=wait_exponential_jitter(initial=0.5, max=30),
    retry=retry_if_exception_type((ConnectionResetError, TimeoutError)),
)
def fetch_data():
    return requests.get("https://api.example.com/data", timeout=(5, 30))

مزیت tenacity: پشتیبانی از الگوهای مختلف retry، لاگ‌گیری داخلی، و امکان ترکیب با circuit breaker. برای پروژه‌های بزرگ، این کتابخانه انتخاب اول من است. سناریوهای مشابه در کتابخانهٔ httpx در پایتون هم پوشش داده شده است.

timeout: پیش‌نیاز هر فراخوانی شبکه

یک قاعدهٔ بی‌استثنا در معماری شبکه: هیچ فراخوانی شبکه‌ای بدون timeout ننویسید. این قاعده، نه فقط برای جلوگیری از توقف برنامه، بلکه برای امنیت و پایدارسازی سرویس هم ضروری است.

انواع timeout

سه نوع timeout در شبکه وجود دارد که هر کدام معنا و کاربرد متفاوتی دارند:

نوعمعنامقدار معمول
connect timeoutزمان مجاز برای برقراری اتصال TCP۳ تا ۵ ثانیه
read timeoutزمان مجاز برای دریافت پاسخ پس از ارسال درخواست۱۰ تا ۶۰ ثانیه
write timeoutزمان مجاز برای ارسال کل درخواستمعمولاً برابر با read

در requests، پارامتر timeout به‌صورت tuple گرفته می‌شود:

requests.get(
    "https://api.example.com/data",
    timeout=(5, 30),  # connect=5s, read=30s
)

اگر فقط یک عدد بدهید، همان عدد برای هر دو استفاده می‌شود. توصیهٔ من: همیشه tuple استفاده کنید، چون کاربرد connect و read متفاوت است.

انتخاب مقدار درست timeout

انتخاب timeout، تعادل بین دو هدف است: از یک طرف، نباید آن‌قدر کوتاه باشد که درخواست‌های سالم را رد کند؛ از طرف دیگر، نباید آن‌قدر طولانی باشد که درخواست‌های مشکل‌دار، منابع را هدر دهند. قاعدهٔ من: timeout را معادل P99 پاسخ‌های موفق همان سرویس به‌علاوهٔ ۵۰٪ ضریب امنیت تعیین کنید. برای نمونه، اگر P99 سرویس شما ۲۰۰ میلی‌ثانیه است، timeout ۳۰۰ میلی‌ثانیه معقول است. برای سرویس‌هایی که نوسان بیشتری دارند (مثل API‌های خارجی)، از قاعدهٔ بالاتری استفاده کنید.

timeout در asyncio

در asyncio، هم ابزار asyncio.wait_for برای timeout کل عملیات و هم asyncio.timeout (پایتون ۳٫۱۱+) برای timeout ساخت‌یافته در دسترس است:

import asyncio

async def fetch_with_timeout(url):
    async with asyncio.timeout(10):  # پایتون 3.11+
        return await fetch(url)

این الگو هم برای خود عملیات و هم برای زیرعملیات قابل اعمال است. برای مطالعهٔ بیشتر دربارهٔ تفاوت timeout و ConnectionError، مقالهٔ خطای TimeoutError در پایتون نکات مکمل را ارائه می‌دهد.

ConnectionError در requests

کتابخانهٔ requests، رایج‌ترین کتابخانهٔ HTTP در پایتون است و رفتار اختصاصی با خطاهای شبکه دارد. این رفتار در نسخه‌های مختلف تفاوت‌های ظریفی دارد که در production بسیار مهم است.

سلسله‌مراتب استثناها در requests

کتابخانهٔ requests استثناهای خود را در ماژول requests.exceptions تعریف می‌کند:

RequestException
 ├── HTTPError
 ├── ConnectionError
 │    ├── ConnectTimeout
 │    ├── ReadTimeout
 │    ├── SSLError
 │    └── ProxyError
 ├── Timeout
 └── TooManyRedirects

توجه کنید که requests.exceptions.ConnectionError با ConnectionError استاندارد پایتون تفاوت دارد. این کلاس، استثنای تخصصی requests است که از RequestException ارث می‌برد و جدا از استثنای سطح‌پایین socket است. برای گرفتن هر دو نوع، باید هم requests.exceptions.ConnectionError و هم ConnectionError استاندارد را در except قرار دهید، یا از الگوی زیر استفاده کنید:

import requests

try:
    response = requests.get("https://api.example.com", timeout=(5, 30))
except requests.exceptions.RequestException as e:
    # گرفتن هر نوع خطای requests
    handle_error(e)

تشخیص زیرکلاس دقیق

در requests، برای تشخیص دقیق نوع شکست، از e.__cause__ استفاده کنید:

import requests
import socket

try:
    response = requests.get("https://api.example.com", timeout=5)
except requests.exceptions.ConnectionError as e:
    cause = e.__cause__
    if isinstance(cause, socket.gaierror):
        # خطای DNS
        pass
    elif isinstance(cause, ConnectionRefusedError):
        # سرویس بالا نیست
        pass
    elif isinstance(cause, ConnectionResetError):
        # اتصال قطع شد
        pass

این تفکیک، در طراحی retry هوشمند ضروری است. برای مطالعهٔ بیشتر دربارهٔ تفاوت رفتار requests و httpx، کتابخانهٔ httpx در پایتون مرجع مکمل خوبی است.

session و connection pool

استفاده از requests.Session به‌جای فراخوانی‌های مستقیم requests.get، مزایای قابل‌توجهی در connection pool و کارایی دارد:

session = requests.Session()
adapter = requests.adapters.HTTPAdapter(
    pool_connections=10,
    pool_maxsize=20,
    max_retries=3,
)
session.mount("https://", adapter)

response = session.get("https://api.example.com/data", timeout=(5, 30))

پارامتر pool_connections تعیین می‌کند چند connection pool جداگانه (برای host‌های مختلف) داشته باشید و pool_maxsize حداکثر اتصال همزمان در هر pool را مشخص می‌کند. تنظیم درست این دو، تفاوت بین سرویس کند و سریع در بار زیاد است.

ConnectionError در asyncio و aiohttp

در برنامه‌های async، رفتار ConnectionError کمی متفاوت است و مدیریت آن نیاز به دقت بیشتری دارد. خطاها ممکن است در Task‌های جداگانه رخ دهند و اگر await نشوند، ممکن است در لاگ حلقهٔ رویداد گم شوند.

گرفتن خطا در gather

در asyncio.gather، اگر یکی از Task‌ها شکست بخورد، به‌طور پیش‌فرض کل gather لغو می‌شود. برای رفتار بهتر، از پارامتر return_exceptions=True استفاده کنید:

import asyncio

async def main():
    results = await asyncio.gather(
        fetch_url("https://api1.example.com"),
        fetch_url("https://api2.example.com"),
        fetch_url("https://api3.example.com"),
        return_exceptions=True,
    )
    for i, result in enumerate(results):
        if isinstance(result, Exception):
            print(f"task {i} failed: {type(result).__name__}")
        else:
            process(result)

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

aiohttp و connection pool async

در aiohttp، مدیریت connection pool از طریق ClientSession انجام می‌شود:

import aiohttp

async def fetch(url):
    connector = aiohttp.TCPConnector(
        limit=100,
        limit_per_host=10,
        ttl_dns_cache=300,
    )
    timeout = aiohttp.ClientTimeout(total=30, connect=5)
    async with aiohttp.ClientSession(
        connector=connector, timeout=timeout
    ) as session:
        try:
            async with session.get(url) as response:
                return await response.text()
        except aiohttp.ClientConnectorError as e:
            # خطای connect
            raise
        except aiohttp.ServerDisconnectedError as e:
            # خطای قطع اتصال در حین انتقال
            raise

نکتهٔ ظریف: در aiohttp، خطاهای مختلف با کلاس‌های مجزا مدیریت می‌شوند (ClientConnectorError، ServerDisconnectedError، ClientPayloadError). این تفکیک دقیق‌تر از requests است و اجازه می‌دهد retry دقیق‌تری پیاده کنید.

نکات ظریف in async

سه نکته که در پروژه‌های async به آن‌ها برخورده‌ام:

  1. task بازراه‌اندازی: در asyncio، اگر یک Task بدون await باقی بماند و خطایی داشته باشد، ممکن است در لاگ ظاهر نشود. همیشه تسک‌ها را در try/except قرار دهید یا از asyncio.TaskGroup استفاده کنید.
  2. timeout در حلقهٔ رویداد: در پایتون ۳٫۱۱+، asyncio.timeout یکی از بهترین ابزارهاست. در نسخه‌های قدیمی‌تر، از asyncio.wait_for استفاده کنید.
  3. session بازنشانی: هر بار که یک ClientSession جدید بسازید، connection pool جدیدی ساخته می‌شود. برای سرویس‌های طولانی‌مدت، یک session بسازید و از آن استفاده کنید.

برای مطالعهٔ بیشتر دربارهٔ رفتار async و خطاهای مرتبط، خطای RuntimeError در پایتون نکات مکمل را ارائه می‌دهد.

connection pool و idle timeout

در سرویس‌های طولانی‌مدت، مدیریت connection pool یکی از مهم‌ترین دغدغه‌هاست. یک pool بد تنظیم‌شده، می‌تواند منبع بی‌پایان ConnectionError باشد.

مشکل اصلی: idle timeout ناهمگون

در production، شما با چند لایهٔ timeout مواجه‌اید:

  • timeout اتصال در سیستم‌عامل (معمولاً چند دقیقه)
  • idle timeout load balancer (معمولاً ۶۰ تا ۳۰۰ ثانیه)
  • idle timeout وب‌سرور مقصد (معمولاً ۳۰ تا ۹۰ ثانیه)
  • timeout برنامه شما (که باید کوتاه‌تر از همه باشد)

اگر برنامه شما timeout ۵ دقیقه‌ای داشته باشد ولی load balancer بعد از ۶۰ ثانیه اتصال را ببندد، شما انتظار ۵ دقیقه‌ای می‌کشید و بعد ConnectionResetError می‌گیرید. راه‌حل: همیشه timeout داخلی برنامه را کوتاه‌تر از timeout لایه‌های بیرونی تنظیم کنید.

keep-alive و pool_pre_ping

در SQLAlchemy، پارامتر pool_pre_ping به‌طور خودکار اتصال‌های قطع‌شده را تشخیص می‌دهد:

from sqlalchemy import create_engine

engine = create_engine(
    "postgresql://user:pass@localhost/mydb",
    pool_size=10,
    max_overflow=20,
    pool_pre_ping=True,  # تشخیص اتصال‌های قطع‌شده
    pool_recycle=3600,    # بازیابی اتصال بعد از یک ساعت
)

دو پارامتر کلیدی: pool_pre_ping قبل از هر استفاده از اتصال، یک query سبک می‌فرستد تا از سلامت آن مطمئن شود؛ pool_recycle اتصال‌های قدیمی را بازنشانی می‌کند تا از idle timeout جلوگیری کند. این ترکیب، در پروژه‌های اتصال پایتون به MySQL بسیار مفید است.

تنظیمات redis-py

در redis-py، پارامترهای socket_keepalive و socket_timeout نقش کلیدی دارند:

import redis

client = redis.Redis(
    host="localhost",
    port=6379,
    socket_keepalive=True,
    socket_timeout=5,
    socket_connect_timeout=3,
    health_check_interval=30,
    retry_on_timeout=True,
)

پارامتر health_check_interval به‌طور دوره‌ای اتصال را بررسی می‌کند و retry_on_timeout در صورت timeout، خودکار retry می‌زند. این ترکیب، در پروژه‌های production ضروری است.

مونیتورینگ pool

در سرویس‌های بزرگ، مونیتورینگ connection pool ضروری است. سه شاخص را پایش کنید: تعداد اتصال‌های فعال، اتصال‌های idle، و مدت زمان انتظار برای دریافت اتصال از pool. اگر این اعداد به‌طور مداوم به سقف می‌رسند، pool خیلی کوچک است؛ اگر همیشه در سطوح پایین هستند، pool بزرگ‌تر از نیاز است و منابع هدر می‌رود.

در مدیریت connection pool، تنظیم درست مهم‌تر از اندازهٔ بزرگ است. یک pool با اندازهٔ متوسط که درست بسته می‌شود، بهتر از pool بزرگی است که اتصال‌های مرده را نگه می‌دارد.

ConnectionError در دیتابیس و Redis

دیتابیس‌ها و Redis، منبع اصلی ConnectionError در سرویس‌های backend هستند. رفتار هر کدام، تفاوت‌های ظریفی دارد که در production بسیار مهم است.

PostgreSQL و psycopg2

در psycopg2، خطاهای اتصال به‌طور معمول در OperationalError بسته‌بندی می‌شوند که زیرکلاس DatabaseError است. ولی در پیوندهای پایین‌تر، خطای socket اصلی قابل استخراج است:

import psycopg2

try:
    conn = psycopg2.connect(
        host="localhost",
        database="mydb",
        user="user",
        password="pass",
        connect_timeout=5,
    )
except psycopg2.OperationalError as e:
    # معمولاً خطای شبکه یا احراز هویت
    print(f"operational error: {e}")

پارامتر connect_timeout در psycopg2، مستقیم روی سوکت اعمال می‌شود و از انتظار بی‌پایان جلوگیری می‌کند. این پارامتر، ضروری است.

MySQL و mysql-connector

در mysql-connector-python، خطاهای اتصال مشابه رفتار می‌کنند:

import mysql.connector

try:
    conn = mysql.connector.connect(
        host="localhost",
        user="user",
        password="pass",
        database="mydb",
        connection_timeout=5,
        autocommit=True,
    )
except mysql.connector.Error as e:
    if e.errno == 2003:
        # cannot connect to MySQL server
        pass
    elif e.errno == 2013:
        # lost connection during query
        pass

نکتهٔ مهم: در MySQL، دو کد خطای کلیدی وجود دارد: ۲۰۰۳ برای «اتصال برقرار نشد» و ۲۰۱۳ برای «اتصال در حین query قطع شد». هر کدام، استراتژی متفاوتی می‌طلبد. برای مطالعهٔ بیشتر، رفع خطای MySQL server has gone away نکات مرتبط را ارائه می‌دهد.

Redis و cache

در Redis، خطاهای اتصال می‌توانند در چرخهٔ بار بالا بسیار شایع باشند. الگوی امن:

import redis
from redis.exceptions import ConnectionError as RedisConnectionError

try:
    client.set("key", "value")
except RedisConnectionError:
    # Redis از دسترس خارج شده
    # راه‌حل: fallback به دیتابیس اصلی
    pass

نکتهٔ مهم: در سرویس‌های با Redis، خطای Redis نباید برنامه را متوقف کند. همیشه یک مسیر fallback داشته باشید: اگر Redis قطع شد، مستقیماً از دیتابیس اصلی بخوانید.

mongo و Motor

در MongoDB، مدیریت اتصال از طریق MongoClient انجام می‌شود که به‌طور خودکار retry می‌زند:

from pymongo import MongoClient
from pymongo.errors import ConnectionFailure

try:
    client = MongoClient(
        "mongodb://localhost:27017",
        serverSelectionTimeoutMS=5000,
        connectTimeoutMS=5000,
    )
    client.admin.command("ping")
except ConnectionFailure as e:
    print(f"cannot connect to MongoDB: {e}")

MongoDB به‌طور داخلی retry می‌زند و ابزار ping برای بررسی سلامت اتصال بسیار مفید است. برای مطالعهٔ بیشتر، آموزش pymongo در پایتون مرجع مکمل خوبی است.

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

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

تفاوت ConnectionError و TimeoutError چیست؟

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

چرا requests.exceptions.ConnectionError با ConnectionError استاندارد تفاوت دارد؟

کتابخانهٔ requests استثناهای خودش را در ماژول requests.exceptions تعریف می‌کند که جدا از استثناهای استاندارد پایتون است. requests.exceptions.ConnectionError از requests.exceptions.RequestException ارث می‌برد، نه از ConnectionError استاندارد. برای گرفتن هر دو، از except (requests.exceptions.ConnectionError, ConnectionError) استفاده کنید یا از والد مشترک OSError در requests سطح پایین‌تر استفاده کنید.

چرا با اینکه اینترنت سالم است ConnectionError می‌گیرم؟

سه دلیل اصلی: اول، مسئله ممکن است در DNS resolution باشد، نه در شبکه. دوم، ممکن است سرویس مقصد پایین باشد، نه شبکه شما. سوم، ممکن است فایروال یا proxy میانی، اتصال را قطع کند. برای تشخیص دقیق، ابتدا با ping و dig بررسی کنید، سپس با curl -v تست کنید.

آیا باید برای همهٔ ConnectionErrorها retry بزنم؟

خیر. تفکیک بین retryable و non-retryable ضروری است. ConnectionResetError، ConnectionAbortedError و TimeoutError retryable هستند. ConnectionRefusedError ممکن است retryable باشد ولی نیاز به circuit breaker دارد. خطاهای احراز هویت و 4xx retryable نیستند. این تفکیک، جلوگیری از هدر رفتن منابع را تضمین می‌کند.

timeout مناسب برای هر فراخوانی چقدر است؟

قاعدهٔ سرانگشتی: timeout را معادل P99 پاسخ‌های موفق + ۵۰٪ ضریب امنیت تعیین کنید. برای API سریع، ۳ تا ۵ ثانیه؛ برای دیتابیس، ۵ تا ۱۰ ثانیه؛ برای فراخوانی‌های طولانی (مثل پردازش تصویر)، ۳۰ تا ۶۰ ثانیه. مهم‌تر از مقدار، داشتن timeout صریح در همهٔ فراخوانی‌هاست.

چرا ConnectionError در محیط Docker بیشتر رخ می‌دهد؟

چون در Docker، لایه‌های متعددی از شبکه وجود دارد: bridge network، overlay network در swarm یا Kubernetes، و تفاوت‌های DNS داخلی. اگر کانتینر شما به سرویس دیگری در شبکه Docker متصل می‌شود، همیشه از نام سرویس (که توسط DNS داخلی resolve می‌شود) استفاده کنید، نه از localhost. همچنین، تنظیمات network_mode و depends_on را با دقت بررسی کنید.

آیا ConnectionError می‌تواند ناشی از problem در SSL باشد؟

بله. در مواردی که مشکل در TLS handshake باشد، ممکن است به‌جای SSLError، یک ConnectionError بگیرید؛ به‌ویژه اگر خطا در سطح socket رخ دهد. برای تفکیک، حتماً e.__cause__ را بررسی کنید. اگر ssl.SSLError در cause باشد، مسئله TLS است، نه شبکه. برای مطالعهٔ بیشتر، بررسی گواهی SSL در پایتون نکات مرتبط را ارائه می‌دهد.

چگونه در pytest، ConnectionError را تست کنیم؟

با استفاده از pytest.raises و mock کردن کتابخانهٔ شبکه:

import pytest
from unittest.mock import patch
import requests

def test_connection_error():
    with patch("requests.get") as mock_get:
        mock_get.side_effect = requests.exceptions.ConnectionError("test")
        with pytest.raises(requests.exceptions.ConnectionError):
            fetch_data()

برای تست رفتار retry، از side_effect با لیست استفاده کنید:

mock_get.side_effect = [
    requests.exceptions.ConnectionError("fail"),
    requests.exceptions.ConnectionError("fail"),
    {"status": "ok"},
]

این الگو، امکان تست دقیق سناریوهای retry را می‌دهد. برای مطالعهٔ بیشتر دربارهٔ رفتار خطاها در تست، خطای AssertionError در پایتون نکات مرتبط را ارائه می‌دهد.

آیا باید ConnectionError را در سطح سرویس log کنیم؟

بله، ولی با شدت مناسب. در سرویس‌های production، ConnectionError روزانه ده‌ها بار رخ می‌دهد و نباید هر بار در سطح ERROR لاگ شود، وگرنه هشدارها بی‌ارزش می‌شوند. توصیهٔ من: شکست‌های اول و دوم در سطح WARNING، شکست سوم و بعد در سطح ERROR، و اگر نرخ شکست از آستانه عبور کرد (مثلاً بیش از ۵٪ درخواست‌ها)، در سطح CRITICAL با هشدار به سیستم مانیتورینگ.

آیا باید از proxy برای فراخوانی‌های خارجی استفاده کنم؟

بسته به شرایط. در محیط‌های سازمانی، proxy ممکن است اجباری باشد. در محیط‌های خانگی، می‌توانید از proxy برای مسیرپذیری بهتر استفاده کنید. ولی نکتهٔ مهم: proxy خودش می‌تواند منبع ConnectionError باشد. اگر خطای شما بیشتر بعد از تنظیم proxy رخ می‌دهد، مسئله در proxy است، نه در شبکه اصلی. برای پروژه‌های بین‌المللی، استفاده از proxy region‌محور می‌تواند تأخیر را کاهش دهد.

چطور بفهمم ConnectionError ناشی از load balancer است یا سرویس مقصد؟

سه نشانه: اول، خطا فقط در ساعات اوج رخ می‌دهد، که نشانهٔ ظرفیت LB است. دوم، خطا با الگوی زمانی دقیق (مثلاً هر ۶۰ ثانیه) رخ می‌دهد، که نشانهٔ idle timeout است. سوم، خطا با ConnectionResetError ظاهر می‌شود که معمولاً از سمت LB می‌آید. برای تشخیص قطعی، لاگ‌های LB را بررسی کنید یا مستقیماً به سرویس مقصد (با دور زدن LB) تست بزنید.

آیا استفاده از socket_keepalive کمک می‌کند؟

بله، در بسیاری از موارد. socket_keepalive باعث می‌شود سیستم‌عامل به‌طور دوره‌ای بسته‌های keep-alive بفرستد تا اتصال‌های idle حفظ شوند. این تنظیم، مخصوصاً در محیط‌هایی که load balancer اتصال‌های idle را می‌بندد، بسیار مفید است. در requests و کتابخانه‌های مشابه، این تنظیم معمولاً از طریق adapter قابل اعمال است.

برای مطالعات مکمل در حوزهٔ خطاهای پایتون، خطای StopIteration در پایتون و خطای UnicodeDecodeError در پایتون نمونه‌های خوبی از خطاهای شبکه‌ای و داده‌ای در پایتون هستند.

آنچه مقاوم‌سازی شبکه به معماری سرویس‌های من آموخت

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

نخست، شکست شبکه را «طبیعی» ببینید، نه «استثنایی». در مقیاس production، شکست شبکه نه یک استثنا، بلکه بخشی از واقعیت عملیات است. اگر از ابتدا این دیدگاه را بپذیرید، طراحی شما تغییر می‌کند: هر فراخوانی شبکه timeout دارد، هر عملیات پرتکرار retry دارد، و هر سرویس مهم fallback دارد. تجربه‌ام این است که تیم‌هایی که این نگاه را درونی کرده‌اند، تقریباً هیچ‌وقت با ConnectionError در تولید غافلگیر نمی‌شوند.

دوم، بودجهٔ خطا را در سطح سرویس تعریف کنید. هر سرویس باید یک بودجهٔ مشخص برای شکست داشته باشد: مثلاً «حداکثر ۱٪ از درخواست‌ها می‌توانند شکست بخورند». وقتی این بودجه تعریف شد، هم retry هوشمندتر طراحی می‌شود و هم آلرت‌ها معنادار می‌شوند. در پروژه‌های خودم، سه سطح بودجه تعریف می‌کنم: WARNING (۲٪)، ERROR (۵٪)، CRITICAL (۱۰٪). هر سطح، واکنش متفاوتی دارد.

سوم، از chaos testing استفاده کنید. در محیط staging، عمداً قطعی شبکه و کندی را شبیه‌سازی کنید تا رفتار برنامه در شرایط واقعی را ببینید. ابزارهایی مثل toxiproxy، tc، یا حتی یک اسکریپت ساده که به‌طور تصادفی اتصال‌ها را قطع می‌کند، در این زمینه بسیار مفیدند. این تمرین، نه فقط برای یافتن باگ‌ها، بلکه برای ایجاد اعتماد به معماری خودتان ضروری است.

در پایان، اگر در پروژه‌ای با حالت خاصی از ConnectionError برخورد کردید که این‌جا پوشش داده نشده — مثلاً در ترکیب با gRPC، WebSocket، MQTT، یا در محیط‌های Kubernetes با پیچیدگی‌های شبکه overlay — تجربه‌تان را در دیدگاه‌ها بنویسید. به‌ویژه اگر راه‌حلی متفاوت از رویکردهای معمول پیدا کرده‌اید که می‌تواند برای خوانندهٔ بعدی ارزشمند باشد. 🌐