• ホーム
  • テクノロジー
  • Go + Echo で WithInternal/SetInternal を使うとエラーレスポンスのカスタマイズがうまくいかないことがある

Go + Echo で WithInternal/SetInternal を使うとエラーレスポンスのカスタマイズがうまくいかないことがある

Go 言語の Web アプリケーションフレームワークである Echo に関する小ネタです。

執筆時点 (2024/10/01) での Echo フレームワークのバージョンは v4.12.0 です。将来のバージョンでは挙動が変わる可能性があるのでご了承ください。

先にまとめ

  • echo.Context.Bind() メソッド等は *echo.HTTPError 型の値を返すことがある
  • *echo.HTTPError 型の値を *echo.HTTPError.WithInternal()/SetInternal() メソッドに渡すと、エラーレスポンスのレスポンスボディが変化してしまうことがある

前提知識1:NewHTTPError() 関数によるエラーレスポンスの返却

Echo では echo.NewHTTPError() 関数を用いてエラーレスポンスを返却することができます。

echo.NewHTTPError() 関数の第二引数に "エラーメッセージ" のような文字列を渡した場合、デフォルトでは {"message": "エラーメッセージ"} というようなレスポンスボディが返却されます。

文字列の代わりに構造体を渡すことで、レスポンスボディを自由にカスタマイズすることも可能です。

package main

import "github.com/labstack/echo/v4"

type ErrorResponse struct {
    ErrorCode int    `json:"error_code"`
    Reason    string `json:"reason"`
}

func main() {
    e := echo.New()
    e.GET("/foo", func(c echo.Context) error {
        return echo.NewHTTPError(500, "エラーメッセージ")
    })
    e.GET("/bar", func(c echo.Context) error {
        return echo.NewHTTPError(500, ErrorResponse{ErrorCode: 1, Reason: "エラーの理由"})
    })
    e.Start(":8080")
}
$ curl localhost:8080/foo
{"message":"エラーメッセージ"}

$ curl localhost:8080/bar
{"error_code":1,"reason":"エラーの理由"}

echo.NewHTTPError() 関数の戻り値の型は *echo.HTTPError で、この型は error インターフェースを実装しています。そのため error 型の変数や戻り値として扱うことができます。

var err error = echo.NewHTTPError(500, "エラーメッセージ")

前提知識2:WithInternal()/SetInternal() メソッドによる内部エラー情報の設定

*echo.HTTPError.WithInternal()/SetInternal() メソッドを用いることで、echo.NewHTTPError() 関数などで作成した *echo.HTTPError に内部で発生したエラー情報を付与することができます。

この内部エラー情報はレスポンスボディには含まれませんが、サーバーのエラーログ等に出力させることができます。

package main

import (
    "errors"

    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

var ErrHoge = errors.New("Hogeエラーが発生しました")

func main() {
    e := echo.New()
    e.Use(middleware.Logger())

    e.GET("/hoge", func(c echo.Context) error {
        return echo.NewHTTPError(500, "エラーメッセージ").WithInternal(ErrHoge)
    })

    e.Start(":8080")
}
$ curl localhost:8080/hoge
{"message":"エラーメッセージ"}
# サーバーのログ(一部抜粋して整形)
{
  "method": "GET",
  "uri": "/hoge",
  "status": 500,
  "error": "code=500, message=エラーメッセージ, internal=Hogeエラーが発生しました",
  ...
}

前提知識3:Bind() メソッドによるリクエストのバインド

echo.Context.Bind() メソッドを用いることで、リクエストのクエリ/パスパラメータ、ヘッダー、リクエストボディを構造体にバインドすることができます。

package main

import "github.com/labstack/echo/v4"

type Request struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

func main() {
    e := echo.New()

    e.POST("/piyo", func(c echo.Context) error {
        var r Request
        if err := c.Bind(&r); err != nil {
            return echo.NewHTTPError(400, "リクエストが不正です")
        }
        return c.JSON(200, r)
    })

    e.Start(":8080")
}
$ curl localhost:8080/piyo -H "content-type: application/json" -d '{"name":"Taro","age":10}'
{"name":"Taro","age":10}

本題

ここからが本題です。

package main

import (
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

type Request struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

type ErrorResponse struct {
    ErrorCode int    `json:"error_code"`
    Reason    string `json:"reason"`
}

func main() {
    e := echo.New()
    e.Use(middleware.Logger())

    e.POST("/hogehoge", func(c echo.Context) error {
        var r Request
        if err := c.Bind(&r); err != nil {
            return echo.NewHTTPError(400, ErrorResponse{
                ErrorCode: 1,
                Reason:    "リクエストが不正です",
            }).WithInternal(err)
        }
        return c.JSON(200, r)
    })

    e.Start(":8080")
}

以上のようなコードで作成したサーバーに対して、以下のようにリクエストを送信するとどのようなレスポンスが返却されるでしょうか。

$ curl localhost:8080/hogehoge -H "content-type: application/json" -d '{"name":"Taro","age":"FFFF"}'

リクエストボディは意図的に不正な形式("age" が数字でない)にしています。

実際に送信してみると以下のようなレスポンスが返却されます。

$ curl localhost:8080/hogehoge -H "content-type: application/json" -d '{"name":"Taro","age":"FFFF"}'
{"message":"Unmarshal type error: expected=int, got=string, field=age, offset=27"}

{"error_code":1,"reason":"リクエストが不正です"} のようなレスポンスボディが返ってくると考えた方も多いのではないでしょうか。

ポイントは、echo.Context.Bind() メソッドが返したエラーを WithInternal() に渡しているところです:

        if err := c.Bind(&r); err != nil {
            return echo.NewHTTPError(400, ErrorResponse{
                ErrorCode: 1,
                Reason:    "リクエストが不正です",
            }).WithInternal(err)
        }

.WithInternal(err) 部分を削除すると、期待通り {"error_code":1,"reason":"リクエストが不正です"} といったレスポンスボディが返却されるようになります。

原因

上記のコードで echo.Context.Bind() メソッドが実際に返したインターフェース値の動的な型を確認してみます。

    if err := c.Bind(&r); err != nil {
        fmt.Printf("%T\n", err)
    }

すると、*echo.HTTPError 型を動的な型として持つインターフェース値が返されていることがわかります。これは echo.NewHTTPError() 関数が返す値の型と同じです。

今回の場合だと、実際に返されたエラーは このあたり のコードで作成されているようです。

つまり実質的には

            return echo.NewHTTPError(400, ErrorResponse{
                ErrorCode: 1,
                Reason:    "リクエストが不正です",
            }).WithInternal(
                echo.NewHTTPError(http.StatusBadRequest, "Unmarshal type error: ..."),
            )

といった感じになっている、ということです。

そして、おそらく *echo.HTTPError 型の値 e1, e2 があったとすると、return e1.WithInternal(e2) した場合には e2 を元にレスポンスボディ等が作成されるのだと考えられます。

簡単に確認してみます:

package main

import (
    "github.com/labstack/echo/v4"
)

type ErrorResponse struct {
    ErrorCode int    `json:"error_code"`
    Reason    string `json:"reason"`
}

func main() {
    e := echo.New()
    e.GET("/foobar", func(c echo.Context) error {
        e1 := echo.NewHTTPError(400, ErrorResponse{ErrorCode: 1, Reason: "エラー1"})
        e2 := echo.NewHTTPError(500, ErrorResponse{ErrorCode: 2, Reason: "エラー2"})
        return e1.WithInternal(e2)
    })
    e.Start(":8080")
}
$ curl localhost:8080/foobar -v
< HTTP/1.1 500 Internal Server Error
{"error_code":2,"reason":"エラー2"}

解決法

いろいろな解決法が考えられると思いますが、ここではそのうちのいくつかを紹介します。

1. WithInternal()/SetInternal()*echo.HTTPError 型の値を渡さない

echo.Context.Bind() から返されたエラーをそのまま *echo.HTTPError.WithInternal()/SetInternal() に渡さない、というのが最も簡単な解決方法だと思います。また、エラーをラップすることでも解決可能です:

package main

import (
    "fmt"

    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

type Request struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

type ErrorResponse struct {
    ErrorCode int    `json:"error_code"`
    Reason    string `json:"reason"`
}

func main() {
    e := echo.New()
    e.Use(middleware.Logger())

    e.POST("/hogehoge", func(c echo.Context) error {
        var r Request
        if err := c.Bind(&r); err != nil {
            return echo.NewHTTPError(400, ErrorResponse{
                ErrorCode: 1,
                Reason:    "リクエストが不正です",
            }).WithInternal(
                fmt.Errorf("リクエストが不正です: %w", err),
            )
        }
        return c.JSON(200, r)
    })

    e.Start(":8080")
}
$ curl localhost:8080/hogehoge -H "content-type: application/json" -d '{"name":"Taro","age":"FFFF"}'
{"error_code":1,"reason":"リクエストが不正です"}
# サーバーのログ(一部抜粋して整形)
{
  "method": "POST",
  "uri": "/hogehoge",
  "status": 400,
  "error": "code=400, message={1 リクエストが不正です}, internal=リクエストが不正です: code=400, message=Unmarshal type error: expected=int, got=string, field=age, offset=27, internal=json: cannot unmarshal string into Go struct field Request.age of type int",
  ...
}

2. WithInternal()/SetInternal() を使わず、エラーレスポンスのレスポンスボディの構造体内に内部エラー情報を含める

以下のように、エラーレスポンスのレスポンスボディの構造体 (ErrorResponse) 内に内部エラー情報を含めてしまうといった方法もあります:

package main

import (
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

type Request struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

type ErrorResponse struct {
    ErrorCode     int    `json:"error_code"`
    Reason        string `json:"reason"`
    InternalError error  `json:"-"` // クライアントには返さない
}

func main() {
    e := echo.New()
    e.Use(middleware.Logger())

    e.POST("/hogehoge", func(c echo.Context) error {
        var r Request
        if err := c.Bind(&r); err != nil {
            return echo.NewHTTPError(400, ErrorResponse{
                ErrorCode:     1,
                Reason:        "リクエストが不正です",
                InternalError: err,
            })
        }
        return c.JSON(200, r)
    })

    e.Start(":8080")
}

このようにすることで、*echo.HTTPError.WithInternal()/SetInternal() を使わずにサーバーのログ等に内部エラー情報を出力することができます。また json:"-" を指定しておけば、クライアント側には内部エラー情報は返却されません。

$ curl localhost:8080/hogehoge -H "content-type: application/json" -d '{"name":"Taro","age":"FFFF"}'
{"error_code":1,"reason":"リクエストが不正です"}
# サーバーのログ(一部抜粋して整形)
{
    "method": "POST",
    "uri": "/hogehoge",
    "status": 400,
    "error": "code=400, message={1 リクエストが不正です code=400, message=Unmarshal type error: expected=int, got=string, field=age, offset=27, internal=json: cannot unmarshal string into Go struct field Request.age of type int}",
    ...
}

余談

API のエラーコード(not HTTP ステータスコード)は 10001 のように整数を用いるのが一般的なのかなと思いますが、最近は "UserNotFound" のような文字列でもよくね? と思っています。

API の設計って難しいですよね。いつも頭を悩ませています。

会津ラボはきれいな API 設計に一家言ある存在を募集しています。

コメントを残す

メールアドレスが公開されることはありません。*がついている欄は必須項目です。

日本語が含まれない投稿は無視されますのでご注意ください。