快轉到主要內容

好好處理 Python exception

最爛的 Python 例外處理 - 蓋牌 #

「不要」全攔且只加 pass

try:
    do_something()
except Exception:
    pass

一是所有例外都攔下 (*) ,二是攔住後 pass 什麼都不做。你以為 do_something() 會發生的錯誤你都清楚,可以忽略,其實不見得不會出現其他錯誤 – 而你卻全抓下來,還蓋牌不讓外面的人知道,發生例外都沒人曉得

(* 有些系統的不屬於 Exception 而屬於 BaseException, 不過也就少數,扯遠了)

不要全攔而只說一句話 #

盡量「不要」這樣做

try:
    do_something()
except Exception:
    logger.error("Something is wrong")
# 執行結果
$ python a.py
Something is wrong

比第一個例子好一點,但 log 看到 Something is wrong,你只知道錯了(別人還要翻原始碼才知道 Something 是什麼),不知道為什麼錯、第幾行錯

然後你還是全攔下來了,所以你不知道 Something is wrong 背後的原因是什麼

logging 時加 exc_info #

如果把上一段的程式改成這樣

try:
    do_something()
except Exception:
    logger.error("Something is wrong~~~", exc_info=True)

print("=== I'm still here ===")

exc_info=Trueexception info 的意思,錯誤時會多一些資訊: Traceback, 包含 try 裡面的哪裡呼叫的,錯誤在哪,為什麼錯等等

$ python a.py
Something is wrong~~~
Traceback (most recent call last):
  File "/Users/myname/a.py", line 26, in <module>
    do_something()
  File "/Users/myname/a.py", line 22, in do_something
    1/0
ZeroDivisionError: division by zero
=== I'm still here ===

你沒有我的程式碼,也能有概念發生什麼事(雖然還是全攔下來,但在一些情況下 ok)

上面的例子乍看噴出了錯誤訊息,但因為 except 攔住例外以後沒有再丟出去,所以程式還是可以繼續執行到最後(見文章後半段做比較)

exe_info=True 不一定用在 logger 的 .error(),也可以用在任何層級像是 .info() 等等 – 雖然用在 error 比較合理一點?

  • info 可能就是記錄有某件預期的事發生,狀態變成什麼等等,並不是錯誤,要大費周章記載整個 stack trace 怪怪的(我一時想不到場景)
  • warning 可能有適合加的時候,取決於因素像是否全攔 Exception、except 裡面有沒有應對特定的例外 (像除以 0 的時候把結果改成 999 之類的)

如果是 ERROR 層級,也可在 except 裡面直接用 logger.exception(msg) 作為簡寫

就算你用 DataDog 之類的中控平台, log 用 JSON 也一樣,你傳 exc_info 給 logger, DataDog 也能看到

先抓比較特定的 Exception #

一個 try 可以 except 多種 exception

try:
    do_something()
except FileNotFoundError:
    ...
except OSError:
    ...
except Exception:
    logger.error("Something is wrong", exc_info=True)

例外處理盡量先處理你知道的

因為由上到下,所以最地圖砲的 except Exception 放在最後:只有在前面預期之內的落空,而且還是想接住所有例外的時候來當作 fallback 處理

定義自己的 Exception #

在寫自己的 library 或類別時,無論是給別人或自己用,如果是特屬於這個 library 的例外,可以定義自己的 Exception subclass

class OpenAIError(Exception):
    pass

class APIError(OpenAIError):
    ...
    # 可以 override constructor 之類的

class APIConnectionError(APIError):
    ...
  • 繼承 python 的 Exception 而不是 BaseException
  • 名字慣例以 Error 結尾

如此一來 library 的使用者就能專注在你的 exception,跟其他雜七雜八的例外區隔

try:
    ...
except openai.APIConnectionError:
    ...

抓住不解決,重新丟出去 #

你想「抓住」某個例外,做一些事情,但又不想在目前「解決」這個例外,想把他繼續往外丟出去(讓呼叫的人去傷腦筋),這叫做 re-raise

你可以在 except 攔住,但用 raise 重新再丟出去:raise 如果單用沒加任何東西,會把目前在處理的例外給丟出去

try:
    1/0
except ZeroDivisionError:
    logger.error("div by 0")
    raise

print("=== I'm still here ===")
$ python a.py
div by 0
Traceback (most recent call last):
  File "/Users/myname/a.py", line 7, in <module>
    1/0
ZeroDivisionError: division by zero

常見情況是 log 來偵錯,送到其他地方記錄,做額外的商業邏輯處理等等

注意例子裡的 log error() 並沒有 exc_info=True : 你會看到 traceback 是因為例外重新被丟出來了,而且後續沒有人接,這也表示不會看到程式最後一行的訊息

抓住後,改頭換面 - Chain Exception #

「抓住後丟出去」也有這樣的模式:造一個新的錯誤丟出去。其中有三種寫法

def my_work1():
    try:
        do_something()
    except Exception as err:
        # Implicit exception chaining
        raise MyError("An error in my library")


def my_work2():
    try:
        do_something()
    except Exception as err:
        # Explicit exception chaining
        raise MyError("An error in my library") from err


def my_work3():
    try:
        do_something()
    except Exception as err:
        # Suppressing exception context
        raise MyError("An error in my library") from None

雖然跟上一段類似,這邊的情境比較像自己寫 library 或 API 給別人使用,有種隔離封裝的感覺:我丟出去的錯誤跟我自己碰到的不一樣,經過了翻譯、轉化

「我內部做事的時候發現錯誤,不過對於你使用我的 API 而言,你要關心的是我跟你說的錯誤(不一定是我碰到的錯誤)」

上面三種做法分別代表三種不同心態

  1. 你使用的時候注意 MyError 就好,如果你想知道「為什麼我有錯誤」,我也跟你說
  2. 你使用的時候注意 MyError 就好,如果你想知道「為什麼我有錯誤」,我有特地把理由記下來,因為那個錯誤,所以我才丟錯的
  3. 你使用的時候注意 MyError 就好,你不用管我內部碰到了什麼錯誤

有點抽象?直接看程式 output


### Code:
#
# my_work()
#

### Result

# Case 1 (raise MyError)
Traceback (most recent call last):
  File "/Users/myname/d.py", line 13, in my_work1
    do_something()
  File "/Users/myname/d.py", line 8, in do_something
    1/0
ZeroDivisionError: division by zero

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/Users/myname/d.py", line 58, in <module>
    my_work1()
  File "/Users/myname/d.py", line 16, in my_work1
    raise MyError("An error in my library")
__main__.MyError: An error in my library

# Case 2 (raise MyError from err)
Traceback (most recent call last):
  File "/Users/myname/d.py", line 21, in my_work2
    do_something()
  File "/Users/myname/d.py", line 8, in do_something
    1/0
ZeroDivisionError: division by zero

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/Users/myname/d.py", line 58, in <module>
    my_work2()
  File "/Users/myname/d.py", line 24, in my_work2
    raise MyError("An error in my library") from err
__main__.MyError: An error in my library

# Case 3 (raise MyError from None)
Traceback (most recent call last):
  File "/Users/myname/d.py", line 58, in <module>
    my_work3()
  File "/Users/myname/d.py", line 32, in my_work3
    raise MyError("An error in my library") from None
__main__.MyError: An error in my library
  1. raise MyError 會把你碰到的錯誤跟你丟的錯誤都印出來,說 “During handling of the above exception, another exception occurred:",亦即「我在處理這錯誤的時候,有另一個錯誤發生」
  2. raise MyError from err 特指有因果關係,所以不但兩個錯誤都印出來,還說 “The above exception was the direct cause of the following exception:",亦即「上面那個錯誤直接導致了這個錯誤」
  3. raise MyError from None 則把原本的錯誤遮住,只印出你丟的錯誤

這些最主要是「闡述」,你在創造一個新的例外丟出去的時候,背後的因果與心態

1 是 implicit exception chaining (PEP 3134),話不特別講明白

2 是 explicit exception chaining (PEP 3134),你特別描述錯誤的原因

3 是把 exception chain 給 suppress 暗槓下來 (PEP 409),你不把內部碰到的錯誤原封不動給其他人看;雖然你可以(也推薦)把內部錯誤有用的資訊擷取出來,放在給外人看的錯誤敘述裡面,但這意味著「你不需要知道這麼詳細」

為啥要 implicit exception chaining? #

聽起來要嘛就 raise MyError from err 講清楚,要嘛就 raise MyError from None 不讓外人知道內部家務事,為什麼有 implicit chaining 這種機制呢?

我的解釋是:「有時候不是故意的」 – 有時候別人用你的 API 發生 exception,並不是因為你主動造出來丟的。舉個 PEP 3134 裡面類似的例子

logger = None

def compute(a, b):
    try:
        print(a/b)
    except Exception:
        logger.exception(f"Exception when computing {a}/{b}")

compute(1,2)
compute(1,0)
$ python e.py
0.5
Traceback (most recent call last):
  File "/Users/myname/e.py", line 7, in compute
    print(a/b)
ZeroDivisionError: division by zero

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/Users/myname/e.py", line 13, in <module>
    compute(1,0)
  File "/Users/myname/e.py", line 9, in compute
    logger.exception(f"Exception when computing {a}/{b}")
AttributeError: 'NoneType' object has no attribute 'exception'

在這個例子,你在 compute() 的確是想要抓住錯誤,去處理他解決他,但沒想到在處理的過程中,logger 沒設好所以又發生了錯誤,compute() 自然就丟錯誤出來,而且是真的 “During handling of the above exception, another exception occurred”

別人用我的 API,要怎麼 try except? #

就單純抓 MyError 就好,他想去攔你內部碰到的例外 (例如 ZeroDivisionError) 也攔不到,這個 exception 的型態就是你丟出來的了

不過,別人可以用 __context____cause__ 拿到你內部碰到的錯誤

### Code
#
# try:
#     my_work()
# except MyError as e:
#     print(f"e: {e}, type={type(e)}")
#     print(f"cause: {e.__cause__} , type={type(e.__cause__)}")
#     print(f"context: {e.__context__} , type={type(e.__context__)}")
#

### Result

# Case 1 (raise MyError)
e: An error in my library, type=<class '__main__.MyError'>
cause: None , type=<class 'NoneType'>
context: division by zero , type=<class 'ZeroDivisionError'>

# Case 2 (raise MyError from err)
e: An error in my library, type=<class '__main__.MyError'>
cause: division by zero , type=<class 'ZeroDivisionError'>
context: division by zero , type=<class 'ZeroDivisionError'>

# Case 3 (raise MyError from None)
e: An error in my library, type=<class '__main__.MyError'>
cause: None , type=<class 'NoneType'>
context: division by zero , type=<class 'ZeroDivisionError'>
  • 在外面抓到的錯誤,能用其 __context__ 拿到內部錯誤的資訊,三種情況都可以
  • 只有 Explicit chaining 的 __cause__ 才有「原因」的資訊

Finally, finally #

finally 雖然非必要,但可以是例外處理的一部分:就算 try 裡面有例外,在 finally 裡面的程式在最後還是會執行,所以可以用來做清理像是資料庫或網路連線釋放之類的

try:
    print(1/2)
    print(1/0)
except ZeroDivisionError:
    print("div by 0")
    raise
finally:
    print("free my brain")
$ python a.py
0.5
div by 0
free my brain
Traceback (most recent call last):
  File "/Users/myname/a.py", line 3, in <module>
    print(1/0)
ZeroDivisionError: division by zero
  • 就算是例外沒有處理到,或是例外重新 re-raise, 例外也會先暫存下來,執行完 finally 裡面的程式,然後才繼續丟出例外
  • 但是如果 finally 裡有 return 或是 break, continue,本來會丟出來的例外會消失,所以不建議這樣做(見 PEP 8
若您覺得有趣, 請 追蹤我的Facebook 或  Linkedin, 讓你獲得更多資訊!