Почему я не могу поймать это исключение Python? Модуль/класс исключения не соответствует отслеживаемому модулю/классу

Я писал несколько модулей etcd для SaltStack и столкнулся с этой странной проблемой, где это каким-то образом мешало мне перехватывать исключение, и мне интересно, как это делается. Кажется, что он сосредоточен вокруг urllib3.

Небольшой script (не соль):

import etcd
c = etcd.Client('127.0.0.1', 4001)
print c.read('/test1', wait=True, timeout=2)

И когда мы запустим его:

[[email protected] utils]# /tmp/etcd_watch.py
Traceback (most recent call last):
  File "/tmp/etcd_watch.py", line 5, in <module>
    print c.read('/test1', wait=True, timeout=2)
  File "/usr/lib/python2.6/site-packages/etcd/client.py", line 481, in read
    timeout=timeout)
  File "/usr/lib/python2.6/site-packages/etcd/client.py", line 788, in api_execute
    cause=e
etcd.EtcdConnectionFailed: Connection to etcd failed due to ReadTimeoutError("HTTPConnectionPool(host='127.0.0.1', port=4001): Read timed out.",)

Хорошо, поймаем этого bugger:

#!/usr/bin/python

import etcd
c = etcd.Client('127.0.0.1', 4001)

try:
  print c.read('/test1', wait=True, timeout=2)
except etcd.EtcdConnectionFailed:
  print 'connect failed'

Запустите его:

[[email protected] _modules]# /tmp/etcd_watch.py
connect failed

Выглядит хорошо - все работает на питоне. Так в чем проблема? У меня это в модуле соли и т.д.:

[[email protected] _modules]# cat sjmh.py
import etcd

def test():
  c = etcd.Client('127.0.0.1', 4001)
  try:
    return c.read('/test1', wait=True, timeout=2)
  except etcd.EtcdConnectionFailed:
    return False

И когда мы запустим это:

[[email protected] _modules]# salt 'alpha' sjmh.test
alpha:
    The minion function caused an exception: Traceback (most recent call last):
      File "/usr/lib/python2.6/site-packages/salt/minion.py", line 1173, in _thread_return
        return_data = func(*args, **kwargs)
      File "/var/cache/salt/minion/extmods/modules/sjmh.py", line 5, in test
        c.read('/test1', wait=True, timeout=2)
      File "/usr/lib/python2.6/site-packages/etcd/client.py", line 481, in read
        timeout=timeout)
      File "/usr/lib/python2.6/site-packages/etcd/client.py", line 769, in api_execute
        _ = response.data
      File "/usr/lib/python2.6/site-packages/urllib3/response.py", line 150, in data
        return self.read(cache_content=True)
      File "/usr/lib/python2.6/site-packages/urllib3/response.py", line 218, in read
        raise ReadTimeoutError(self._pool, None, 'Read timed out.')
    ReadTimeoutError: HTTPConnectionPool(host='127.0.0.1', port=4001): Read timed out.

Герм, это странно. etcd, должно быть возвращено etcd.EtcdConnectionFailed. Итак, давайте посмотрим на это дальше. Наш модуль теперь выглядит следующим образом:

import etcd

def test():
  c = etcd.Client('127.0.0.1', 4001)
  try:
    return c.read('/test1', wait=True, timeout=2)
  except Exception as e:
    return str(type(e))

И получим:

[[email protected] _modules]# salt 'alpha' sjmh.test
alpha:
    <class 'urllib3.exceptions.ReadTimeoutError'>

Хорошо, поэтому мы знаем, что мы можем поймать эту вещь. И теперь мы знаем, что это бросило ReadTimeoutError, поэтому пусть это поймает. Последняя версия нашего модуля:

import etcd
import urllib3.exceptions

def test():
  c = etcd.Client('127.0.0.1', 4001)
  try:
    c.read('/test1', wait=True, timeout=2)
  except urllib3.exceptions.ReadTimeoutError as e:
    return 'caught ya!'
  except Exception as e:
    return str(type(e))

И наш тест..

[[email protected] _modules]# salt 'alpha' sjmh.test
alpha:
    <class 'urllib3.exceptions.ReadTimeoutError'>

Э, подождите, что? Почему мы этого не заметили? Исключения работают правильно...?

Как насчет того, попытаемся ли мы поймать базовый класс из urllib3..

[[email protected] _modules]# cat sjmh.py
import etcd
import urllib3.exceptions

def test():
  c = etcd.Client('127.0.0.1', 4001)
  try:
    c.read('/test1', wait=True, timeout=2)
  except urllib3.exceptions.HTTPError:
    return 'got you this time!'

Надеюсь и помолитесь.

[[email protected] _modules]# salt 'alpha' sjmh.test
alpha:
    The minion function caused an exception: Traceback (most recent call last):
      File "/usr/lib/python2.6/site-packages/salt/minion.py", line 1173, in _thread_return
        return_data = func(*args, **kwargs)
      File "/var/cache/salt/minion/extmods/modules/sjmh.py", line 7, in test
        c.read('/test1', wait=True, timeout=2)
      File "/usr/lib/python2.6/site-packages/etcd/client.py", line 481, in read
        timeout=timeout)
      File "/usr/lib/python2.6/site-packages/etcd/client.py", line 769, in api_execute
        _ = response.data
      File "/usr/lib/python2.6/site-packages/urllib3/response.py", line 150, in data
        return self.read(cache_content=True)
      File "/usr/lib/python2.6/site-packages/urllib3/response.py", line 218, in read
        raise ReadTimeoutError(self._pool, None, 'Read timed out.')
    ReadTimeoutError: HTTPConnectionPool(host='127.0.0.1', port=4001): Read timed out.

BLAST YE! Хорошо, попробуйте другой метод, который возвращает другое исключение etcd. Теперь наш модуль выглядит следующим образом:

import etcd

def test():
  c = etcd.Client('127.0.0.1', 4001)
  try:
    c.delete('/')
  except etcd.EtcdRootReadOnly:
    return 'got you this time!'

И наш запуск:

[[email protected] _modules]# salt 'alpha' sjmh.test
alpha:
    got you this time!

В качестве окончательного теста я создал этот модуль, который я могу запустить либо из прямого питона, либо в виде солевого модуля.

import etcd
import urllib3

def test():
  c = etcd.Client('127.0.0.1', 4001)
  try:
    c.read('/test1', wait=True, timeout=2)
  except urllib3.exceptions.ReadTimeoutError:
    return 'got you this time!'
  except etcd.EtcdConnectionFailed:
    return 'cant get away from me!'
  except etcd.EtcdException:
    return 'oh no you dont'
  except urllib3.exceptions.HTTPError:
    return 'get back here!'
  except Exception as e:
    return 'HOW DID YOU GET HERE? {0}'.format(type(e))

if __name__ == "__main__":
  print test()

Через python:

[[email protected] _modules]# python ./sjmh.py
cant get away from me!

Через соль:

[[email protected] _modules]# salt 'alpha' sjmh.test
alpha:
    HOW DID YOU GET HERE? <class 'urllib3.exceptions.ReadTimeoutError'>

Таким образом, мы можем перехватывать исключения из etcd, которые он выбрасывает. Но, хотя мы, как правило, можем поймать urllib3 ReadTimeoutError, когда мы запускаем python-etcd своим одиноким, когда я запускаю его через соль, ничто, похоже, не в состоянии поймать это исключение urllib3, кроме обложки "Исключение".

Я могу это сделать, но мне действительно интересно, что делает черная соль, делая это так, чтобы исключение было несовместимым. Я никогда не видел этого раньше, работая с python, поэтому мне было бы любопытно, как это происходит и как я могу обойти его.

Edit:

Итак, я наконец смог его поймать.

import etcd
import urllib3.exceptions
from urllib3.exceptions import ReadTimeoutError

def test():
  c = etcd.Client('127.0.0.1', 4001)
  try:
    c.read('/test1', wait=True, timeout=2)
  except urllib3.exceptions.ReadTimeoutError:
    return 'caught 1'
  except urllib3.exceptions.HTTPError:
    return 'caught 2'
  except ReadTimeoutError:
    return 'caught 3'
  except etcd.EtcdConnectionFailed as ex:
    return 'cant get away from me!'
  except Exception as ex:
    return 'HOW DID YOU GET HERE? {0}'.format(type(ex))

if __name__ == "__main__":
  print test()

И при запуске:

[[email protected] _modules]# salt 'alpha' sjmh.test
alpha:
    caught 3

Это все еще не имеет смысла. Из того, что я знаю об исключениях, возврат должен быть "пойман 1". Почему мне нужно напрямую импортировать имя исключения, а не просто использовать полное имя класса?

БОЛЬШЕ РЕДАКТОРОВ!

Итак, добавив сравнение между двумя классами, получим "False" - это очевидно, потому что предложение except не работает, поэтому они не могут быть одинаковыми.

Я добавил следующее в script, прямо перед вызовом c.read().

log.debug(urllib3.exceptions.ReadTimeoutError.__module__)
log.debug(ReadTimeoutError.__module__)

И теперь я получаю это в журнале:

[DEBUG   ] requests.packages.urllib3.exceptions
[DEBUG   ] urllib3.exceptions

Итак, это, по-видимому, причина, по которой его поймали так, как она есть. Это также можно воспроизвести, просто загрузив библиотеку etcd и request и сделав что-то вроде этого:

#!/usr/bin/python

#import requests
import etcd

c = etcd.Client('127.0.0.1', 4001)
c.read("/blah", wait=True, timeout=2)

В результате вы получите "правильное" исключение - etcd.EtcdConnectionFailed. Однако раскомментируйте "запросы", и в итоге вы получите urllib3.exceptions.ReadTimeoutError, потому что etcd теперь больше не ловит исключения.

Таким образом, кажется, что когда запросы импортируются, он перезаписывает исключения urllib3, и любой другой модуль, который пытается их поймать, терпит неудачу. Кроме того, похоже, что более новые версии запросов не имеют этой проблемы.

Ответ 1

Мой ответ ниже - это немного предположения, потому что я не могу доказать это на практике с этими точными библиотеками (для начала я не могу воспроизвести вашу ошибку, поскольку она также зависит от версий библиотек и того, как они установлены), но тем не менее показывает один возможных путей этого:

Самый последний пример дает хороший ключ: действительно, дело в том, что в разные моменты времени выполнения программы имя urllib3.exceptions.ReadTimeoutError может относиться к разным классам. ReadTimeoutError, как и для любого другого модуля в Python, является просто именем в пространстве имен urllib3.exceptions, и может быть переназначен (но это не значит, что это хорошая идея. так).

Когда вы ссылаетесь на это имя по его полностью "пути", мы гарантируем, что будем ссылаться на его фактическое состояние к тому моменту, когда мы ссылаемся на него. Однако, когда мы сначала импортируем его как from urllib3.exceptions import ReadTimeoutError - он вносит имя ReadTimeoutError в пространство имен, которое делает импорт, и это имя привязано к значению urllib3.exceptions.ReadTimeoutError ко времени этого импорта. Теперь, если какой-либо другой код переназначает позже значение для urllib3.exceptions.ReadTimeoutError - два (его "текущее" / "последнее" значение и ранее импортированное) могут быть фактически разными - так что технически вы можете иметь два разных класса. Теперь, какой класс исключений будет фактически поднят - это зависит от того, как использует код, который вызывает ошибку: если они ранее импортировали ReadTimeoutError в их пространство имен, то этот ( "оригинал" ) будет поднят.

Чтобы убедиться, что это так, вы можете добавить следующее в блок except ReadTimeoutError:

print(urllib3.exceptions.ReadTimeoutError == ReadTimeoutError)

Если это печатает False - это доказывает, что к моменту возникновения исключения две "ссылки" действительно относятся к разным классам.


Упрощенный пример плохой реализации, которая может дать аналогичный результат:

Файл api.py (правильно разработан и существует сам по себе):

class MyApiException(Exception):
    pass

def foo():
    raise MyApiException('BOOM!')

Файл apibreaker.py (тот, который виноват):

import api

class MyVeryOwnException(Exception):
    # note, this doesn't extend MyApiException,
    # but creates a new "branch" in the hierarhcy
    pass

# DON'T DO THIS AT HOME!
api.MyApiException = MyVeryOwnException

Файл apiuser.py:

import api
from api import MyApiException, foo
import apibreaker

if __name__ == '__main__':
    try:
        foo()
    except MyApiException:
        print("Caught exception of an original class")
    except api.MyApiException:
        print("Caught exception of a reassigned class")

При выполнении:

$ python apiuser.py
Caught exception of a reassigned class

Если вы удалите строку import apibreaker - ясно, тогда все вернется к своим местам, как и должно быть.

Это очень упрощенный пример, но достаточно иллюстративный, чтобы показать, что когда класс определен в некотором модуле - новый тип (объект, представляющий новый класс), "добавляется" под объявленным именем класса в пространство имен модулей. Как и любая другая переменная - ее значение может быть технически модифицировано. То же самое происходит с функциями.