以下行為都是在 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,一律用 -bin key 放 Buffer;一般 key 只放自己控制的 ASCII 值(code、id)
  • 錯誤處理分支本身也會出錯——它出錯時,原本的錯誤會被吞掉,變成更難懂的錯誤。錯誤路徑要有自己的測試,不能只測 happy path
  • 這個坑已寫成回歸測試:一條斷言一般 key 放中文會丟錯、-bin key 放 Buffer 可以;一條斷言錯誤碼是 FAILED_PRECONDITION 而不是 UNKNOWN

相關主題

  • 用 fetch 手動帶 If-None-Match 時 ETag 快取靜默失效(同一個練習專案裡踩的另一個坑)
  • 容器裡的 node 當 PID 1 收不到訊號