重試通常會被加在「看起來會壞的地方」:外部氣象 API、第三方資料源、模型服務。這些地方確實常常抖動,加上重試也很直覺。

但失敗代價最高的位置,往往不是最容易失敗的位置。

Twinco 的每日推播要先併發抓天氣、行事曆、RSS 與星座,再交給模型產生摘要,最後送進 LINE。上游每一段都有各自的重試,少一個資料源也只是內容少一塊。唯獨最後一步——真正把訊息交給 LINE 的那一次呼叫——完全沒有重試。

於是某天清晨,一位使用者的推播在 TLS 握手中途被切斷。上游全部正常:天氣快取是新的、星座取到備援、摘要也產好了。只有最後一哩掛掉,而那位使用者當天的推播就這樣消失了。

這篇文章整理我們補上重試時的判準,以及幾個比「加上重試」本身更重要的細節。

先問排程語意,再決定重試策略

補上重試之前,要先回答一個和網路無關的問題:這件事如果現在沒做成,下一次它會在什麼時候被重新選中?

我們的排程每分鐘執行,篩選條件是推播設定的執行時間精確等於當下的時與分,再加上「同一則推播同一個日曆日只發一次」的去重。這兩個條件合起來,決定了整個失敗語意:

  1. 失敗當下沒送出去,那一整天就沒了。 失敗時送達時間不會更新,但下一分鐘的時與分已經不同、不再匹配,所以不會自動補送。
  2. 隔天會自動恢復。 去重比對的是日曆日而不是滾動 24 小時,昨天的失敗不影響今天的判斷。

所以在這套語意下,「之後再補送」這個選項根本不存在。重試必須在原本那一次呼叫裡就完成。

這個推論可以一般化。一個任務的重試視窗,等於它下一次會被重新選中的間隔:

排程形狀 重試視窗 合適的重試策略
每分鐘輪詢待辦佇列 一分鐘 當下試一次就好,剩下的交給下一輪
失敗進死信佇列 由消費者決定 當下可以放棄得很快
一天只命中一分鐘的排程 二十四小時 必須在當下重試到底

很多團隊會反過來做:先決定重試次數與退避曲線,再假設「反正之後還會再跑一次」。但那個假設是排程語意給的,不是重試邏輯自己能保證的。

值得一提的是另一條路。我們也可以把排程改成「執行時間已過且今天還沒送」的區間比對,讓失敗自然在下一分鐘被重新選中。那確實更耐錯,但會連帶改變去重語意與「使用者指定時間」的意義——使用者設定早上七點,卻可能在七點半收到。一個看似單純的可靠度需求,代價會落在產品語意上,這是選擇當下重試的真正理由,不只是實作比較簡單。

哪些錯誤值得重送

決定了「在當下重試」之後,下一個問題是重試哪些錯誤。判準只有一句:這個請求到底有沒有送到對方手上?

錯誤類型 送到了嗎 重送
連線類例外(TLS 握手中斷、DNS 失敗、對方斷線)與逾時 沒有 絕對安全
HTTP 429、500、502、503、504 可能送到了 需要冪等鍵兜底
其餘 4xx(格式錯誤、被封鎖、憑證失效) 送到了,而且被拒絕 沒有意義

第三列常被忽略。對沒救的錯誤重試不只是白費力氣,它有實際成本:推播是併發送出的,重試期間仍然佔著併發額度,所以對一個永遠不會成功的請求試三次,等於讓後面排隊的人多等。不重試也是一種保護。

退避曲線同樣要跟排程語意扣在一起。我們用的是三次嘗試、間隔 0.5 秒與 1.0 秒——刻意的短。瞬斷型錯誤重送一次幾乎必定成功,而拉長到分鐘級的指數退避對我們毫無幫助:整個重試視窗雖然名義上有二十四小時,實際上卻被併發額度與整批推播的完成時間壓在幾秒之內。能用的時間比看起來少很多。

冪等鍵讓「可能已送達」變成可以重送

上表第二列之所以能重送,唯一的理由是 LINE 提供了 X-Line-Retry-Key:帶著同一把 key 的重複請求會被去重,使用者不會收到兩則。

實作上只有一個關鍵細節,但它很容易寫錯:這把 key 的生命週期綁在「這一次邏輯上的送出」,不是「這一次 HTTP 請求」。

retry_key = str(uuid.uuid4())          # 迴圈外面,整次呼叫共用

for attempt in range(PUSH_MAX_ATTEMPTS):
    try:
        await line_bot_api.push_message(request, x_line_retry_key=retry_key)
        return
    except Exception as exc:
        ...

把產生 key 的那一行移進迴圈裡,程式仍然會跑、測試多半也會過,但每次嘗試都變成一筆全新的請求——冪等性完全消失,而且只有在「請求其實已送達、只是回應沒收到」時才會顯現,也就是最難重現的那種情況。

沒有冪等鍵的系統不是不能重試,而是只能重試第一類錯誤。這決定了重試能覆蓋多少故障,值得在接一個新的外部 API 時就先查清楚。

驗證冪等 header 不需要打擾任何人

補上 header 之後,怎麼確認對方真的在讀它?

直覺的做法是送一則真的訊息給自己,然後想辦法讓它失敗。但這既難重現,也會真的送出訊息。更好的方法是讓收件人無效,只觀察對方對 header 的反應。我們送了三組對照,全程沒有送出任何訊息:

送出內容 對方回應
合法 UUID 格式的 key 只抱怨收件人無效,key 被接受
不帶 key 同上(基準線)
格式錯誤的 key 明確指出這個 header 的值無效

第三列證明對方真的會解析並驗證這個 header,第一列證明我們送的格式通過了驗證。兩者合起來就足夠了。

一般化的說法是:**要驗證一個 header 有沒有被接受,用「壞掉的收件人」比用「真的收件人」更能分辨,也更安全。**失敗回應裡抱怨的是什麼,往往比成功回應提供更多資訊。這條檢查可以隨時重跑,不需要挑時間,也不會打擾任何人。

失敗要分成三種,不是兩種

推播的每一次嘗試都會留下一筆事件,狀態有三種而不是兩種:

狀態 意義
成功 已送達,同時更新送達時間
失敗 摘要產生失敗,或推播重試後仍然失敗
略過 沒有可送的對象、所有資料源皆空

第三種很重要。實測一個月的分布是 998 成功、182 略過、1 失敗——如果把略過算成失敗,失敗率會從 0.1% 變成 15%,而那個告警很快就不會有人再看。

「今天沒有任何資料源有東西可講」不是故障,是系統正確地選擇不打擾使用者。把它和真正的失敗混在一起,等於用雜訊淹掉唯一值得看的那一列。

還有一種「使用者沒收到,但不會留下失敗事件」的路徑:長期未使用的免費會員,推播會被自動降頻或凍結,那條路徑根本不會走到送出。追查「我怎麼沒收到推播」的客訴時,這通常要先排除,否則會在網路層找一個不存在的問題。可靠度告警的價值取決於它的訊噪比,而不是它涵蓋多少狀態。

診斷:先看例外型別,再看訊息

連線類錯誤的字串長得都很像,所以我們把例外型別一起寫進錯誤紀錄。先看型別再看訊息,能省下大量時間。

Cannot connect to host api.line.me:443 ssl:default [None] 為例,方括號裡是底層作業系統例外的錯誤描述:

方括號內容 通常代表
名稱解析失敗 DNS
連線被拒或被重置 TCP 層
None 底層例外根本沒有錯誤描述

最後一列最容易誤判。None 不代表「沒有錯誤」,也不代表 SSL 有問題——它最常見的來源是 TLS 握手中途收到 EOF:asyncio 在握手狀態收到 EOF 時,會用一個不帶任何參數的連線重置例外去完成握手,錯誤碼與錯誤描述都是空的,再被 HTTP 客戶端包了一層。

同一段訊息裡還有一個會誤導自己的欄位。ssl: 印的是連線鍵裡的設定值,真實請求會印 default;但如果你為了重現而自己構造一個例外並傳入空值,它就會印 ssl:None——**那是你自己造成的,不是 SSL 有問題。**重現故障時做出錯誤結論,比沒有重現更糟。

最後是一個和網路無關、但一樣會讓人查錯方向的細節:我們的事件時間存的是台北時間,而資料庫與容器記錄跑的是世界標準時。對照兩邊時要記得差八小時。可觀測性資料的時區不一致,比缺少資料更容易讓人得到錯誤結論,因為它看起來完全合理。

修好之後,掉的那一則不會回來

補上重試與冪等鍵之後,那一則已經消失的推播並沒有回來,它只是在隔天的排程自然恢復。

這件事值得明講。可靠度改動的價值全部在未來,沒有任何一次修復會追溯地補上已經發生的損失。它會影響兩個決定:要不要另外做補償,以及怎麼跟受影響的使用者說明。把修復講成「問題已解決」而不區分這一點,等於默認了一個不存在的補償。

可以帶走的原則

  • 重試要加在失敗代價最高的地方,不是最容易失敗的地方;最後一哩經常兩者兼具,卻最常被漏掉。
  • 重試視窗由排程語意決定,不是由重試邏輯決定。先確認「下一次會在什麼時候被重新選中」。
  • 用「請求有沒有送達」分類錯誤:沒送到的重送絕對安全,可能送到的需要冪等鍵,被拒絕的重試沒有意義。
  • 對沒救的錯誤重試會佔用併發額度,不重試也是一種保護。
  • 冪等鍵綁在「一次邏輯送出」,不是「一次 HTTP 請求」。寫錯不會有任何症狀,直到最難重現的那一刻。
  • 驗證 header 用壞掉的收件人,不要用真的收件人。
  • 失敗、略過、成功是三種狀態;把略過混進失敗會讓告警失去意義。
  • 診斷先看例外型別,再看訊息;並確認可觀測性資料的時區是一致的。
  • 可靠度修復不追溯。已經掉的那一則不會回來,溝通時要說清楚。