【Echo】タイムアウトのレスポンスをJSONで返却する

2024/05/10に公開

はじめに

本記事では、GoのWebフレームワークであるEchoで、タイムアウトのレスポンスをJSONで返却するカスタムミドルウェアを作成します。
Echoにはタイムアウトを制御するミドルウェアがデフォルトで用意されていますが、text/plain以外のレスポンスを扱うことはできないので、JSONを扱いたい場合はカスタムミドルウェアを実装する必要があります。

// デフォルトで用意されているタイムアウトを制御するミドルウェア
e := echo.New()
e.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{
  Skipper:                     middleware.DefaultSkipper,
  ErrorMessage:                "custom timeout error message returns to client",
  OnTimeoutRouteErrorHandler:  func(err error, c echo.Context) {
    log.Println(c.Path())
  },
  Timeout:                     30*time.Second,
}))

また、デフォルトのタイムアウトを制御するミドルウェアでは、レスポンスヘッダなどを追加することもできません。
個人的な意見ですが、EchoでAPIを構築する際にはカスタムミドルウェアを作成するのがベターな気がしています。

カスタムミドルウェアを実装する

最初にカスタムミドルウェアの実装をお見せします。

package middlewares

import (
	"context"
	"net/http"
	"time"

	"github.com/labstack/echo/v4"
	"github.com/wasuwa/echo-sample/logger"
)

// APIError APIエラー
type APIError struct {
	Code       string `json:"code"`
	Message    string `json:"message"`
	StatusCode int    `json:"-"`
}

var errGatewayTimeout = &APIError{
	Code:       "001",
	Message:    "タイムアウトしました。再度お試しください。",
	StatusCode: http.StatusGatewayTimeout,
}

// Timeout タイムアウトを制御するミドルウェア
func Timeout(timeout time.Duration) echo.MiddlewareFunc {
	return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) error {
			ctx, cancel := context.WithTimeout(c.Request().Context(), timeout)
			defer cancel()

			errChan := make(chan error, 1)

			go func() {
 				defer func() {
					// Panic対策。非同期で処理しているので、middleware.Recover()ではパニックから回復できないので注意。
					if r := recover(); r != nil {
						errChan <- fmt.Errorf("%v", r)
					}
				}()
				errChan <- next(c)
			}()

			select {
			case <-ctx.Done():
				if c.Response().Committed {
					return nil
				}
				logger.Error(c, "リクエストがタイムアウトしました", ctx.Err())
				return c.JSON(errGatewayTimeout.StatusCode, errGatewayTimeout)
			case err := <-errChan:
				return err
			}
		}
	}
}

簡単にコードの説明をすると、context.WithTimeout関数で処理時間を管理し、規定の時間を過ぎたらタイムアウトエラーのレスポンスを返却するようにしています。
タイムアウトのレスポンスを返却する前に、!c.Response().Committedのチェックを行うことで、レスポンスの2重書き込みを防ぎます。
上記のチェックをしないと、ctx.Done()errChanを受信するタイミングがほぼ同時の場合に、レスポンスボディが以下のように合体する可能性があるので注意が必要です。

{
    "version": "v1.0.0"
}
{
    "code": "001",
    "message": "タイムアウトしました。再度お試しください。"
}

補足として、Timeout関数の使い方は以下となります。

e := echo.New()
e.Use(middlewares.Timeout(time.Second * 30))

タイムアウト時間を引数で渡すようにしているので、テストが書きやすいと思います。

実際にタイムアウトのレスポンスを確認する

サンプルでAPIを作成し、実際にリクエストがタイムアウトした時のレスポンスを確認します。
APIのコードは以下です。

package main

import (
	"net/http"
	"time"

	"github.com/labstack/echo/v4"
	"github.com/wasuwa/echo-sample/middlewares"
)

func main() {
	e := echo.New()
	e.Use(middlewares.Timeout(time.Second * 1))
	e.GET("/version", versionHandler)
	e.Logger.Fatal(e.Start(":8080"))
}

// VersionResponse バージョン取得APIのレスポンス
type VersionResponse struct {
	Version string `json:"version"`
}

// versionHandler バージョン取得APIのHTTPハンドラ
func versionHandler(c echo.Context) error {
	time.Sleep(time.Second * 10000)
	res := &VersionResponse{
		Version: "v1.0.0",
	}
	return c.JSON(http.StatusOK, res)
}

リクエストをタイムアウトさせたいので、versionHandler関数の内部でtime.Sleep関数を実行しています。

では実際にリクエストを送信してみましょう。

// レスポンスボディ
{
    "code": "001",
    "message": "タイムアウトしました。再度お試しください。"
}
// レスポンスヘッダ
Content-Type: application/json
...

JSONで返却されているので、想定通りに動作していそうです。

参考

Discussion