前面幾天我們把功能做完了,API 可以新增、查詢、修改、刪除,資料也存進資料庫,資料表要改結構也有 Migration 可以用。
不過到目前為止,我們都是「照著正確的方式」在測試。
今天要換個角度:如果出錯了會怎麼樣?
先做兩個實驗。
啟動程式後打開 Swagger,用 POST /tasks 送出一包故意少東西的資料:
{
"completed": false
}
這次沒有傳 name。
Execute 之後,Code 是 500:
{
"message": "Internal Server Error"
}
沒有傳 name 時,Swagger 顯示 500 Internal Server Error
終端機那邊會看到一大串紅字,裡面有:
sqlalchemy.exc.IntegrityError: (sqlite3.IntegrityError) NOT NULL constraint failed: task.name
從使用者的角度看,這個結果其實很糟:
只看到「500 Internal Server Error」,完全不知道自己哪裡做錯
明明是「你少傳了東西」,卻回報成「伺服器壞掉了」
而且還有一個比較隱形的問題:這次 commit 失敗之後 session 沒有清乾淨,接下來的請求也可能跟著出問題。
這個我們在 Day 14 就處理過了,用 GET /tasks/999 試試看。
Code 是 404,看起來沒問題,但是訊息長這樣:
{
"message": "找不到 id 為 999 的作業. You have requested this URI [/tasks/999] but did you mean /tasks/<int:task_id> or /tasks ?"
}
404 的訊息後面被自動接了一串英文
我們明明只寫了「找不到 id 為 999 的作業」,後面那串是哪來的?
那是 Flask-RESTX 的一個預設行為,叫做 ERROR_404_HELP。
它在 404 的時候會「好心」幫我們猜使用者是不是打錯網址,然後把猜測接在訊息後面。
對開發的人來說可能有幫助,但是對 API 的使用者來說,這串東西不只沒有用,還會把我們的路徑結構透露出去。
所以今天也會順便把它關掉。
狀態碼在 Day 13 稍微提過,今天完整一點看。
狀態碼會用第一個數字分類:
2xx → 成功
4xx → 請求有問題(通常是「發出請求的人」有問題)
5xx → 伺服器有問題(是「我們」有問題)
常見的有:
200 OK → 成功
201 Created → 成功,而且建立了新資料
204 No Content → 成功,但沒有內容要回傳(例如刪除)
400 Bad Request → 請求的格式或內容有問題
401 Unauthorized → 沒有提供身分(Day 26 會講)
403 Forbidden → 有身分,但沒有權限(Day 26 會講)
404 Not Found → 找不到這個資源
409 Conflict → 跟現有資料衝突,例如重複建立
500 Internal Server Error → 伺服器內部出錯
最重要的觀念是 4xx 和 5xx 的分界:
4xx 是「你傳的東西有問題」
5xx 是「我的程式有問題」
剛才實驗一的狀況,其實是使用者沒有傳 name,屬於「請求有問題」,應該要回 400。
但我們的程式讓它一路跑到資料庫才爆炸,變成 500。
這就是今天要修正的地方。
Day 14 就用過了:
api.abort(404, f"找不到 id 為 {task_id} 的作業")
api.abort() 會立刻中斷這次的請求,回傳我們指定的狀態碼和訊息。
回傳的格式會是:
{
"message": "找不到 id 為 1 的作業"
}
要主動擋下某些狀況(例如資料不合法、找不到資料)的時候,就用它。
api.abort() 是我們主動擋。
但有些錯誤是「沒想到會發生」的,例如剛才的 IntegrityError。
這時候可以用 @api.errorhandler 統一處理。
先在最上面多匯入兩個東西:
from sqlalchemy.exc import IntegrityError
from werkzeug.exceptions import HTTPException
然後加上:
@api.errorhandler(IntegrityError)
def handle_integrity_error(error):
"""資料不符合資料庫規則"""
db.session.rollback()
return {"message": "資料不符合規則,請檢查輸入的內容"}, 400
意思是:只要這次請求中出現 IntegrityError,就交給這個函式處理,
它會把 session 復原,然後回傳 400 和一句看得懂的訊息。
再加一個「最後的防線」,處理所有沒有被接到的例外:
@api.errorhandler(Exception)
def handle_unexpected_error(error):
"""未預期的錯誤"""
# api.abort() 產生的 400、404 也是例外,要讓它照原本的狀態碼回去
if isinstance(error, HTTPException):
return {"message": error.description}, error.code
db.session.rollback()
app.logger.exception("未預期的錯誤")
return {"message": "伺服器發生錯誤,請稍後再試"}, 500
中間那個 HTTPException 的判斷一定要加,這是我實際測試才發現的。
因為 @api.errorhandler(Exception) 的意思是「所有例外都交給我處理」,
而我們自己用 api.abort(404, …) 產生的錯誤,本質上也是一種例外(HTTPException)。
沒有這個判斷的話會變成:
api.abort(404, “找不到作業”) → 被這個函式接走 → 回傳 500
明明是「找不到資料」,前端卻收到「伺服器發生錯誤」。
加上判斷之後,HTTPException 會照著原本的狀態碼回去,只有真正沒預期到的錯誤才會變成 500。
另外兩個重點:
app.logger.exception(...)
把完整的錯誤內容記錄在伺服器這邊(我們自己要看的)
return {"message": "伺服器發生錯誤,請稍後再試"}, 500
回給使用者的只有一句話(使用者看得懂就好)
詳細的錯誤留給自己看、簡單的訊息給使用者,這其實也是資安的一部分。
因為錯誤訊息裡常常會包含資料表名稱、欄位名稱、檔案路徑,這些都是攻擊者想知道的資訊。
(Day 27 會再提到這件事。)
Day 21 最後有提到,commit 失敗之後 session 會停在不乾淨的狀態。
所以在有寫入資料庫的地方,可以包起來:
try:
db.session.add(task)
db.session.commit()
except IntegrityError:
db.session.rollback()
api.abort(400, "資料不符合規則,請檢查輸入的內容")
這樣就算失敗,session 也會被復原,不會影響到後面的請求。
上面的 errorhandler 和這裡的 try/except 其實是兩層:
try/except 是針對「這段程式我知道可能會失敗」
errorhandler 是「萬一有漏掉的,統一接住」
兩個一起用會比較安心。
就是實驗二看到的那串英文。
在設定的地方加一行就好:
app.config['ERROR_404_HELP'] = False
這樣 404 就只會回我們自己寫的訊息。
只列出有改的部分:
from sqlalchemy.exc import IntegrityError
from werkzeug.exceptions import HTTPException
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///tasks.db'
app.config['ERROR_404_HELP'] = False
# ...(db、migrate、api、Model、api.model 都不變)
@api.errorhandler(IntegrityError)
def handle_integrity_error(error):
"""資料不符合資料庫規則"""
db.session.rollback()
return {"message": "資料不符合規則,請檢查輸入的內容"}, 400
@api.errorhandler(Exception)
def handle_unexpected_error(error):
"""未預期的錯誤"""
if isinstance(error, HTTPException):
return {"message": error.description}, error.code
db.session.rollback()
app.logger.exception("未預期的錯誤")
return {"message": "伺服器發生錯誤,請稍後再試"}, 500
@api.route('/tasks')
class TaskList(Resource):
@api.expect(task_input)
@api.marshal_with(task_response, code=201)
@api.response(400, '輸入資料有問題')
def post(self):
"""新增一筆作業"""
data = api.payload
task = Task(
name=data.get("name"),
completed=data.get("completed", False),
)
try:
db.session.add(task)
db.session.commit()
except IntegrityError:
db.session.rollback()
api.abort(400, "資料不符合規則,請檢查輸入的內容")
return task, 201
重新啟動之後,把剛才兩個實驗再做一遍。
實驗一:一樣送 {“completed”: false}
這次的結果是:
Code:400
{
"message": "資料不符合規則,請檢查輸入的內容"
}
同樣的錯誤請求,這次回的是 400 和看得懂的訊息
從 500 變成 400,訊息也變成人看得懂的句子了。
實驗二:一樣查 GET /tasks/999
{
"message": "找不到 id 為 999 的作業"
}
後面那串英文不見了
順便確認原本正常的功能都還在:
正常的 POST → 還是 201
DELETE → 還是 204
GET /tasks → 還是 200
這點要特別確認,因為 @api.errorhandler(Exception) 是「什麼都接」,
如果寫錯(像前面說的沒加 HTTPException 判斷),很容易把正常的回應也一起弄壞。
查資料的時候很容易看到一種說法:「debug 模式下 error handler 不會生效,要先把 debug 關掉才能測」。
我實際測過,在現在的 Flask 版本不是這樣。
只要你有註冊 @api.errorhandler,debug=True 底下它照樣會被呼叫,回傳的還是我們自己寫的那包 JSON,不會跳出除錯畫面。
我把同一支程式用三種設定各跑一次,結果完全一樣:
debug=False → 500 + 我們自己的訊息
debug=True → 500 + 我們自己的訊息
debug=True 再加 PROPAGATE_EXCEPTIONS = False → 500 + 我們自己的訊息
原因是 Flask 遇到例外的時候,會先找有沒有註冊過的 handler,找得到就直接交給它。
PROPAGATE_EXCEPTIONS 這個設定只管「完全沒有人要處理的例外」,而我們今天已經用 @api.errorhandler(Exception) 把所有例外都接走了,所以它根本沒有出場的機會。
結論:不用為了測錯誤處理去動 debug 設定。
那想看完整的錯誤堆疊怎麼辦?去看終端機。我們寫的這一行:
app.logger.exception("未預期的錯誤")
會把整串 traceback 印在終端機,而瀏覽器收到的還是那句乾淨的訊息。
這剛好就是我們要的分工:詳細的留給自己看,簡單的給使用者。
上一段說例外會被 handler 接住,所以看不到除錯畫面 —— 那是在「有註冊 handler」的前提下。
如果某個例外完全沒有人接(例如今天改之前的程式),debug=True 就會把那個畫面丟出來。那個畫面長什麼樣子,值得講一下。
我們從 Day 7 就一直寫著:
app.run(debug=True)
它的好處是改完程式會自動重新啟動、出錯時會顯示詳細的錯誤畫面。
但那個詳細的錯誤畫面,會顯示程式碼、檔案路徑,甚至還附一個可以直接在瀏覽器上執行 Python 的互動式主控台(Werkzeug 的互動式除錯器,要輸入終端機顯示的 PIN 才能用)。
所以有一句話要記住:
debug=True 只能用在自己開發的時候,正式環境絕對不能開。
整理一下我們 API 目前的狀況:
GET /tasks → 200
POST /tasks → 201(成功)、400(資料有問題)
GET /tasks/{id} → 200、404(找不到)
PUT /tasks/{id} → 200、400、404
DELETE /tasks/{id} → 204、404
還有一個常見的問題:
「找不到資料」到底要回 404 還是 200 加一個空陣列?
一般的判斷方式是:
查詢清單(GET /tasks)查不到任何東西 → 200 + []
因為「清單是空的」本身就是一個正常的結果
查詢單筆(GET /tasks/999)找不到 → 404
因為這個資源真的不存在
認識 HTTP 狀態碼的分類,以及 4xx 和 5xx 的差別
用 api.abort() 主動回傳錯誤
用 @api.errorhandler 統一接住沒預期到的錯誤,記得加 HTTPException 判斷
在寫入資料庫時加上 try / except 和 rollback
用 ERROR_404_HELP = False 關掉 404 多餘的提示
知道詳細錯誤要記在伺服器,給使用者的只要一句話
知道 debug=True 不能用在正式環境
不過今天處理的比較像是「出錯之後怎麼收拾」。
有些問題其實可以更早擋下來,例如:
name 沒填、name 傳了一個數字、completed 傳了一串文字、name 傳了 5000 個字……
這些根本不應該跑到資料庫才發現。
下一篇就來看 Input Validation,把不合法的資料在進來的時候就擋住。