Day 054 — 2026-09-24 | gRPC/Protocol Buffers実装(続き): エラーモデル設計——ドメインエラーをステータスコードへ写像し、リトライの範囲を決める

2026-09-24 🟡 上級者 / Phase 3 設計・アーキテクチャ gRPC/Protocol Buffers実装

📚 背景知識(読んでから問題へ)

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" 行):

番号コード意味(要約)HTTPgRPC ライブラリ自身が返すか
0OK成功200—
1CANCELLED呼び出し側が取り消した499返す
2UNKNOWN分類できない失敗500返す
3INVALID_ARGUMENT引数そのものが不正400返さない
4DEADLINE_EXCEEDED期限切れ504返す
5NOT_FOUND対象が無い404返さない
6ALREADY_EXISTS作ろうとした物が既にある409返さない
7PERMISSION_DENIED権限が無い403返す
8RESOURCE_EXHAUSTED資源の枯渇(利用者ごとの quota など)429返す
9FAILED_PRECONDITIONシステムが必要な状態にない400返さない
10ABORTED並行処理の競合などで中断した409返さない
11OUT_OF_RANGE有効な範囲を越えた400返さない
12UNIMPLEMENTED未実装501返す
13INTERNAL重大な内部エラー500返す
14UNAVAILABLE一時的に使えない503返す
15DATA_LOSS回復できないデータ損失500返さない
16UNAUTHENTICATED認証情報が無い・不正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 を返します。今日は外部モジュールを使わず、同じ形を標準ライブラリだけで再現して設計判断に集中します。

📌
今日の位置づけ: ステータスコードは「失敗の分類」であると同時に、クライアントに「どの単位でやり直すか」を指示する信号です。UNAVAILABLE は「まだ何も処理していない」という宣言でもあります。

📝 問題

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 が実際に呼ばれた回数です。

  1. 写像表を設計する: 次の7つの状況それぞれに、返すステータスコード・クライアントのやり直し方(「やり直さない」「同じ呼び出しをやり直す」「読み直してからやり直す」のどれか)・理由を表にしてください。特に在庫不足は、UNAVAILABLE(同僚案)・RESOURCE_EXHAUSTED・OUT_OF_RANGE・FAILED_PRECONDITION の4候補を比べて1つ選んでください。
    • SKU が存在しない / 数量が 1〜1000 の外 / 在庫不足 / 楽観ロック競合 / DB 接続失敗(トランザクション開始前) / 呼び出し側の期限切れ / 想定外のエラー
  2. 実装する: 1 の表どおりに toStatusV1 を書き、S1〜S5 の v1 の結果を v0 と比べてください。条件は、(a) fmt.Errorf("...: %w", err) で何重に包まれていても判定できること、(b) INTERNAL の message に内部情報(SQL・ホスト名など)を載せないこと、(c) クライアントが分岐に使える安定した識別子 Reason を付けること。
  3. S6 を説明する: S6 は「DB への commit は成功したが、その後の応答で接続が切れた」状況です。v1 で BACKEND_UNAVAILABLE(UNAVAILABLE)を返すと、冪等キー無しでは在庫が 10 → 6(2個の注文で4個減る)になります。なぜ起きるのかを calls と rmw の数から説明し、在庫サービス側・注文サービス側それぞれで取れる対策を1つずつ挙げてください。
  4. 設計判断を言葉で: 在庫不足を 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 の比較(上の出力から):

シナリオv0v1
S1 在庫不足4回呼んで UNAVAILABLE1回で 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. 1回目の呼び出し: 在庫を 10→8 に引いて commit。応答で接続が切れ、write reply: %w ErrDBUnavailable が UNAVAILABLE になる
  2. 注文サービスの透過リトライ(2回目の呼び出し): 同じ readVersion で再送。サーバーの version は1回目の commit で進んでいるので、楽観ロック競合で ABORTED
  3. 注文サービスのアプリ層が 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 解説

1
switch の判定順を決める(期限切れを最初に)

errors.Is は包まれたエラーの鎖を辿るので、1つのエラーが複数の case に当てはまることがあります。例えば DB ドライバが期限切れを fmt.Errorf("begin tx: %w", ctx.Err()) のように返し、それをさらにドメイン層が包むと、鎖の中に context.DeadlineExceeded とドメインエラーの両方が入りえます。期限切れは「呼び出し側がもう待っていない」という、ドメインの失敗より優先して伝えるべき事実なので、switch の最初に置いています。テストの「二重に包んだ期限切れ」はこの確認です。

2
構造体エラーは errors.As、受け皿に errors.Is

StockError は Unwrap() で ErrInsufficientStock を返すので、errors.Is(err, ErrInsufficientStock) でも判定できます。ただし Is では「あと何個あるか」を取り出せません。そこで先に errors.As で構造体を取り出して Metadata に available を載せ、構造体を経ずに番兵だけ返されたときのために errors.Is の受け皿を後ろに置きました。テストの「番兵だけの在庫不足」はこの受け皿の確認です(最初に書いた版はこの受け皿が無く、番兵だけの在庫不足が INTERNAL になることをこのテストで見つけました)。

3
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 に載せます(今日は未実行)。

4
INTERNAL では内部を隠し、追跡できる番号だけ返す

S4 の v0 は pq: column "qty" does not exist (host=db-primary.internal user=inventory) をそのまま返しました。スキーマ・ホスト名・DB ユーザー名が、呼び出し元(さらにその先のブラウザまで中継されることもある)に出ます。v1 は inc-0001 という番号だけを返し、同じ番号で詳細をサーバーログに書きます。問い合わせを受けた人はログを番号で引けるので、調査のしやすさは失いません。

5
「リトライのやり方」をコードの選択で指定する

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_PRECONDITIONRESOURCE_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 APIHTTP ステータス(数百種)+独自の 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か所に」という考え方です

🎯 自己評価

自分の回答

気づき・メモ