iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0

前面幾天我們把功能做完了,API 可以新增、查詢、修改、刪除,資料也存進資料庫,資料表要改結構也有 Migration 可以用。

不過到目前為止,我們都是「照著正確的方式」在測試。

今天要換個角度:如果出錯了會怎麼樣?

先做兩個實驗。

實驗一:故意少傳一個欄位

啟動程式後打開 Swagger,用 POST /tasks 送出一包故意少東西的資料:

{
  "completed": false
}

這次沒有傳 name。

Execute 之後,Code 是 500:

{
  "message": "Internal Server Error"
}

沒有傳 name 時,Swagger 顯示 500 Internal Server Error
https://ithelp.ithome.com.tw/upload/images/20261008/20183865PU2vPkHTy9.png
終端機那邊會看到一大串紅字,裡面有:

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 的訊息後面被自動接了一串英文
https://ithelp.ithome.com.tw/upload/images/20261008/20183865vNfiWfJMso.png
我們明明只寫了「找不到 id 為 999 的作業」,後面那串是哪來的?

那是 Flask-RESTX 的一個預設行為,叫做 ERROR_404_HELP。

它在 404 的時候會「好心」幫我們猜使用者是不是打錯網址,然後把猜測接在訊息後面。

對開發的人來說可能有幫助,但是對 API 的使用者來說,這串東西不只沒有用,還會把我們的路徑結構透露出去。

所以今天也會順便把它關掉。

先認識 HTTP 狀態碼

狀態碼在 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。

這就是今天要修正的地方。

工具一:api.abort()

Day 14 就用過了:

api.abort(404, f"找不到 id 為 {task_id} 的作業")

api.abort() 會立刻中斷這次的請求,回傳我們指定的狀態碼和訊息。

回傳的格式會是:

{
  "message": "找不到 id 為 1 的作業"
}

要主動擋下某些狀況(例如資料不合法、找不到資料)的時候,就用它。

工具二:@api.errorhandler

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 會再提到這件事。)

工具三:try / except + rollback

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 是「萬一有漏掉的,統一接住」

兩個一起用會比較安心。

順便把 404 的雜訊關掉

就是實驗二看到的那串英文。

在設定的地方加一行就好:

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 和看得懂的訊息
https://ithelp.ithome.com.tw/upload/images/20261008/20183865QrbflGo1NV.png
從 500 變成 400,訊息也變成人看得懂的句子了。

實驗二:一樣查 GET /tasks/999

{
  "message": "找不到 id 為 999 的作業"
}

後面那串英文不見了
https://ithelp.ithome.com.tw/upload/images/20261008/20183865PlAYZBojJx.png
順便確認原本正常的功能都還在:

正常的 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 印在終端機,而瀏覽器收到的還是那句乾淨的訊息。

這剛好就是我們要的分工:詳細的留給自己看,簡單的給使用者。

順便講 debug=True

上一段說例外會被 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,把不合法的資料在進來的時候就擋住。


上一篇
Day 23|資料表改了怎麼辦?
下一篇
Day 25|使用者亂傳資料怎麼辦?
系列文
從零開始的後端開發:用 Flask 實作 REST API,搞懂 API 與資料庫之間如何協作 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言