好好處理 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=True
是 exception 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 而言,你要關心的是我跟你說的錯誤(不一定是我碰到的錯誤)」
上面三種做法分別代表三種不同心態
- 你使用的時候注意 MyError 就好,如果你想知道「為什麼我有錯誤」,我也跟你說
- 你使用的時候注意 MyError 就好,如果你想知道「為什麼我有錯誤」,我有特地把理由記下來,因為那個錯誤,所以我才丟錯的
- 你使用的時候注意 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
raise MyError
會把你碰到的錯誤跟你丟的錯誤都印出來,說 “During handling of the above exception, another exception occurred:",亦即「我在處理這錯誤的時候,有另一個錯誤發生」raise MyError from err
特指有因果關係,所以不但兩個錯誤都印出來,還說 “The above exception was the direct cause of the following exception:",亦即「上面那個錯誤直接導致了這個錯誤」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)