以下行為都是在 Node.js 的 @grpc/grpc-js 1.14.5 上測到的。
症狀
一個用 gRPC 回錯誤的服務:domain 層說「這個品項已無庫存」,我把 domain 的 code 和細節(含中文品名的 JSON)放進 metadata 帶回去。結果呼叫端收到的不是 FAILED_PRECONDITION,而是 UNKNOWN: Unknown error——連錯誤碼都傳不回來。
同一份程式碼,「找不到資料」那條錯誤卻正常(它沒有 details)。
原因
- grpc-js 對一般(非
-bin)metadata 的值做字元檢查:只允許可印出的 ASCII。原始碼build/src/metadata.js第 24 行是LEGAL_NON_BINARY_VALUE_REGEX = /^[ -~]*$/,validate()對非-bin的 key 套用這條規則。中文字不合格,metadata.set直接丟出 illegal characters 的例外 - 這個例外發生在伺服器的錯誤處理分支裡(catch 區塊中組 metadata 時),於是原本要回的
FAILED_PRECONDITION沒送出去,變成UNKNOWN
這條限制不是 grpc-js 自己發明的。gRPC 的 PROTOCOL-HTTP2.md 規格裡,一般 metadata 的值定義為 ASCII-Value → 1*( %x20-%x7E )(空白加可印出 ASCII);名稱以 -bin 結尾的是 Binary-Header,值以 base64 編碼傳輸。
(未查證:Go、Java 等其他語言的 gRPC 實作遇到非 ASCII 值,是不是一樣在 set 時丟錯,還是在別的階段出事。)
修法
細節改放在名稱以 -bin 結尾的 metadata key,值用 Buffer:
// 伺服器端
metadata.set('domain-code', err.code); // ASCII,一般 key 可以
metadata.set('domain-details-bin', Buffer.from(JSON.stringify(err.details))); // 可能有中文 → -bin
// 呼叫端
const [details] = err.metadata.get('domain-details-bin'); // 拿到 Buffer
JSON.parse(details.toString('utf8'));改完之後,呼叫端收到 FAILED_PRECONDITION,也還原得出 domain 細節;讀回來的 UTF-8 字串跟放進去的相同。
為什麼這樣解
-bin結尾的 key 表示「值是二進位」,可以放任何位元組;中文用 UTF-8 轉成 Buffer 就合法- 另一個做法是把細節放在錯誤訊息(
details字串)裡。「找不到資料」這種中文訊息在我的測試裡是正常傳回的;看 grpc-js 1.14.5 原始碼,伺服器端送grpc-message前會先encodeURI(status.details),用戶端再decodeURI回來,這也符合規格對grpc-message的定義(UTF-8 再 percent-encode)。但結構化的細節(是哪個品項、剩多少)要讓呼叫端讀得出來,還是 metadata+JSON 比較乾淨
診斷順序值得記:先比對「成功的錯誤」和「失敗的錯誤」差在哪(有沒有 details、details 有沒有中文),形成假設,再去原始碼找證據,最後才改。
怎麼預防再次發生
- 規則:任何可能含使用者資料、中文、任意字串的 metadata,一律用
-binkey 放 Buffer;一般 key 只放自己控制的 ASCII 值(code、id) - 錯誤處理分支本身也會出錯——它出錯時,原本的錯誤會被吞掉,變成更難懂的錯誤。錯誤路徑要有自己的測試,不能只測 happy path
- 這個坑已寫成回歸測試:一條斷言一般 key 放中文會丟錯、
-binkey 放 Buffer 可以;一條斷言錯誤碼是FAILED_PRECONDITION而不是UNKNOWN
相關主題
- 用
fetch手動帶If-None-Match時 ETag 快取靜默失效(同一個練習專案裡踩的另一個坑) - 容器裡的 node 當 PID 1 收不到訊號