📚 背景知識(読んでから問題へ)
Day 048〜053 では「メッセージがどうバイト列になるか」を見てきました。今日は、RPC が失敗したときに何を返すかです。gRPC の失敗は、HTTP ステータスではなく、17 種類のステータスコード(0〜16)と文字列の message の組で返ります。
一覧(出典: grpc.io/docs/guides/status-codes。2026-09-24 に WebFetch で取得し確認。HTTP の列は googleapis の google/rpc/code.proto の "HTTP Mapping" 行):
| 番号 | コード | 意味(要約) | HTTP | gRPC ライブラリ自身が返すか |
|---|---|---|---|---|
| 0 | OK | 成功 | 200 | — |
| 1 | CANCELLED | 呼び出し側が取り消した | 499 | 返す |
| 2 | UNKNOWN | 分類できない失敗 | 500 | 返す |
| 3 | INVALID_ARGUMENT | 引数そのものが不正 | 400 | 返さない |
| 4 | DEADLINE_EXCEEDED | 期限切れ | 504 | 返す |
| 5 | NOT_FOUND | 対象が無い | 404 | 返さない |
| 6 | ALREADY_EXISTS | 作ろうとした物が既にある | 409 | 返さない |
| 7 | PERMISSION_DENIED | 権限が無い | 403 | 返す |
| 8 | RESOURCE_EXHAUSTED | 資源の枯渇(利用者ごとの quota など) | 429 | 返す |
| 9 | FAILED_PRECONDITION | システムが必要な状態にない | 400 | 返さない |
| 10 | ABORTED | 並行処理の競合などで中断した | 409 | 返さない |
| 11 | OUT_OF_RANGE | 有効な範囲を越えた | 400 | 返さない |
| 12 | UNIMPLEMENTED | 未実装 | 501 | 返す |
| 13 | INTERNAL | 重大な内部エラー | 500 | 返す |
| 14 | UNAVAILABLE | 一時的に使えない | 503 | 返す |
| 15 | DATA_LOSS | 回復できないデータ損失 | 500 | 返さない |
| 16 | UNAUTHENTICATED | 認証情報が無い・不正 | 401 | 返す |
右端の列が今日の出発点です。公式ページは、INVALID_ARGUMENT・NOT_FOUND・ALREADY_EXISTS・FAILED_PRECONDITION・ABORTED・OUT_OF_RANGE・DATA_LOSS の7つを "never generated by the library" と書いています。この7つは、アプリケーションが自分で選ばない限り絶対に出てこない。つまりサービスの作者が何もしなければ、失敗はほぼすべて UNKNOWN か INTERNAL になり、クライアントは「直せば通るのか」「待てば通るのか」「二度と通らないのか」を区別できません。
判断の拠り所として、公式は FAILED_PRECONDITION・ABORTED・UNAVAILABLE の3つについて次の判定基準を示しています(原文を要約)。
- (a) 失敗した呼び出しだけをやり直せば通りうる → UNAVAILABLE
- (b) もっと上の単位(読み直して書き直す read-modify-write 全体など)からやり直すべき → ABORTED
- (c) システムの状態が明示的に直されるまでやり直すべきでない → FAILED_PRECONDITION
コードの選択は、そのままクライアントのリトライ動作に直結します。gRPC の透過リトライの仕様 gRFC A6(grpc/proposal A6-client-retries.md、2026-09-24 確認)では、リトライ対象は service config の retryableStatusCodes で決まり、例として載っているのは "UNAVAILABLE" だけです。同じ文書には "In general, only status codes that indicate the service did not process the request should be retried."(一般に、サービスがリクエストを処理しなかったことを示すコードだけをリトライすべき)とあります。UNAVAILABLE を返すことは「私はまだ何もしていない」と宣言することでもある、というのが今日の核心です。
Go の実物では、google.golang.org/grpc/status(pkg.go.dev で v1.84.0 を 2026-09-24 に確認)の status.Error(codes.X, msg) でエラーを作り、status.FromError / status.Code で取り出します。doc comment によれば、status.Code は包まれた(wrap された)ステータスエラーも辿り、ステータスでない error には codes.Unknown を返します。今日は外部モジュールを使わず、同じ形を標準ライブラリだけで再現して設計判断に集中します。
📝 問題
EC サイトの注文サービスが、在庫サービスの InventoryService.Reserve(SKU の在庫を数量ぶん引き当てる)を gRPC で呼んでいます。在庫サービスは DB の行に version を持つ楽観ロックで更新し、注文サービスは gRFC A6 の例と同じ次のリトライ設定を使っています。
{ "retryPolicy": { "maxAttempts": 4, "initialBackoff": "0.1s", "maxBackoff": "1s",
"backoffMultiplier": 2, "retryableStatusCodes": ["UNAVAILABLE"] } }
在庫サービスの内部には、gRPC を知らないドメインエラーがあります。同僚は、境界(gRPC ハンドラ)でこれをステータスに変える関数 toStatusV0 を書きました。
func toStatusV0(err error) *Status {
switch {
case errors.Is(err, ErrSKUNotFound):
return &Status{Code: NotFound, Message: err.Error()}
case errors.Is(err, ErrInvalidQuantity):
return &Status{Code: InvalidArgument, Message: err.Error()}
case errors.Is(err, ErrInsufficientStock):
return &Status{Code: Unavailable, Message: err.Error()} // 「入荷すれば通るのでリトライ可」
}
return &Status{Code: Internal, Message: err.Error()} // 競合も DB 停止も期限切れも全部ここ
}
以下は、この写像・A6 を真似たクライアントのリトライ・偽の在庫サーバーをまとめた模擬環境です(標準ライブラリのみ。go run . で実行。バックオフは実時間を縮めるため 10ms 起点にしています)。toStatusV1 は今は v0 と同じです。
package main
import (
"context"
"errors"
"fmt"
"math/rand/v2"
"time"
)
// ---- gRPC のステータスコード(番号は grpc.io/docs/guides/status-codes と同じ) ----
type Code uint32
const (
OK Code = 0
Canceled Code = 1
Unknown Code = 2
InvalidArgument Code = 3
DeadlineExceeded Code = 4
NotFound Code = 5
AlreadyExists Code = 6
PermissionDenied Code = 7
ResourceExhausted Code = 8
FailedPrecondition Code = 9
Aborted Code = 10
OutOfRange Code = 11
Unimplemented Code = 12
Internal Code = 13
Unavailable Code = 14
DataLoss Code = 15
Unauthenticated Code = 16
)
var codeNames = [...]string{"OK", "CANCELLED", "UNKNOWN", "INVALID_ARGUMENT", "DEADLINE_EXCEEDED",
"NOT_FOUND", "ALREADY_EXISTS", "PERMISSION_DENIED", "RESOURCE_EXHAUSTED", "FAILED_PRECONDITION",
"ABORTED", "OUT_OF_RANGE", "UNIMPLEMENTED", "INTERNAL", "UNAVAILABLE", "DATA_LOSS", "UNAUTHENTICATED"}
func (c Code) String() string { return codeNames[c] }
// Status は google.rpc.Status + ErrorInfo(AIP-193)を最小限に真似たもの。
type Status struct {
Code Code
Message string // 開発者向け。内部実装を漏らさない
Reason string // ErrorInfo.reason: 機械が分岐に使う安定した識別子
Metadata map[string]string // ErrorInfo.metadata: message に入れた可変情報
}
func (s *Status) Error() string { return fmt.Sprintf("%s: %s", s.Code, s.Message) }
// ---- ドメインエラー(サービス内部の語彙。gRPC を知らない) ----
var (
ErrSKUNotFound = errors.New("sku not found")
ErrInvalidQuantity = errors.New("invalid quantity")
ErrInsufficientStock = errors.New("insufficient stock")
ErrVersionConflict = errors.New("version conflict")
ErrDBUnavailable = errors.New("db unavailable before tx begin")
)
type StockError struct {
SKU string
Want, Have int
}
func (e *StockError) Error() string {
return fmt.Sprintf("sku %s: want %d, have %d", e.SKU, e.Want, e.Have)
}
func (e *StockError) Unwrap() error { return ErrInsufficientStock }
// ---- 境界での写像 v0(同僚の案) ----
func toStatusV0(err error) *Status {
switch {
case errors.Is(err, ErrSKUNotFound):
return &Status{Code: NotFound, Message: err.Error()}
case errors.Is(err, ErrInvalidQuantity):
return &Status{Code: InvalidArgument, Message: err.Error()}
case errors.Is(err, ErrInsufficientStock):
return &Status{Code: Unavailable, Message: err.Error()} // 「入荷すれば通るのでリトライ可」
}
return &Status{Code: Internal, Message: err.Error()} // 競合も DB 停止も期限切れも全部ここ
}
// ---- 境界での写像 v1(設計後) ----
// TODO: あなたの設計をここに書く。今は v0 と同じ。
func toStatusV1(err error) *Status {
return toStatusV0(err)
}
// ---- クライアント側: gRFC A6 の retryPolicy を真似た透過リトライ ----
type RetryPolicy struct {
MaxAttempts int
InitialBackoff time.Duration
MaxBackoff time.Duration
BackoffMultiplier float64
Retryable map[Code]bool
}
// A6: n 回目の再試行は min(initial*mult^(n-1), max) * random(0.8, 1.2) 後。期限は全試行にまたがる。
func invoke(ctx context.Context, p RetryPolicy, call func() *Status) (attempts int, st *Status, waited time.Duration) {
backoff := float64(p.InitialBackoff)
for attempts = 1; ; attempts++ {
st = call()
if st == nil || !p.Retryable[st.Code] || attempts >= min(p.MaxAttempts, 5) {
return
}
d := time.Duration(min(backoff, float64(p.MaxBackoff)) * (0.8 + 0.4*rand.Float64()))
select {
case <-ctx.Done():
return attempts, &Status{Code: DeadlineExceeded, Message: "deadline exceeded during backoff"}, waited
case <-time.After(d):
waited += d
}
backoff *= p.BackoffMultiplier
}
}
// ---- 偽の在庫サーバー ----
type Inventory struct {
stock, version int
failDB int // 残り何回 DB 接続に失敗するか
conflicts int // 残り何回 楽観ロックで負けるか
lostReplies int // 残り何回 commit 後に応答が失われるか
seen map[string]bool // 冪等キー
}
func (inv *Inventory) Reserve(ctx context.Context, reqID, sku string, qty, readVersion int) error {
if err := ctx.Err(); err != nil {
return fmt.Errorf("reserve: %w", err)
}
if sku == "bug" {
return errors.New(`pq: column "qty" does not exist (host=db-primary.internal user=inventory)`)
}
if sku != "A" {
return fmt.Errorf("reserve %s: %w", sku, ErrSKUNotFound)
}
if qty < 1 || qty > 1000 {
return fmt.Errorf("reserve qty=%d: %w", qty, ErrInvalidQuantity)
}
if inv.failDB > 0 {
inv.failDB--
return fmt.Errorf("begin tx: %w", ErrDBUnavailable)
}
if inv.seen != nil && inv.seen[reqID] {
return nil // 同じ冪等キーは既に処理済み: 二重に引かない
}
if inv.conflicts > 0 {
inv.conflicts--
inv.version++ // 誰かが先に更新した
}
if readVersion != inv.version {
return fmt.Errorf("reserve: read v%d, now v%d: %w", readVersion, inv.version, ErrVersionConflict)
}
if qty > inv.stock {
return fmt.Errorf("reserve: %w", &StockError{SKU: sku, Want: qty, Have: inv.stock})
}
inv.stock -= qty
inv.version++
if inv.seen != nil {
inv.seen[reqID] = true
}
if inv.lostReplies > 0 { // commit は済んだが応答が届かない(接続断)
inv.lostReplies--
return fmt.Errorf("write reply: %w", ErrDBUnavailable) // ← 誤って「未処理」を名乗る
}
return nil
}
var policy = RetryPolicy{MaxAttempts: 4, InitialBackoff: 10 * time.Millisecond, MaxBackoff: 100 * time.Millisecond,
BackoffMultiplier: 2, Retryable: map[Code]bool{Unavailable: true}}
// 楽観ロックの read-modify-write 全体。ABORTED ならアプリ層で読み直して最大3回。
func reserveFlow(ctx context.Context, inv *Inventory, toStatus func(error) *Status, reqID, sku string, qty int) (rmw, total int, st *Status) {
for rmw = 1; rmw <= 3; rmw++ {
v := inv.version // 読み直し
n, s, _ := invoke(ctx, policy, func() *Status {
if err := inv.Reserve(ctx, reqID, sku, qty, v); err != nil {
return toStatus(err)
}
return nil
})
total += n
st = s
if st == nil || st.Code != Aborted {
return
}
}
return rmw - 1, total, st
}
func show(label string, rmw, total int, st *Status, inv *Inventory) {
res := "OK"
if st != nil {
res = st.Error()
if st.Reason != "" {
res += fmt.Sprintf(" reason=%s", st.Reason)
}
if len(st.Metadata) > 0 {
res += fmt.Sprintf(" meta=%v", st.Metadata)
}
}
fmt.Printf(" %-3s rmw=%d calls=%d stock=%-3d -> %s\n", label, rmw, total, inv.stock, res)
}
func main() {
type scenario struct {
name string
inv func() *Inventory
sku string
qty int
ctx func() (context.Context, context.CancelFunc)
}
bg := func() (context.Context, context.CancelFunc) { return context.WithCancel(context.Background()) }
scenarios := []scenario{
{"S1 在庫不足 (在庫3に5個)", func() *Inventory { return &Inventory{stock: 3} }, "A", 5, bg},
{"S2 楽観ロック競合1回", func() *Inventory { return &Inventory{stock: 10, conflicts: 1} }, "A", 2, bg},
{"S3 DB接続失敗2回", func() *Inventory { return &Inventory{stock: 10, failDB: 2} }, "A", 2, bg},
{"S4 想定外のバグ", func() *Inventory { return &Inventory{stock: 10} }, "bug", 2, bg},
{"S5 期限切れ済みの呼び出し", func() *Inventory { return &Inventory{stock: 10} }, "A", 2,
func() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), 0) }},
}
for _, s := range scenarios {
fmt.Println(s.name)
for _, m := range []struct {
label string
f func(error) *Status
}{{"v0", toStatusV0}, {"v1", toStatusV1}} {
inv := s.inv()
ctx, cancel := s.ctx()
rmw, total, st := reserveFlow(ctx, inv, m.f, "req-1", s.sku, s.qty)
cancel()
show(m.label, rmw, total, st, inv)
}
}
fmt.Println("S6 commit後に応答喪失 (在庫10から2個、v1写像)")
for _, withKey := range []bool{false, true} {
inv := &Inventory{stock: 10, lostReplies: 1}
label := "鍵無"
if withKey {
inv.seen = map[string]bool{}
label = "鍵有"
}
rmw, total, st := reserveFlow(context.Background(), inv, toStatusV1, "req-1", "A", 2)
show(label, rmw, total, st, inv)
}
}
このコードの実行結果(実測。go1.27.1 darwin/arm64):
S1 在庫不足 (在庫3に5個)
v0 rmw=1 calls=4 stock=3 -> UNAVAILABLE: reserve: sku A: want 5, have 3
v1 rmw=1 calls=4 stock=3 -> UNAVAILABLE: reserve: sku A: want 5, have 3
S2 楽観ロック競合1回
v0 rmw=1 calls=1 stock=10 -> INTERNAL: reserve: read v0, now v1: version conflict
v1 rmw=1 calls=1 stock=10 -> INTERNAL: reserve: read v0, now v1: version conflict
S3 DB接続失敗2回
v0 rmw=1 calls=1 stock=10 -> INTERNAL: begin tx: db unavailable before tx begin
v1 rmw=1 calls=1 stock=10 -> INTERNAL: begin tx: db unavailable before tx begin
S4 想定外のバグ
v0 rmw=1 calls=1 stock=10 -> INTERNAL: pq: column "qty" does not exist (host=db-primary.internal user=inventory)
v1 rmw=1 calls=1 stock=10 -> INTERNAL: pq: column "qty" does not exist (host=db-primary.internal user=inventory)
S5 期限切れ済みの呼び出し
v0 rmw=1 calls=1 stock=10 -> INTERNAL: reserve: context deadline exceeded
v1 rmw=1 calls=1 stock=10 -> INTERNAL: reserve: context deadline exceeded
S6 commit後に応答喪失 (在庫10から2個、v1写像)
鍵無 rmw=1 calls=1 stock=8 -> INTERNAL: write reply: db unavailable before tx begin
鍵有 rmw=1 calls=1 stock=8 -> INTERNAL: write reply: db unavailable before tx begin
rmw は read-modify-write(在庫を読み直して引き当てる一連)を何周したか、calls は Reserve が実際に呼ばれた回数です。
- 写像表を設計する: 次の7つの状況それぞれに、返すステータスコード・クライアントのやり直し方(「やり直さない」「同じ呼び出しをやり直す」「読み直してからやり直す」のどれか)・理由を表にしてください。特に在庫不足は、UNAVAILABLE(同僚案)・RESOURCE_EXHAUSTED・OUT_OF_RANGE・FAILED_PRECONDITION の4候補を比べて1つ選んでください。
- SKU が存在しない / 数量が 1〜1000 の外 / 在庫不足 / 楽観ロック競合 / DB 接続失敗(トランザクション開始前) / 呼び出し側の期限切れ / 想定外のエラー
- 実装する: 1 の表どおりに
toStatusV1を書き、S1〜S5 の v1 の結果を v0 と比べてください。条件は、(a)fmt.Errorf("...: %w", err)で何重に包まれていても判定できること、(b) INTERNAL の message に内部情報(SQL・ホスト名など)を載せないこと、(c) クライアントが分岐に使える安定した識別子Reasonを付けること。 - S6 を説明する: S6 は「DB への commit は成功したが、その後の応答で接続が切れた」状況です。v1 で
BACKEND_UNAVAILABLE(UNAVAILABLE)を返すと、冪等キー無しでは在庫が 10 → 6(2個の注文で4個減る)になります。なぜ起きるのかを calls と rmw の数から説明し、在庫サービス側・注文サービス側それぞれで取れる対策を1つずつ挙げてください。 - 設計判断を言葉で: 在庫不足を UNAVAILABLE にした同僚案は、リトライ設定を
maxAttempts: 4のまま本番で使うと何が起きますか。セール開始直後に在庫切れの SKU へ注文が集中した場面で、在庫サービスが受ける呼び出し数を見積もってください。
💡 ヒント(クリックで開く)
ヒント1 — 方向性
背景知識の判定基準 (a)(b)(c) を、7つの状況に1つずつ当てはめます。「クライアントがもう一度同じ引数で呼んだら、次は通る見込みがあるか」「通るとしたら、何が変わる必要があるか(時間だけ/読み直し/人の操作)」を考えます。
ヒント2 — アプローチ
- 包まれたエラーは
errors.Is(番兵エラー)とerrors.As(構造体エラー。フィールドを取り出せる)で判定します。err == ErrXやswitch errでは、1回%wで包んだだけで外れます context.DeadlineExceeded/context.Canceledも、ハンドラの中で包まれて返ってきます。status.FromContextErrorが「context error or wrapped context error」を扱うのと同じ理由です- 在庫不足で「あと何個あるか」をクライアントに伝えたいなら、message の文字列に埋め込むより、AIP-193 の
ErrorInfo.metadataのような機械が読める場所に置きます - OUT_OF_RANGE は、code.proto のコメントでは「ファイルの終端を越えて読む」ような、範囲を順に辿っている呼び出し側が終わりを検出する用途が想定されています
ヒント3 — コード骨格
func toStatusV1(err error) *Status {
var se *StockError
switch {
case errors.Is(err, context.DeadlineExceeded): // 先に判定する(理由は解説 Step 1)
return &Status{Code: DeadlineExceeded, ...}
case errors.As(err, &se):
return &Status{Code: /* ? */, Reason: "INSUFFICIENT_STOCK",
Metadata: map[string]string{"sku": se.SKU, "available": fmt.Sprint(se.Have)}}
case errors.Is(err, ErrVersionConflict):
return &Status{Code: /* ? */, ...}
// ...
}
// 想定外: 詳細はサーバーのログへ、クライアントには問い合わせ番号だけ
}✅ 模範解答
模範解答を表示する(まず自分で表を作ってから)
1. 写像表
| 状況 | コード | クライアントのやり直し方 | 理由 |
|---|---|---|---|
| SKU が存在しない | NOT_FOUND | やり直さない | 引数を変えない限り結果は変わらない |
| 数量が 1〜1000 の外 | INVALID_ARGUMENT | やり直さない | システムの状態に関係なく不正(OUT_OF_RANGE との違いはこの点。公式は OUT_OF_RANGE を「システムの状態が変われば直りうる」ものとしている) |
| 在庫不足 | FAILED_PRECONDITION | やり直さない(入荷=状態が明示的に直されるまで) | 判定基準 (c)。比較は下の表 |
| 楽観ロック競合 | ABORTED | 読み直してからやり直す | 判定基準 (b)。同じ version のまま再送しても必ず負けるので、透過リトライ(同じ呼び出し)では直らない |
| DB 接続失敗(tx 開始前) | UNAVAILABLE | 同じ呼び出しをやり直す | 判定基準 (a)。tx を始める前なので「処理していない」と言い切れる。ここが S6 との違い |
| 呼び出し側の期限切れ | DEADLINE_EXCEEDED | やり直さない(A6 では期限は全試行にまたがる) | 包まれた context.DeadlineExceeded を INTERNAL にすると、監視上「サーバーのバグ」に見えてしまう |
| 想定外のエラー | INTERNAL | やり直さない | message は問い合わせ番号だけにし、詳細はサーバーのログに残す(AIP-193 は message が内部実装を前提にしてはならないとしている) |
在庫不足の4候補の比較
| 候補 | 採否 | 理由 |
|---|---|---|
| UNAVAILABLE(同僚案) | 不採用 | 「失敗した呼び出しだけをやり直せば通りうる」が成り立たない。数百 ms 後に入荷している見込みはほぼ無く、透過リトライで必ず4回呼ばれる(S1 で calls=4 を実測) |
| RESOURCE_EXHAUSTED | 不採用 | 公式の例は "perhaps a per-user quota"。サーバーの資源や利用者の割当の枯渇を表し、HTTP では 429 になる。クライアントのレート制限器や監視が「こちらの呼びすぎ」と誤解する |
| OUT_OF_RANGE | 不採用 | 状態が変われば直りうる点は合うが、想定用途は範囲を順に辿る呼び出しの終端検出。在庫引当は範囲を辿る操作ではない |
| FAILED_PRECONDITION | 採用 | 「システムが必要な状態(在庫 ≥ 数量)にない」そのもの。入荷という明示的な状態の修正まではやり直さない、という判定基準 (c) に一致する |
2. toStatusV1 の実装
問題のコードの // TODO から始まる toStatusV1 を、次のコードに差し替えます。
var incident int
func toStatusV1(err error) *Status {
var se *StockError
switch {
case errors.Is(err, context.DeadlineExceeded):
return &Status{Code: DeadlineExceeded, Message: "deadline exceeded"}
case errors.Is(err, context.Canceled):
return &Status{Code: Canceled, Message: "canceled by caller"}
case errors.Is(err, ErrSKUNotFound):
return &Status{Code: NotFound, Message: "sku not found", Reason: "SKU_NOT_FOUND"}
case errors.Is(err, ErrInvalidQuantity):
return &Status{Code: InvalidArgument, Message: "quantity must be 1..1000", Reason: "INVALID_QUANTITY"}
case errors.As(err, &se):
return &Status{Code: FailedPrecondition, Message: "insufficient stock", Reason: "INSUFFICIENT_STOCK",
Metadata: map[string]string{"sku": se.SKU, "available": fmt.Sprint(se.Have)}}
case errors.Is(err, ErrInsufficientStock): // 構造体を経ずに番兵だけ返された場合の受け皿
return &Status{Code: FailedPrecondition, Message: "insufficient stock", Reason: "INSUFFICIENT_STOCK"}
case errors.Is(err, ErrVersionConflict):
return &Status{Code: Aborted, Message: "concurrent update; re-read and retry", Reason: "VERSION_CONFLICT"}
case errors.Is(err, ErrDBUnavailable):
return &Status{Code: Unavailable, Message: "temporarily unavailable", Reason: "BACKEND_UNAVAILABLE"}
}
incident++
id := fmt.Sprintf("inc-%04d", incident)
fmt.Printf(" [server log] %s: %v\n", id, err) // 詳細はサーバー側のログにだけ残す
return &Status{Code: Internal, Message: "internal error (id=" + id + ")", Reason: "INTERNAL"}
}
写像のテスト main_test.go(go test -v . で実行):
package main
import (
"context"
"fmt"
"strings"
"testing"
)
func TestToStatusV1(t *testing.T) {
tests := []struct {
name string
err error
code Code
reason string
}{
{"二重に包んだ期限切れ", fmt.Errorf("svc: %w", fmt.Errorf("query: %w", context.DeadlineExceeded)), DeadlineExceeded, ""},
{"在庫不足は構造体エラー", fmt.Errorf("reserve: %w", &StockError{SKU: "A", Want: 5, Have: 3}), FailedPrecondition, "INSUFFICIENT_STOCK"},
{"番兵だけの在庫不足", ErrInsufficientStock, FailedPrecondition, "INSUFFICIENT_STOCK"},
{"競合", fmt.Errorf("x: %w", ErrVersionConflict), Aborted, "VERSION_CONFLICT"},
{"DB停止", fmt.Errorf("x: %w", ErrDBUnavailable), Unavailable, "BACKEND_UNAVAILABLE"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
st := toStatusV1(tt.err)
if st.Code != tt.code || st.Reason != tt.reason {
t.Fatalf("got %s/%s, want %s/%s", st.Code, st.Reason, tt.code, tt.reason)
}
})
}
}
func TestInternalDoesNotLeak(t *testing.T) {
st := toStatusV1(fmt.Errorf(`pq: password=secret host=db-primary.internal`))
for _, bad := range []string{"pq:", "host=", "password"} {
if strings.Contains(st.Message, bad) {
t.Fatalf("message leaks %q: %s", bad, st.Message)
}
}
}
差し替え後の実行結果(実測。go1.27.1 darwin/arm64):
S1 在庫不足 (在庫3に5個)
v0 rmw=1 calls=4 stock=3 -> UNAVAILABLE: reserve: sku A: want 5, have 3
v1 rmw=1 calls=1 stock=3 -> FAILED_PRECONDITION: insufficient stock reason=INSUFFICIENT_STOCK meta=map[available:3 sku:A]
S2 楽観ロック競合1回
v0 rmw=1 calls=1 stock=10 -> INTERNAL: reserve: read v0, now v1: version conflict
v1 rmw=2 calls=2 stock=8 -> OK
S3 DB接続失敗2回
v0 rmw=1 calls=1 stock=10 -> INTERNAL: begin tx: db unavailable before tx begin
v1 rmw=1 calls=3 stock=8 -> OK
S4 想定外のバグ
v0 rmw=1 calls=1 stock=10 -> INTERNAL: pq: column "qty" does not exist (host=db-primary.internal user=inventory)
[server log] inc-0001: pq: column "qty" does not exist (host=db-primary.internal user=inventory)
v1 rmw=1 calls=1 stock=10 -> INTERNAL: internal error (id=inc-0001) reason=INTERNAL
S5 期限切れ済みの呼び出し
v0 rmw=1 calls=1 stock=10 -> INTERNAL: reserve: context deadline exceeded
v1 rmw=1 calls=1 stock=10 -> DEADLINE_EXCEEDED: deadline exceeded
S6 commit後に応答喪失 (在庫10から2個、v1写像)
鍵無 rmw=2 calls=3 stock=6 -> OK
鍵有 rmw=1 calls=2 stock=8 -> OK
--- PASS: TestToStatusV1 (0.00s)
--- PASS: TestInternalDoesNotLeak (0.00s)
PASS
ok day054 0.259s
v0 と v1 の比較(上の出力から):
| シナリオ | v0 | v1 |
|---|---|---|
| S1 在庫不足 | 4回呼んで UNAVAILABLE | 1回で FAILED_PRECONDITION。available:3 を機械可読で返す |
| S2 楽観ロック競合 | 1回で INTERNAL(注文は失敗) | ABORTED を受けて読み直し、2周目で成功(在庫 10→8) |
| S3 DB 接続失敗2回 | 1回で INTERNAL(注文は失敗) | 透過リトライ3回目で成功(在庫 10→8) |
| S4 想定外 | SQL の列名と host=db-primary.internal をクライアントに返している | internal error (id=inc-0001) だけ返し、詳細はサーバーログへ |
| S5 期限切れ | INTERNAL(サーバー障害に見える) | DEADLINE_EXCEEDED |
3. S6 の説明
冪等キー無しの行は rmw=2 calls=3 stock=6 です。
- 1回目の呼び出し: 在庫を 10→8 に引いて commit。応答で接続が切れ、
write reply: %w ErrDBUnavailableが UNAVAILABLE になる - 注文サービスの透過リトライ(2回目の呼び出し): 同じ
readVersionで再送。サーバーのversionは1回目の commit で進んでいるので、楽観ロック競合で ABORTED - 注文サービスのアプリ層が ABORTED を受けて読み直し(rmw 2周目、3回目の呼び出し): 新しい
versionで引き当てに成功し 8→6
サーバーは1回目で処理を終えていたのに、UNAVAILABLE で「処理していない」と名乗ったため、クライアントは正しい手順どおりに動いて二重に引き当てました。ABORTED の読み直しも、その誤った宣言の上に積み重なっています。
- 在庫サービス側の対策: UNAVAILABLE を返すのは tx 開始前に限る。commit 後に失敗したかどうか分からない場合は、「処理したかもしれない」ことを隠さないコードにする(ここでは INTERNAL か UNKNOWN。A6 の既定設定ではリトライされない)。そのうえで、
reqID(冪等キー)ごとの処理結果を引当と同じトランザクションで記録し、同じキーの再送には前回の結果を返す(鍵有の行:calls=2 stock=8) - 注文サービス側の対策: 冪等キーを注文ごとに1回だけ発行し、透過リトライ・アプリ層の読み直しをまたいで同じ値を送る。リトライのたびに新しいキーを作ると、サーバー側の重複排除は効かない
4. 同僚案を本番で使った場合
maxAttempts: 4 と retryableStatusCodes: ["UNAVAILABLE"] の組み合わせでは、在庫不足の注文1件につき Reserve が4回呼ばれます(S1 の実測 calls=4)。待ち時間は A6 の式 min(initialBackoff*backoffMultiplier^(n-1), maxBackoff) * random(0.8, 1.2) から、0.1 + 0.2 + 0.4 = 約 0.7 秒(導出。±20% の揺れあり)。
セール直後に在庫切れの SKU へ毎秒 N 件の注文が来ると、在庫サービスが受ける Reserve は約 4N 回/秒(導出)になり、4倍の負荷のうち 3N 回/秒は結果が変わらない無駄な呼び出しです。しかも利用者は「在庫がありません」を得るまで約 0.7 秒余計に待ちます。在庫サービスがこの負荷で本当に遅くなって期限切れや接続失敗が起き始めると、今度は正しい UNAVAILABLE も増えてさらにリトライが増え、負荷が負荷を呼ぶ状態(リトライストーム)に入ります。A6 がリトライ回数の上限を5に固定し、サーバーから grpc-retry-pushback-ms でリトライを止められるようにしているのは、この増幅を抑えるためです。
🪜 Step-by-Step 解説
switch の判定順を決める(期限切れを最初に)errors.Is は包まれたエラーの鎖を辿るので、1つのエラーが複数の case に当てはまることがあります。例えば DB ドライバが期限切れを fmt.Errorf("begin tx: %w", ctx.Err()) のように返し、それをさらにドメイン層が包むと、鎖の中に context.DeadlineExceeded とドメインエラーの両方が入りえます。期限切れは「呼び出し側がもう待っていない」という、ドメインの失敗より優先して伝えるべき事実なので、switch の最初に置いています。テストの「二重に包んだ期限切れ」はこの確認です。
errors.As、受け皿に errors.IsStockError は Unwrap() で ErrInsufficientStock を返すので、errors.Is(err, ErrInsufficientStock) でも判定できます。ただし Is では「あと何個あるか」を取り出せません。そこで先に errors.As で構造体を取り出して Metadata に available を載せ、構造体を経ずに番兵だけ返されたときのために errors.Is の受け皿を後ろに置きました。テストの「番兵だけの在庫不足」はこの受け皿の確認です(最初に書いた版はこの受け皿が無く、番兵だけの在庫不足が INTERNAL になることをこのテストで見つけました)。
Reason と Metadata を分けるクライアントが if st.Message == "insufficient stock" と文字列で分岐すると、message の文言を直しただけで挙動が変わります。AIP-193(google.aip.dev/193、2026-09-24 確認)は、ErrorInfo.reason を [A-Z][A-Z0-9_]+[A-Z0-9] の形の安定した識別子とし、message に入れた可変情報は metadata にも入れることを求めています。模範解答の Reason: "INSUFFICIENT_STOCK" と Metadata: {"sku", "available"} はこれを真似たものです。実物の grpc-go では、status.New(...).WithDetails(&errdetails.ErrorInfo{...}) でステータスの details に載せます(今日は未実行)。
S4 の v0 は pq: column "qty" does not exist (host=db-primary.internal user=inventory) をそのまま返しました。スキーマ・ホスト名・DB ユーザー名が、呼び出し元(さらにその先のブラウザまで中継されることもある)に出ます。v1 は inc-0001 という番号だけを返し、同じ番号で詳細をサーバーログに書きます。問い合わせを受けた人はログを番号で引けるので、調査のしやすさは失いません。
v1 で S2 と S3 がどちらも成功したのは、やり直しの単位が違うことをコードで伝えたからです。S3 の UNAVAILABLE は invoke(透過リトライ)が同じ呼び出しを繰り返して直し、S2 の ABORTED は reserveFlow(アプリ層)が inv.version を読み直して直しました。同じ「一時的な失敗」でも、クライアントのどの層が拾うべきかは異なります。
🧭 設計思想・なぜこう書くのか
エラーは値であり、境界で意味を翻訳する。 Go のエラーは例外ではなく戻り値なので、ドメイン層は gRPC を知らない ErrVersionConflict を返し、gRPC ハンドラという1か所の境界でステータスに翻訳できます。Go 1.13 で入った %w・errors.Is・errors.As は、この翻訳を「包まれていても判定できる」ようにするための道具です。翻訳を1関数(toStatusV1)に集めたので、写像表とテストが1対1で対応します。
選択肢が少ないことに意味がある。 gRPC のコードは17個しかありません。HTTP のように数百の番号や独自コードを増やせないので、「この失敗はどれか」を必ず判定基準に照らして選ぶことになります。細かい区別は Reason と Metadata に逃がし、コードはクライアントの動き方(やり直すか、どの単位でか)を決めるための粗い分類として保ちます。
| 論点 | 採った案 | 採らなかった案と理由 | 前提が変わったら |
|---|---|---|---|
| 在庫不足のコード | FAILED_PRECONDITION | RESOURCE_EXHAUSTED。「在庫=資源の枯渇」と読めなくはないが、429 に写像され、クライアントのレート制限や監視の「呼びすぎ」アラートと混ざる | 在庫サービスが「利用者ごとの1日の引当上限」を持つなら、その上限超過は RESOURCE_EXHAUSTED が正しい。在庫不足とは Reason で分ける |
| 写像をどこで行うか | gRPC ハンドラの直前に1関数 | ドメインエラーに GRPCStatus() メソッドを持たせる(grpc-go の status.FromError はこれを拾う)。短く書けるが、ドメイン層が gRPC に依存し、同じドメインを HTTP でも公開したときに写像が二重になる | サービスが gRPC だけを公開し続けると決まっているなら、GRPCStatus() 方式の方が包み漏れに強い |
| commit 後の失敗 | INTERNAL(リトライされない)+冪等キー | UNAVAILABLE のまま冪等キーだけ付ける。キーが正しく実装されていれば S6 の「鍵有」のとおり結果は正しいが、キーの無い古いクライアントが1つでもいると二重引当になる | 全クライアントが冪等キーを送ることを interceptor で強制できるなら、UNAVAILABLE で自動回復させる方が可用性は高い |
| ABORTED のやり直し回数 | アプリ層で最大3周 | 無制限にやり直す。人気 SKU では競合が続き、同じ行を奪い合う呼び出しが増えるだけになる | 競合が常態化するなら、楽観ロックをやめて SELECT ... FOR UPDATE などの悲観ロックや、在庫をバケットに分ける設計を検討する |
🌐 他言語との比較
| 言語・仕組み | 失敗の表し方 | gRPC ステータスへの変換 |
|---|---|---|
| Go(grpc-go) | 戻り値の error。status.Error(codes.X, msg) で作る | ハンドラが返した error がステータスでなければ UNKNOWN になる(status.Code の doc comment で確認)。包まれた error も辿る |
| Java(grpc-java) | 例外。Status.NOT_FOUND.withDescription(...).asRuntimeException() を投げる | 例外の型でステータスを運ぶ。ドメイン例外からの変換は interceptor で行うのが一般的 |
| Python(grpcio) | ハンドラ内で context.abort(grpc.StatusCode.NOT_FOUND, msg) を呼ぶ | 捕まえていない例外は UNKNOWN になる |
| HTTP/JSON API | HTTP ステータス(数百種)+独自の JSON 本文 | 在庫不足に 409・422・400 のどれを使うかは組織ごとに割れやすい。gRPC は 17 個に絞り、Reason で補う |
Java・Python の行は各公式ライブラリの一般的な使い方として記載しています。今日は実行して確かめていません(Go は標準ライブラリによる模擬のみ実測)。
🏭 実務での使いどころ
- リトライ設定のレビュー: service config の
retryableStatusCodesに UNAVAILABLE 以外(特に INTERNAL・UNKNOWN・DEADLINE_EXCEEDED)を足す変更は、「そのメソッドのサーバーは、そのコードを返すとき本当に処理していないか」を確かめない限り通さない。A6 の原文も、INTERNAL をリトライしてよいのは冪等なメソッドで二重処理しないことが保証されている場合に限る、という書き方をしています - 監視の設計: INTERNAL・UNKNOWN・DATA_LOSS はサーバー側の問題、INVALID_ARGUMENT・NOT_FOUND・FAILED_PRECONDITION は呼び出し側か業務上の結果、と分けてエラー率アラートを作れます。v0 のように期限切れや競合まで INTERNAL にすると、この分離が壊れ、夜中に「サーバーエラー急増」で起こされます
- gRPC-Gateway などで HTTP に公開するとき: code.proto の HTTP Mapping どおり、FAILED_PRECONDITION は 400、ABORTED は 409、UNAVAILABLE は 503 になります。コードを正しく選んでおけば、HTTP 側のクライアントやCDNのリトライ判断も揃います
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| 「後で通るかもしれない」失敗は UNAVAILABLE | 「一時的」という言葉だけで判断する | UNAVAILABLE は「同じ呼び出しをすぐやり直せば通りうる」かつ「まだ処理していない」とき。入荷待ちは FAILED_PRECONDITION、競合は ABORTED |
| 楽観ロック競合を透過リトライで直す | 競合も一時的な失敗に見える | 同じ version で再送しても必ず負ける。読み直しが要るので ABORTED を返し、アプリ層でやり直す(S2) |
ハンドラでは err.Error() をそのまま message に入れる | デバッグしやすい | SQL・ホスト名が外に出る(S4)。番号を返し、詳細はログへ |
switch err { case ErrX: } で判定する | Go 1.13 以前の書き方 | 1回 %w で包まれただけで外れる。errors.Is / errors.As を使う |
| エラーの message 文字列でクライアントを分岐させる | Reason のような識別子が無い | 文言の修正で挙動が変わる。安定した Reason(ErrorInfo)で分岐させる |
🚧 扱わなかった範囲
- 実物の grpc-go で動かすこと: 今日は
google.golang.org/grpcを import せず、status・codes・service config の retryPolicy を標準ライブラリで模擬しました。実物では透過リトライはgrpc.WithDefaultServiceConfigで有効にし、details はerrdetailsパッケージを使います(どちらも今日は未実行) - hedging(同じ呼び出しを並列に複数送る A6 のもう1つの方式)とリトライ予算(retry throttling)
grpc-retry-pushback-msによるサーバーからのリトライ抑止: 仕様の存在だけ触れました- ストリーミング RPC の途中で失敗したときのステータス: 応答メッセージを一部送った後のリトライは A6 では行われません(原文未確認のため深入りしていません)
- 冪等キーの保存期間・容量の設計: 模擬では
mapに無期限で保持しています
🚀 次のステップ
- 発展: S6 の在庫サービスで、
reqIDごとの結果を引当と同じトランザクションで保存する形に書き換え、同じreqIDの再送に前回と同じステータス(成功なら成功、在庫不足なら在庫不足)を返すようにしてみましょう。今日の実装は「処理済みなら nil を返す」だけなので、成功以外の結果を覚えていません - 次回予告: gRPC/Protocol Buffers は今日で7日目です。自己評価が「完全理解/おおむね理解」で3回そろえばテーマ15「ミドルウェア設計パターン」へ進みます。今日の
toStatusV1は、実物では gRPC の unary interceptor の中に置くのが定石で、http.Handlerのミドルウェアと同じ「横断的な処理を1か所に」という考え方です