Статьи

Профессиональная обработка ошибок с Python

В этом руководстве вы узнаете, как обрабатывать ошибки в Python с точки зрения всей системы. Обработка ошибок является критическим аспектом проектирования, и она переходит от самых низких уровней (иногда аппаратных) до конечных пользователей. Если у вас нет согласованной стратегии, ваша система будет ненадежной, пользовательский интерфейс будет плохим, и у вас будет много проблем отладки и устранения неполадок.

Ключом к успеху является знание всех этих взаимосвязанных аспектов, их четкое рассмотрение и формирование решения для каждой точки.

Существует две основные модели обработки ошибок: коды состояния и исключения. Коды состояния могут использоваться любым языком программирования. Исключения требуют поддержки языка / времени выполнения.

Python поддерживает исключения. Python и его стандартная библиотека свободно используют исключения, чтобы сообщать о многих исключительных ситуациях, таких как ошибки ввода-вывода, деление на ноль, индексация за пределами границ, а также о некоторых не столь исключительных ситуациях, как конец итерации (хотя это скрыто). Большинство библиотек следуют их примеру и поднимают исключения.

Это означает, что ваш код должен обрабатывать все исключения, возникающие в Python и библиотеках, так что вы также можете вызывать исключения из своего кода, когда это необходимо, и не полагаться на коды состояния.

Прежде чем погрузиться во внутреннее святилище исключений Python и рекомендации по обработке ошибок, давайте посмотрим на обработку некоторых исключений в действии:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def f():
 
    return 4 / 0
 
 
 
def g():
 
    raise Exception(«Don’t call us. We’ll call you»)
 
 
 
def h():
 
    try:
 
        f()
 
    except Exception as e:
 
        print(e)
 
    try:
 
        g()
 
    except Exception as e:
 
        print(e)

Вот вывод при вызове h() :

1
2
3
4
5
h()
 
division by zero
 
Don’t call us.

Исключения Python — это объекты, организованные в иерархии классов.

Вот вся иерархия:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
BaseException
 
+— SystemExit
 
+— KeyboardInterrupt
 
+— GeneratorExit
 
+— Exception
 
     +— StopIteration
 
     +— StandardError
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     |
 
     +— Warning
 
          +— DeprecationWarning
 
          +— PendingDeprecationWarning
 
          +— RuntimeWarning
 
          +— SyntaxWarning
 
          +— UserWarning
 
          +— FutureWarning
 
 +— ImportWarning
 
 +— UnicodeWarning
 
 +— BytesWarning

Есть несколько особых исключений, которые являются производными непосредственно от BaseException , такие как SystemExit , KeyboardInterrupt и GeneratorExit . Затем существует класс Exception , который является базовым классом для StopIteration , StandardError и Warning . Все стандартные ошибки получены из StandardError .

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

Вызывать исключения очень легко. Вы просто используете ключевое слово raise чтобы поднять объект, который является подклассом класса Exception . Это может быть экземпляр самого Exception , одно из стандартных исключений (например, RuntimeError ) или подкласс Exception вы получили сами. Вот небольшой фрагмент, который демонстрирует все случаи:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# Raise an instance of the Exception class itself
 
raise Exception(‘Ummm… something is wrong’)
 
 
 
# Raise an instance of the RuntimeError class
 
raise RuntimeError(‘Ummm… something is wrong’)
 
 
 
# Raise a custom subclass of Exception that keeps the timestamp the exception was created
 
from datetime import datetime
 
 
 
class SuperError(Exception):
 
    def __init__(self, message):
 
        Exception.__init__(message)
 
        self.when = datetime.now()
 
 
 
 
 
raise SuperError(‘Ummm… something is wrong’)

Вы перехватываете исключения с предложением » except , как вы видели в примере. Когда вы ловите исключение, у вас есть три варианта:

  • Глотайте его тихо (держите его и продолжайте бежать).
  • Сделайте что-то вроде регистрации, но повторно вызовите то же исключение, чтобы позволить более высоким уровням обрабатывать.
  • Поднимите другое исключение вместо оригинала.

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

Например, если вы получили входной файл, который может быть в разных форматах (JSON, YAML), вы можете попробовать проанализировать его, используя разные парсеры. Если анализатор JSON вызвал исключение, что этот файл не является допустимым файлом JSON, вы проглотите его и попробуйте использовать синтаксический анализатор YAML. Если синтаксический анализатор YAML тоже не удался, вы разрешаете распространению исключения.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
import json
 
import yaml
 
 
 
def parse_file(filename):
 
    try:
 
        return json.load(open(filename))
 
    except json.JSONDecodeError
 
        return yaml.load(open(filename))

Обратите внимание, что другие исключения (например, файл не найден или нет разрешений на чтение) будут распространяться и не будут учитываться специальным исключением, кроме пункта. Это хорошая политика в этом случае, когда вы хотите попробовать разбор YAML, только если разбор JSON не удался из-за проблемы кодирования JSON.

Если вы хотите обработать все исключения, используйте except Exception . Например:

1
2
3
4
5
6
7
8
9
def print_exception_type(func, *args, **kwargs):
 
   try:
 
       return func(*args, **kwargs)
 
   except Exception as e:
 
       print type(e)

Обратите внимание, что добавляя as e , вы связываете объект исключения с именем e доступным в вашем предложении, кроме.

Для повторного рейза просто добавьте raise без аргументов внутри вашего обработчика. Это позволяет вам выполнять некоторую локальную обработку, но все же позволяет верхним уровням обрабатывать это тоже. Здесь invoke_function() выводит тип исключения на консоль, а затем повторно вызывает исключение.

01
02
03
04
05
06
07
08
09
10
11
def invoke_function(func, *args, **kwargs):
 
   try:
 
       return func(*args, **kwargs)
 
   except Exception as e:
 
       print type(e)
 
       raise

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

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

1
2
3
4
5
6
7
def fetch_some_data():
 
   db = open_db_connection()
 
   query(db)
 
   close_db_Connection(db)

Если функция query() вызывает исключение, то вызов close_db_connection() никогда не будет выполнен, а соединение с БД останется открытым. Предложение finally всегда выполняется после выполнения обработчика исключений try all. Вот как это сделать правильно:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
def fetch_some_data():
 
   db = None
 
   try:
 
       db = open_db_connection()
 
       query(db)
 
   finally:
 
       if db is not None:
 
           close_db_connection(db)

Вызов open_db_connection() не может вернуть соединение или вызвать само исключение. В этом случае нет необходимости закрывать соединение с БД.

При использовании finally , вы должны быть осторожны, чтобы не вызывать никаких исключений, потому что они будут маскировать исходное исключение.

Диспетчеры контекста предоставляют еще один механизм для оборачивания ресурсов, таких как файлы или соединения с БД, в код очистки, который выполняется автоматически даже при возникновении исключений. Вместо блоков try-finally вы используете оператор with . Вот пример с файлом:

1
2
3
4
5
def process_file(filename):
 
    with open(filename) as f:
 
       process(f.read())

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

Ведение журнала является в значительной степени требованием в нетривиальных, длительно работающих системах. Это особенно полезно в веб-приложениях, где вы можете обрабатывать все исключения общим образом: просто зарегистрируйте исключение и верните сообщение об ошибке вызывающей стороне.

При ведении журнала полезно регистрировать тип исключения, сообщение об ошибке и трассировку стека. Вся эта информация доступна через объект sys.exc_info , но если вы используете метод logger.exception() в вашем обработчике исключений, система регистрации Python извлечет всю необходимую для вас информацию.

Это лучшая практика, которую я рекомендую:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
import logging
 
logger = logging.getLogger()
 
 
 
def f():
 
    try:
 
        flaky_func()
 
    except Exception:
 
        logger.exception()
 
        raise

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

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

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

Регистрация это возможность. Наиболее распространенной реализацией является использование файлов журнала. Но для крупномасштабных распределенных систем с сотнями, тысячами и более серверами это не всегда лучшее решение.

Чтобы отслеживать исключения во всей вашей инфраструктуре, такая служба, как часовая , очень полезна. Он централизует все отчеты об исключениях и в дополнение к трассировке стека добавляет состояние каждого кадра стека (значение переменных на момент возникновения исключения). Он также предоставляет действительно хороший интерфейс с панелями мониторинга, отчетами и способами разбивки сообщений по нескольким проектам. Это открытый исходный код, поэтому вы можете запустить свой собственный сервер или подписаться на размещенную версию.

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

Если ваш код обращается к какой-то удаленной системе, которая не отвечает, традиционным решением являются тайм-ауты, но иногда не каждая система разработана с тайм-аутами. Таймауты не всегда легко откалибровать при изменении условий.

Другой подход состоит в том, чтобы быстро потерпеть неудачу и затем повторить попытку Преимущество состоит в том, что если цель реагирует быстро, вам не нужно проводить много времени в состоянии сна и вы можете реагировать немедленно. Но если это не удалось, вы можете повторить попытку несколько раз, пока не решите, что это действительно недоступно, и вызовете исключение. В следующем разделе я представлю декоратор, который может сделать это для вас.

Двумя декораторами, которые могут помочь с обработкой ошибок, являются @log_error , который регистрирует исключение и затем повторно вызывает его, и @retry декоратор, который будет повторять вызов функции несколько раз.

Вот простая реализация. Декоратор исключает объект логгера. Когда он декорирует функцию и вызывается, она обернет вызов в предложение try-exclude, и если возникло исключение, оно зарегистрирует его и, наконец, повторно вызовет исключение.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def log_error(logger)
 
   def decorated(f):
 
       @functools.wraps(f)
 
       def wrapped(*args, **kwargs):
 
           try:
 
               return f(*args, **kwargs)
 
           except Exception as e:
 
               if logger:
 
                   logger.exception(e)
 
               raise
 
       return wrapped
 
   return decorated

Вот как это использовать:

01
02
03
04
05
06
07
08
09
10
11
import logging
 
logger = logging.getLogger()
 
 
 
@log_error(logger)
 
def f():
 
    raise Exception(‘I am exceptional’)

Вот очень хорошая реализация декоратора @retry .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
import time
 
import math
 
 
 
# Retry decorator with exponential backoff
 
def retry(tries, delay=3, backoff=2):
 
  »’Retries a function or method until it returns True.
 
 
 
  delay sets the initial delay in seconds, and backoff sets the factor by which
 
  the delay should lengthen after each failure.
 
  or else it isn’t really a backoff.
 
  greater than 0.»’
 
 
 
  if backoff <= 1:
 
    raise ValueError(«backoff must be greater than 1»)
 
 
 
  tries = math.floor(tries)
 
  if tries < 0:
 
    raise ValueError(«tries must be 0 or greater»)
 
 
 
  if delay <= 0:
 
    raise ValueError(«delay must be greater than 0»)
 
 
 
  def deco_retry(f):
 
    def f_retry(*args, **kwargs):
 
      mtries, mdelay = tries, delay # make mutable
 
 
 
      rv = f(*args, **kwargs) # first attempt
 
      while mtries > 0:
 
        if rv is True: # Done on success
 
          return True
 
 
 
        mtries -= 1 # consume an attempt
 
        time.sleep(mdelay) # wait…
 
        mdelay *= backoff # make future wait longer
 
 
 
        rv = f(*args, **kwargs) # Try again
 
 
 
      return False # Ran out of tries 🙁
 
 
 
    return f_retry # true decorator -> decorated function
 
  return deco_retry # @retry(arg[, …]) -> true decorator

Обработка ошибок имеет решающее значение как для пользователей, так и для разработчиков. Python обеспечивает отличную поддержку языка и стандартной библиотеки для обработки ошибок на основе исключений. Прилежно следуя лучшим практикам, вы можете преодолеть этот часто пренебрегаемый аспект.

Изучите Python с нашим полным руководством по питону, независимо от того, начинаете ли вы или начинающий программист, ищущий новые навыки.