契約要對準誰在呼叫
REST、GraphQL、gRPC 不是三個傳輸格式,是三種「呼叫者被允許說什麼」的表面。
選風格之前先問誰拿著客戶端。外部開發者、合作夥伴、瀏覽器裡的前端,和你自己機房裡的下一個服務,要的契約完全不一樣。外部的人需要穩定、可書籤、可被中介層快取的文件;前端需要「這一屏剛好要的形狀」;內部服務需要雙方編譯期就對得上的型別,以及一條 RPC 對應一件工作。
三條箭頭不是同一條管線的先後步驟。它們是三種呼叫者各自的契約:左邊的人不同,中間那一格就該換。
REST 把契約放在資源與 HTTP 語意上。客戶端用 URL 指名要什麼、用方法表明想做的事、用狀態碼判斷整筆結果。中介層(瀏覽器、CDN、反向代理)看得懂 GET,才有機會把公開資源存下來。GraphQL 把契約放在型別系統與一份查詢上:伺服器公布圖,客戶端每次挑自己要的欄位,所以同一份 schema 能餵不同的畫面,不必為每一屏開一個新端點。gRPC 把契約放在 .proto 的方法與訊息上,客戶端與伺服器由同一份 IDL 生成 stub。內部呼叫用它,是因為型別、截止時間與一個 RPC 的成敗都在這條呼叫上,而不是因為「二進位比較快」這一句口號。
Protobuf 的欄位怎麼編碼、gRPC 一條 stream 上的四種 RPC、deadline 與狀態碼表,都在 gRPC 與 Protocol Buffers 那篇,建議讀完本篇再接著讀。瀏覽器要的即時推送(誰該用長連線、誰該用單向下推)在 WebSocket 與 SSE 那兩篇。這裡只用「內部是強型別 RPC、瀏覽器不是 gRPC 的原生客戶端」當作選型論據。
同一個取捨在別的協定上也看得到。MCP 把模型能碰到的表面收成 tools 與 resources,而不是給模型一門通用查詢語言;A2A 把 agent 之間的契約收成任務的生命週期,而不是共享對方的內部 API。兩者都是「誰在呼叫,表面就長成那樣」。角色、交握與生命週期各有專文,這裡不展開。
資源、方法與安全語意
URL 是名詞,方法才是動詞。冪等是伺服器狀態的性質,不是方法名字的保證書。
資源建模先問「客戶端想指到的東西有沒有穩定的名字」。訂單是 /orders/18,它的明細可以是巢狀資源 /orders/18/items,商品則是另一個可被許多訂單引用的 /products/9。把商品欄位直接焊進訂單文件裡,客戶端少打一次,但商品不再是一個能單獨快取、單獨授權、單獨演進的資源。第 3 節的帳本就是在算這個決定值多少錢。
HTTP 方法的語意定義在 HTTP 語意規範(HTTP Semantics,RFC 9110)。安全(safe)表示這次呼叫不該改變伺服器狀態;冪等(idempotent)表示同一筆請求做一次與做多次,伺服器狀態相同。客戶端能不能在逾時後重試,看的是冪等,不是「這個方法通常很快」。
| 方法 | 安全 | 冪等 | 逾時後 |
|---|---|---|---|
| GET | 是 | 是 | 可以重試 |
| PUT | 否 | 是,整份替換 | 本體相同就可以重試 |
| DELETE | 否 | 是,狀態收到「已刪」 | 可以重試 |
| POST | 否 | 否 | 要有冪等鍵,否則可能做第二次 |
| PATCH | 否 | 看這份 patch 能不能重做 | JSON Merge Patch 常常可以;「陣列尾端追加」不行 |
重試欄要看的是「狀態會不會做第二次」,不是狀態碼好不好看。DELETE 第二次回 404 或 204 都可以,只要資源停在已刪。
PUT 的契約是「這個 URL 的整份表示變成你送來的這一份」。用 PUT 送一個只含三個欄位的片段,伺服器若照字面替換,其餘欄位會被清掉。部分更新用 PATCH,或用一個更小的子資源。POST 不是「不冪等的 PUT」:它的 URL 常常是集合,伺服器決定新資源的名字。客戶端若需要冪等,鍵要另外帶,見第 8 節。
Richardson 成熟度:這一節停在第幾級
Richardson 成熟度模型(Richardson Maturity Model)是 Leonard Richardson 提出的一把尺,把一個 HTTP API「有多 REST」分成四級。面試問「你的 API 算不算 RESTful」,常常就是在問這把尺。
| 級 | 長相 | 例子 |
|---|---|---|
| 0 | 所有呼叫打同一個端點,要做什麼寫在本體裡 | POST /api,本體 {"action":"getOrder","id":18} |
| 1 | 拆出資源,每個東西有自己的 URL,但方法不分 | POST /orders/18,本體 {"action":"cancel"} |
| 2 | 用 HTTP 方法表達意圖、用狀態碼表達結果 | GET /orders/18 → 200;DELETE /orders/18 → 204 |
| 3 | 超媒體控制:回應裡附上「下一步能做什麼」的連結 | 見下方的訂單回應 |
本節前半講的資源命名與方法語意,就是第 1 級加第 2 級。第 3 級多的是「由伺服器告訴客戶端下一步」。
第 3 級的意思是,回應不只給資料,也告訴客戶端現在能做哪些事、要打哪個 URL:
{
"id": 18,
"status": "pending",
"links": {
"self": { "href": "/orders/18" },
"pay": { "href": "/orders/18/payment", "method": "POST" },
"cancel": { "href": "/orders/18/cancel", "method": "POST" }
}
}
訂單出貨之後,伺服器不再附上 cancel,客戶端就不畫「取消」按鈕,「什麼狀態能取消」這條規則只留在伺服器一處。這就是 HATEOAS(Hypermedia as the Engine of Application State):好處是改流程不必改客戶端,代價是客戶端必須照連結走,不能把 URL 與流程寫死在程式裡。瀏覽器讀 HTML 天生就是這樣,但多數 SDK 與 App 仍把路徑寫死,所以公開 API 大多停在第 2 級。面試時承認你做的是第 2 級,並說明為什麼不強求客戶端解譯連結,比背誦四個等級有用。
同一個訂單畫面的四筆帳
畫面要 N 筆訂單、每筆 K 個明細、以及商品上畫面真正用到的欄位。四種契約搬的東西不一樣。
面試裡「REST 比較囉嗦、GraphQL 一次拿完、gRPC 比較小」這三句可以同時為真,也可以同時為假,取決於商品放在哪、快取暖不暖、畫面到底用到幾個欄位。這一節拿同一個畫面把四種契約的帳攤開,下方的模擬器可以直接重算。
場景:你們自家手機 App 的「我的訂單」畫面,背後有一個 BFF(Backend for Frontend,專為這個 App 聚合資料的後端)。畫面要 N 筆訂單、每筆 K 個明細,每個明細指向一個商品,但只用到商品的幾個欄位。選手機 App 是因為它能打 REST、GraphQL,也能直接跑原生 gRPC client;瀏覽器得經過 gRPC-Web 與一層 proxy,見 gRPC 與 Protocol Buffers 篇談瀏覽器的章節。
| 契約 | 客戶端打幾次 | 商品怎麼拿到 | 商品進得了共享快取嗎 |
|---|---|---|---|
| REST 拆開 | 1 + N + P 次,分三波 | 每個不重複商品一次 GET /products/{id},完整 8 欄 |
可以。公開商品 GET 命中時,本體不必再傳 |
| REST 內嵌 | 1 次 | 伺服器把完整 8 欄焊進每一筆明細,同一個商品被引用幾次就出現幾次 | 不行,商品成了私人文件的一部分 |
| GraphQL | 1 次 POST | 只帶查詢選到的欄位,但仍按明細筆數重複 | 不行,POST 加私人資料 |
| gRPC | 1 次 unary RPC | 和 GraphQL 同一個形狀,改用 Protobuf 編碼 | 不行,中介層不把它當成 GET |
P 是不重複的商品數,模型假設約六成明細指向不同商品。拆開的 REST 同一波可以並行,HTTP/2 多路複用省的是排程,不是請求數,也不是源站要處理的 GET。
客戶端送出的查詢長這樣:
query {
orders(first: 8) {
id
items {
qty
product { name price }
}
}
}
選取集(selection set)是大括號裡列出的欄位,伺服器只回這些,所以 product 只帶 name 與 price,不會 8 欄全下。Resolver 是伺服器上替一個欄位取值的函式:orders 的 resolver 查訂單,items 的 resolver 對每一筆訂單查明細,product 的 resolver 對每一筆明細拿商品 id 去查商品。照這樣天真地寫,8 筆訂單、24 筆明細就是 1 + 8 + 24 次資料庫查詢,這叫 N+1。DataLoader 把同一次請求裡同型別的查詢收成一批、並記住載過的鍵,三層各一次批次查詢。這些都發生在伺服器裡,不改變客戶端收到的 bytes。伺服器另外設了查詢成本預算,清單太大就直接拒絕;三筆代價在第 4 節展開。
gRPC 這一列是 App 對 BFF 的一次 unary RPC:一問一答,送一則請求訊息、收一則回應訊息,用起來像呼叫一個遠端函式(gRPC 還有串流型的 RPC,見 gRPC 與 Protocol Buffers 篇談四種 RPC 的章節)。回應的形狀和 GraphQL 相同,差在編碼:JSON 每個物件都重寫一次欄位名,"qty":3 是 7 bytes;Protobuf 把欄位名換成 1 byte 的欄位編號,同一個值是 08 03 兩個 bytes。但字串值本身一個 byte 也省不掉,商品名稱只少掉欄位名與引號。所以模型取「Protobuf 本體約為同內容 JSON 的 72%」當教學折算:數字多的訊息縮得更多,字串多的縮得少,實際比例要拿自己的資料量。
sequenceDiagram
participant C as 客戶端
participant O as 訂單 API
participant P as 商品目錄
C->>O: GET /orders
O-->>C: N 筆摘要
C->>O: GET /orders/id/items
O-->>C: 該筆明細
C->>P: GET /products/id
P-->>C: 完整 8 欄
圖上每一對箭頭是一波,不是只發生一次。明細那一波實際是 N 次,商品那一波是 P 次。同一波可以並行,瀑布深度仍是 3。內嵌、GraphQL 與這次的 gRPC 都把三波收成客戶端的一次呼叫,商品也就不再是共享快取能單獨認領的對象。
命中率只影響拆開的 REST 那些公開商品 GET。內嵌文件、GraphQL 的 POST、gRPC 的 unary 都不會因為這個滑桿變得可快取。
條的長度是這一次線上要傳的 bytes,四列共用同一把尺。變短不一定是風格贏了:GraphQL 被預算拒絕時,條會縮成一份錯誤文件。
| 契約 | 請求 | 深度 | 傳輸 | 可快取 | 多傳 | 內部 |
|---|---|---|---|---|---|---|
| REST 拆開 | 23 | 3 | 0 | 0 | 0 | 源站 |
| REST 內嵌 | 1 | 1 | 0 | 0 | 0 | 源站 |
| GraphQL | 1 | 1 | 0 | 0 | 0 | resolver |
| gRPC | 1 | 1 | 0 | 0 | 0 | RPC 1 |
多傳是線上多付的商品 bytes:拆開的 REST 是未命中的完整欄位減去畫面用到的部分;內嵌是整份 8 欄乘上每一筆明細,再扣掉每個不重複商品真正需要的那一份;GraphQL 與 gRPC 只重複嵌套裡已經選到的欄位。可快取只計算共享快取省下、不必再傳的商品本體。
預設 N=8、K=3、用 4 欄、命中 40%、沒有 DataLoader。此時 P=14。拆開傳 9,124 bytes(可快取 2,304、多傳 1,536、源站 17 次),內嵌傳 10,428(多傳 6,528),GraphQL 傳 6,028(多傳 1,920、resolver 33 次、複雜度 96),gRPC 傳 4,064。把命中拉到 0%,拆開變成 11,428,比內嵌更貴;拉到 100%,拆開掉到 6,052,多傳變 0,和 GraphQL 的總量接近,但組成是 23 次請求的標頭,不是商品欄位。
模型模型常數(想自己對帳再展開)▸
常數是教學用的整數,不是生產環境的封包基準;面試要講的是哪一項會變、為什麼變。
- 訂單摘要 64 bytes、明細 20 bytes、商品每欄 48 bytes,完整商品固定 8 欄。
- 不重複商品數 P = round(N × K × 3 / 5)。命中率只作用在拆開的 REST 那 P 個商品 GET;訂單與明細是使用者私人資料,不算可快取。
- 每個 HTTP 請求加回應的標頭 220 bytes(方法、路徑、授權、內容型別),以未壓縮估算。若 App 以 HTTP/2 打 REST,HPACK 會把同一條連線上重複的標頭壓小,拆開那一列的標頭成本在這種情況下被高估。
- GraphQL 查詢文件 160 bytes,再加每欄 12 bytes。執行期複雜度 = N × K × 欄位數,預算 200;超過就拒絕,線上只回 220 + 96 bytes 的錯誤文件,resolver 不跑。
- Resolver 次數:沒有 DataLoader 是 1 + N + N × K,有 DataLoader 是 3。
- gRPC 本體 = 同形狀 JSON × 72%,框架另計 32 bytes:5 bytes 的訊息長度前綴,加上長連線上已被壓縮的少量標頭。內部若再扇出成多個服務,那是伺服器拓樸,不計進這張客戶端帳。
GraphQL 多付的三筆帳
選取集解決 over-fetch 與 under-fetch。N+1、查詢成本、HTTP 快取失效要另外設計。
Over-fetch 是伺服器把畫面不會畫的欄位也送了;under-fetch 是客戶端為了補一個欄位再打一輪。GraphQL 的選取集把這兩件事收進同一次查詢,這是它對前端聚合真正的貢獻。第 3 節裡,欄位滑桿會改變 GraphQL 與 gRPC 的本體,卻不會改變內嵌 REST 的傳輸量——內嵌文件始終是 8 欄。這就是 over-fetch 在帳上的樣子。
Resolver N+1
第 3 節查詢裡的 Order.items 與 Item.product 各是一個 resolver。天真的實作對每一筆父物件各打一次資料庫:訂單一查之後,N 次明細、N × K 次商品;同一個商品被三筆明細引用,就查三次。客戶端只看到一次 HTTP 200,延遲與連線池的壓力都在伺服器。DataLoader 這類批次器在同一次請求內把相同型別的鍵收成 WHERE id IN (...),並在這次請求內記住已經載過的鍵,重複的商品只查一次。它不是跨請求的 HTTP 快取,也不能把私人訂單回應存進 CDN。預設參數下,沒有批次時 resolver 是 33 次,比拆開的 REST 的 23 個請求還多,因為 REST 客戶端至少知道同一個商品只要 GET 一次;打開批次後變成 3 次,客戶端 bytes 不動。
複雜度與深度
公開或半公開的 GraphQL 端點必須假設查詢是敵意的。只限制深度會放過「深度很淺、但清單變數很大」的查詢:文件上只看得到幾個欄位,N 與 K 在變數裡。第 3 節的預算吃的是執行期的 N × K × 欄位。把訂單拉到 30、明細拉到 12、欄位拉到 8,複雜度是 2,880,查詢被拒,條形縮成錯誤文件。生產上常用的是持久化查詢(只允許已經登記的文件)加上執行期成本,兩者缺一,持久化擋不住「合法文件配一個巨大的變數」。
快取為什麼失效
共享快取認的是方法、URL 與明確的 Cache-Control。GraphQL 常見的 POST 沒有可共享的 URL 鍵,回應又混進了這個使用者的訂單,整份都必須是私人的。把查詢換成 GET、或用持久化查詢的雜湊當 URL,只讓「回應本身可以公開」的查詢有機會被存下來。訂單畫面不是那種查詢。商品若要被共享快取認領,就得回到有自己 URL 的 GET,也就是拆開的 REST 那一列。這不是 GraphQL 寫錯,是這份回應的身分不適合當共享物件。
合作夥伴要的是穩定資源、可預期的速率限制與能放進 CDN 的 GET。把一個可任意組合的查詢語言交給他們,等於把第 4 節這三筆帳變成他們的攻擊面,也變成你的支援面。公開邊界用 REST;GraphQL 放在你自己的 BFF 後面,查詢文件由你的前端登記。
分頁:offset 會滑,cursor 會錨
offset 說「跳過幾列」。表頭一插入,跳過的位置就不再是上一頁的下一列。
清單契約要比風格更早講清楚,因為三種風格最後都會碰到同一個問題。REST 可以做 ?offset=40&limit=20,也可以做 ?page_token=。GraphQL 社群的 Connection 模型就是 cursor,不該在選取集裡再發明一套 offset。gRPC 則是請求訊息上的 page token 欄位,語意仍是 keyset,不是「RPC 就不會漏列」。
Offset 的實作是 ORDER BY 之後再跳過前 offset 列。沒有以鍵定位的索引時,引擎仍要走訪被跳過的列,所以越深越貴。下方 demo 的「引擎走訪」就是把每一頁的 offset 加總:page × 頁數 × (頁數 − 1) / 2。Cursor(keyset)記住上一頁最後一列的排序鍵,下一頁問的是「嚴格排在這把鍵之後的 page 列」。排序鍵必須唯一,時間相同時用 id 打破平手,否則同一把時間鍵會漏列或重列。
先看使用者會遇到的症狀。你在 App 裡往下滑訂單列表,最新的在最上面;滑的同時,又有新訂單進來。用 offset 翻頁,上一頁看過的訂單會在下一頁再出現一次,最舊的幾筆滑到底都沒出現。拿每頁 4 列、原本 10 列(列 0 最新、列 9 最舊)、每翻完一頁表頭就多 2 筆新訂單,逐頁走一遍:
offset=0 → 列 0–3offset=4,前 4 格現在是 2 筆新單加列 0、1 → 拿到列 2–5,列 2、3 重複offset=8 → 拿到列 4–7,列 4、5 重複offset 記的是「跳過幾格」:表頭每多一筆,同一個格數就往回指到一筆已經看過的舊資料。cursor 記的是「上一頁最後一筆是誰」,表頭長多長都不影響下一頁從哪裡開始。
Cursor 也沒有把新訂單帶進來:掃描期間插在表頭的列不會混進已經翻過去的頁,要等使用者重新從表頭讀才看得到。這不是 cursor 比較完整,是它故意不把新列塞進這一次走訪。下面的 demo 用同一組參數起跑;把插入量調大,offset 的起點甚至會落進新列裡。
格子是 offset 客戶端對原始列的結果。編號是掃描開始時的列號,不是插入之後的位置。
| offset | cursor | |
|---|---|---|
| 頁數 | 3 | 3 |
| 重複讀到 | 0 | 0 |
| 漏掉的原始列 | 0 | 0 |
| 混進頁面的新列 | 0 | 0 |
| 引擎走訪 | 0 | 0 |
兩欄吃的是同一組滑桿。預設每頁 4、共 10 列、每頁之後插入 2 列,客戶端打 3 頁:offset 重複 4、漏掉列 8 與列 9、新插入的 4 列一次都沒進頁面,引擎走訪 12 列。cursor 走訪 10 列,原始列不重複也不漏。
若客戶端一直打到伺服器回空頁,尾巴終究會出現,但中間的重複已經顯示給使用者,而且每一次都在付更深的 offset。契約若寫「共有 T 列、請打 ceil(T/page) 次」,插入發生的當下這個 T 就是錯的。Cursor 的契約改問「還有沒有下一頁」,總數可以另外估,不要拿來當迴圈終點。
錯誤模型與部分失敗
客戶端要知道「整筆失敗」和「這一份裡有一格失敗」是兩種不同的契約。
REST 用狀態碼描述整份回應。4xx 是客戶端這次的請求不成立,5xx 是伺服器沒有完成自己的契約。回應本體可以用 Problem Details for HTTP APIs(RFC 9457)這份標準格式:type 是穩定的問題識別,title 給人讀,status 與 HTTP 狀態碼一致。機器判斷用 type,不要用 title 的英文句子。
部分失敗要在文件裡明文寫,不要默默回 200 再缺欄。訂單 API 在商品目錄逾時時,可以選擇整筆 502,或回 200 並在該筆明細上放一個問題物件。兩種都合法,客戶端的寫法完全不同。207 Multi-Status 幾乎沒有共享快取與 SDK 會正確處理它,公開 API 少用它來表達「有的列成功、有的列失敗」。
| 整筆失敗 | 部分成功 | 客戶端最容易看錯的地方 | |
|---|---|---|---|
| REST | 4xx/5xx,本體用 Problem Details | 必須在文件裡定義,不要默默 200 缺欄 | 把 200 當成每一格都成功 |
| GraphQL | errors 且 data 為 null |
HTTP 200,data 與 errors 並存,path 指到失敗的欄 |
只看 HTTP 狀態碼 |
| gRPC | 一個 RPC 一個非 OK 狀態 | 要寫進訊息;狀態若仍是 OK,空欄位就是應用的語意 | 把 OK 裡的空訊息當成「沒有這筆訂單」 |
三種風格的失敗粒度不同。GraphQL 的部分成功是規格裡的常態;REST 與 gRPC 若要同樣的粒度,得在應用契約裡自己定義,傳輸不會幫你表達。
GraphQL 的客戶端必須先看 errors,即使狀態碼是 200、即使 data.orders 有內容。某一筆的 items 是 null 且 path 指到它,表示那一格失敗,不是這張訂單沒有明細。把 null 畫成空清單,使用者會以為商品被清空。
gRPC 的一個 RPC 有一個狀態。目錄逾時就讓這次 RPC 失敗,呼叫端不會同時拿到「成功」與「半份訊息」,除非伺服器故意回 OK 並在訊息裡放部分結果——那是應用契約,不是傳輸的預設。狀態碼的名字與何時用哪一個,在 gRPC 與 Protocol Buffers 那篇;這裡只區分「一筆 RPC 一個成敗」和「一份文件裡可以有一格失敗」。
版本化與棄用
能加欄位就不要改欄位的意思。版本是給無法相容的斷裂用的,不是給每一個迭代用的。
多數演進應該是加選的:新客戶端認得新欄位,舊客戶端忽略它。REST 的 JSON 加一個欄位、GraphQL 的 schema 加一個欄位或加一個可空參數、protobuf 加一個新的欄位編號,三者在這件事上是同一個原則。protobuf 不能重用已經刪除的編號,原因在 gRPC 與 Protocol Buffers 那篇的欄位身分;REST 與 GraphQL 對應的紀律是不重用已經棄用的欄位名字,也不改變舊名字的意思。
| 策略 | 客戶端怎麼選 | 快取 | 什麼時候才用 |
|---|---|---|---|
URL /v2 |
換路徑 | 自然分成兩份 | 資源的意思斷了,舊路徑會活很久 |
| 媒體型別或標頭 | Accept 指定版本 |
必須送 Vary: Accept |
同一個 URL,舊型別可以設期限 |
| 欄位級 | 不選;舊客戶端忽略新欄位 | 仍是同一份回應 | 預設。加欄位、不改意思 |
三種策略可以並存,但不能拿 URL 版本代替「加一個欄位」這種相容演進。每切一次 /v2,文件、SDK、權限與快取都要再養一份。
媒體型別版本(例如 application/vnd.orders.v2+json)讓 URL 保持穩定,共享快取才不會把兩種表示存成同一個鍵。Vary: Accept 沒寫,快取就可能把 v2 的回應交給只接受 v1 的客戶端。這是標頭版本比路徑版本更容易出錯的地方,不是它比較先進所以免檢。
棄用是一個有期限的過程,不是註解。回應可以帶 Sunset 標頭規範(The Sunset HTTP Header Field,RFC 8594)定義的 Sunset 標頭,文件寫下舊欄位或舊路徑的最後日期,並用實際呼叫量決定能不能刪。沒有量測的棄用只會在某次發佈把仍在用的客戶端打斷。GraphQL 用 schema 上的 deprecated 標記讓工具在建置期警告,執行期仍要接受舊欄位,直到呼叫量歸零。gRPC 則是保留編號、停止寫入、等舊 stub 消失,而不是把編號讓給新欄位。
把 status 從字串改成整數、或把「金額」從元改成分,即使用 /v2 包起來,舊客戶端的資料仍然會被讀錯。版本只隔離已經斷裂的契約,不代替遷移腳本與雙讀。能並行寫入新舊兩種表示、再用呼叫量切換的,不要只靠客戶端自己升級。
冪等重試與配額標頭
逾時以後你不知道伺服器做完沒有。沒有鍵的 POST 重試,會把扣款做第二次。
第 2 節的方法表決定「能不能裸重試」。建立訂單是 POST:第一次請求可能已經扣款,回應卻在網路上丟了。客戶端只看得到逾時。沒有額外的鍵,重試就是第二筆訂單。
t2 的紅格是客戶端的未知,不是付款失敗。t3 的綠格是鍵發揮作用:同一把鍵、同一份本體,回放第一次的結果。若 t3 沒有鍵,付款格會變成第二次扣款,那才是資料錯誤。
Idempotency-Key 仍是 IETF HTTPAPI 工作小組的 Idempotency-Key 標頭草案(The Idempotency-Key HTTP Header Field,draft-ietf-httpapi-idempotency-key-header),還不是 RFC,但支付與建立類 API 已經把它當成事實契約。客戶端產生唯一字串,建議是 UUID;伺服器在鍵的有效期內記住第一次的請求指紋與回應。同一把鍵配上不同的本體,草案建議回 422,避免把「重試」和「改了一筆新的扣款」混成同一把鍵。第一次還在處理時又送來同一把鍵,回 409,不要並行跑兩次扣款。鍵過期之後的重試必須在文件裡定義:拒絕,或當成新的一次。gRPC 沒有這個 HTTP 標頭,對等的做法是把請求識別放進 metadata,伺服器用同一套「指紋加回應回放」;不要因為換了 RPC 就假設重試是安全的。
速率、配額,以及客戶端看得到的拒絕
速率是一個窗口裡能打幾次,配額是一段較長時間的預算(例如每月呼叫數)。兩者都該在拒絕時告訴客戶端多久以後再試,否則客戶端的重試會變成放大。公開 HTTP API 用 429 Too Many Requests,並帶 Retry-After(秒數或 HTTP 日期)。只回 429、不回何時恢復,客戶端只能猜。
剩餘額度目前沒有一份已經發布的 RFC。網路上常見的是 X-RateLimit-Limit、X-RateLimit-Remaining、X-RateLimit-Reset,這是廠商慣例,不是標準。同一個工作小組的 RateLimit 標頭草案(RateLimit header fields for HTTP,draft-ietf-httpapi-ratelimit-headers)定義 RateLimit-Policy 與 RateLimit,用參數 r 表示剩餘額度、t 表示這個窗口還剩幾秒。契約裡寫明你回的是哪一套,客戶端只實作那一套。兩套同時回可以,但文件要指定哪一套是權威。
429 的本體仍適合用 Problem Details(RFC 9457),讓機器讀 type 區分「視窗速率用盡」和「每月配額用盡」——兩者的等待策略不同,前者等秒,後者可能要等到下個帳期。授權標頭通常讓共享快取不存這份回應;速率限制標頭放在真正打到源站的回應上,不要讓 CDN 快取一份過期的剩餘額度。
gRPC 的配額用盡通常是 RPC 狀態而不是 429。狀態碼表不在這裡重複;公開給瀏覽器與合作夥伴的邊界若是 HTTP,就用上面這組標頭。內部 RPC 的配額常常在服務網格的 token bucket,呼叫端仍要有明確的可重試信號,而不是把所有失敗都立刻重試。
對外與合作夥伴:REST,資源與方法守住 HTTP 語意(RFC 9110),錯誤用 Problem Details(RFC 9457),建立類 POST 配冪等鍵,429 配 Retry-After 與你文件指定的額度標頭,分頁用 keyset。自己的前端聚合:GraphQL 放在 BFF 後,查詢文件要登記,resolver 要批次,成本用執行期清單大小計算。內部服務:gRPC,契約是方法與訊息;重試靠請求識別與冪等的伺服器狀態,不靠「RPC 框架會幫我重試」。