エラー処理

ステータスコード、エラー定義、エラー伝播を含むGoaでのgRPCサービスのエラー処理方法を学ぶ

このガイドでは、Goaを使用したgRPCサービスでのエラー処理方法について説明します。

エラーの種類

ステータスコード

gRPCはエラーを示すためにステータスコードを使用します。Goaはこれらのコードへの組み込みマッピングを提供します:

Method("divide", func() {
    // 発生する可能性のあるエラーを定義
    Error("division_by_zero")
    Error("invalid_input")

    GRPC(func() {
        // エラーをgRPCステータスコードにマッピング
        Response(CodeOK)
        Response("division_by_zero", CodeInvalidArgument)
        Response("invalid_input", CodeInvalidArgument)
    })
})

一般的なステータスコードのマッピング:

GoaエラーgRPCステータスコード使用ケース
not_foundCodeNotFoundリソースが存在しない
invalid_argumentCodeInvalidArgument無効な入力
internal_errorCodeInternalサーバーエラー
unauthenticatedCodeUnauthenticated認証情報の欠落/無効
permission_deniedCodePermissionDenied権限不足

エラー定義

基本的なエラー定義

サービスまたはメソッドレベルでエラーを定義します:

var _ = Service("users", func() {
    // サービスレベルのエラー
    Error("not_found", func() {
        Description("ユーザーが見つかりません")
    })
    Error("invalid_input")

    Method("getUser", func() {
        // メソッド固有のエラー
        Error("profile_incomplete")

        GRPC(func() {
            // 発生する可能性のあるすべてのエラーをマッピング
            Response(CodeOK)
            Response("not_found", CodeNotFound)
            Response("invalid_input", CodeInvalidArgument)
            Response("profile_incomplete", CodeFailedPrecondition)
        })
    })
})

エラーの実装

エラーの返却

サービスメソッドを実装する際には、様々な種類のエラーを処理する必要があります。入力バリデーションについては、これらの制約を直接Goa DSLで定義するのがベストです:

var _ = Service("users", func() {
    Method("createUser", func() {
        Payload(func() {
            Field(1, "name", String, "ユーザーのフルネーム")
            Field(2, "age", Int, "ユーザーの年齢")
            Field(3, "email", String, "ユーザーのメールアドレス")
            
            // DSLでバリデーションルールを定義
            Required("name", "age", "email")
            Minimum("age", 0)
            Maximum("age", 150)
            Pattern("email", "^[^@]+@[^@]+$")
        })
        
        Error("database_error")
        Error("duplicate_email")
        
        GRPC(func() {
            Response(CodeOK)
            Response("database_error", CodeInternal)
            Response("duplicate_email", CodeAlreadyExists)
        })
    })
})

DSLを通じて検証できないランタイムエラー(データベースの競合、外部サービスの障害、ビジネスロジックの違反など)については、生成されたエラーコンストラクタを使用します:

func (s *users) CreateUser(ctx context.Context, p *users.CreateUserPayload) (*users.User, error) {
    // メールアドレスが既に存在するかチェック
    exists, err := s.db.EmailExists(ctx, p.Email)
    if err != nil {
        // データベースエラーをラップ
        return nil, users.MakeDatabaseError(fmt.Errorf("メールアドレスの確認に失敗: %w", err))
    }
    if exists {
        // ビジネスロジックエラーを返却
        return nil, users.MakeDuplicateEmail(fmt.Sprintf("メールアドレス %s は既に登録されています", p.Email))
    }
    
    // データベースにユーザーを作成
    user, err := s.db.CreateUser(ctx, p)
    if err != nil {
        return nil, users.MakeDatabaseError(fmt.Errorf("ユーザーの作成に失敗: %w", err))
    }
    
    return user, nil
}

ストリーミングでのエラー処理

ストリーミングgRPCメソッドを扱う場合、ストリーム関連のエラーとビジネスロジックのエラーの両方を処理する必要があるため、エラー処理はより複雑になります。以下は、ストリーミングサービスメソッドで異なる種類のエラーを処理する方法を示す例です:

func (s *service) StreamData(stream service.StreamDataServerStream) error {
    for {
        data, err := getData()
        if err != nil {
            if isRateLimitError(err) {
                return service.MakeRateLimitExceeded(err)
            }
            return service.MakeInternalError(err)
        }

        if err := stream.Send(data); err != nil {
            if isStreamInterrupted(err) {
                return service.MakeStreamInterrupted(err)
            }
            return err
        }
    }
}

エラー処理パターン

エラーのラッピング

外部パッケージや下位コンポーネントからのエラーを扱う場合、適切なgRPCステータスコードを維持しながらコンテキストを提供することが重要です。以下は、エラーチェーンを保持しながらエラーをラップする方法です:

func (s *service) ProcessData(ctx context.Context, p *service.ProcessDataPayload) (*service.Result, error) {
    result, err := s.processor.Process(p.Data)
    if err != nil {
        switch {
        case errors.Is(err, ErrInvalidFormat):
            return nil, service.MakeInvalidInput(fmt.Errorf("無効なデータ形式: %w", err))
        case errors.Is(err, ErrProcessingFailed):
            return nil, service.MakeInternalError(fmt.Errorf("処理に失敗: %w", err))
        default:
            return nil, service.MakeInternalError(err)
        }
    }
    return result, nil
}

エラーリカバリー

長時間実行される操作やバッチ処理では、一時的な障害を処理するためにエラーリカバリーメカニズムを実装することが望ましい場合があります。以下は、リトライロジックとエラー追跡を含むバッチ処理を実装する例です:

func (s *service) ProcessBatch(stream service.ProcessBatchServerStream) error {
    var processed, failed int

    for {
        payload, err := stream.Recv()
        if err == io.EOF {
            // 最終ステータスを送信
            return stream.SendAndClose(&service.BatchResult{
                Processed: processed,
                Failed:    failed,
            })
        }
        if err != nil {
            return service.MakeStreamInterrupted(err)
        }

        // エラーリカバリー付きで処理
        if err := s.processWithRetry(payload); err != nil {
            failed++
            // エラーをログに記録して処理を継続
            log.Printf("アイテムの処理に失敗: %v", err)
            continue
        }
        processed++
    }
}

func (s *service) processWithRetry(payload *service.Payload) error {
    var err error
    for retries := 0; retries < 3; retries++ {
        err = s.process(payload)
        if err == nil {
            return nil
        }
        // 一時的なエラーの場合のみリトライ
        if !isTransientError(err) {
            return err
        }
        time.Sleep(time.Second * time.Duration(retries+1))
    }
    return err
}

ベストプラクティス

エラー設計のガイドライン

  1. APIレベルの共通エラーを定義する

    すべてのサービスで一貫性を確保し、再利用を可能にするために、APIレベルで共通エラーを定義します。これにより、重複が減少し、統一的なエラー処理が確保されます:

    var _ = API("myapi", func() {
        // API全体で共有される共通エラー
        Error("unauthorized", func() {
            Description("リクエストには認証が必要です")
        })
        Error("not_found")  // デフォルトのErrorResult型を使用
        Error("validation_error", ValidationError, "バリデーションに失敗しました")
    
        // 共通のHTTPマッピングを定義
        HTTP(func() {
            Response("unauthorized", StatusUnauthorized)
            Response("not_found", StatusNotFound)
            Response("validation_error", StatusBadRequest)
        })
    })