Carpe Diem

備忘録

http.ResponseWriterに書き込んだstatus codeを取得したい

背景

  • 5xx系エラーをbugsnagのようなエラー検知サービスに送信したい
  • middleware層で網羅的に対応したい

といった際に、

  • http.ResponseWriterに書き込まれたstatus codeは直接アクセスできない

という問題があります。

今回はこの問題を解決する方法を紹介します。

環境

  • Go 1.21.0

方法

Custom ResponseWriterを作り、それをラップさせることで対応できます。

実装

具体的な実装方法です。

error middleware

type Reporter interface {
        Report(ctx context.Context, err error)
}

// ErrorHandler reports errors to the reporter.
func ErrorHandler(reporter Reporter) func(http.Handler) http.Handler {
        return func(next http.Handler) http.Handler {
                return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                        sw := &statusResponseWriter{ResponseWriter: w}
                        next.ServeHTTP(sw, r)

                        if sw.status >= http.StatusInternalServerError {
                                err := errorFromContext(r.Context())
                                if err == nil {
                                        return
                                }
                                // notify
                                reporter.Report(r.Context(), err)
                        }
                })
        }
}

type statusResponseWriter struct {
        http.ResponseWriter
        status int
}

func (w *statusResponseWriter) WriteHeader(status int) {
        w.status = status
        w.ResponseWriter.WriteHeader(status)
}

ポイント

ポイントは以下です。

  • ResponseWriterをCustom ResponseWriterでラップする
  • Custom ResponseWriterはhttp.ResponseWriterを埋め込んで基本実装されている状態にする
  • WriteHeader()実行時にstatus codeを保持する
  • 保持したstatus codeを使ってReportするかどうか判定する

またReporterをinterfaceで用意しておくと、今回の例の様にbugsnagだけでなく単なるloggerなど切り返しやすくなります。

main

ミドルウェアを使う部分の実装です。

func main() {
        mux := http.NewServeMux()

        mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
                w.WriteHeader(http.StatusOK)
        })

        mux.HandleFunc("/500", func(w http.ResponseWriter, r *http.Request) {
                err := errors.New("some error")
                WithError(r, err)
                w.WriteHeader(http.StatusInternalServerError)
        })

        http.ListenAndServe(":8080", ErrorHandler(&reporter{})(mux))
}

type reporter struct{}

func (r *reporter) Report(ctx context.Context, err error) {
        log.Println(err)
}

エラー詳細

ここはoptionalですが、w.Write()前にエラーの詳細をrequest contextの渡しておくことでエラー検知ツールにエラー詳細を渡すことができます。

var errorContextKey struct{}

// WithError attaches an error to the request's context. 
// It modifies the request in place to ensure that later middleware and handlers 
// using the original pointer to the request can access the updated context.
func WithError(r *http.Request, err error) {
        if err == nil {
                return
        }
        r2 := r.WithContext(withError(r.Context(), err))
        *r = *r2
}

// withError sets an error to the context.
func withError(ctx context.Context, err error) context.Context {
        if err == nil {
                return ctx
        }
        return context.WithValue(ctx, errorContextKey, err)
}

// errorFromContext returns an error from the context.
func errorFromContext(ctx context.Context) error {
        if err, ok := ctx.Value(errorContextKey).(error); ok {
                return err
        }
        return nil
}

ミドルウェアは全リクエストに対して網羅的に対応できる一方で、アクセスできる部分が限定されるので愚直ではありますがrequest context辺りが無難かと思われます。

注意①. エラーの渡し方

ErrorHandler()ミドルウェアでの*http.Requestオブジェクトはhandlerの呼び出し前に引数で生成されるので、handlerで*http.Requestオブジェクトにエラーを渡したい場合はポインタの中身を書き換える必要があります。

例えばミドルウェアでは次のように書くことがよくありますが、

func WithError(r *http.Request, err error) *http.Request {
    if err == nil {
        return r
    }
    return r.WithContext(withError(r.Context(), err))
}

エラーを扱えるのはhandler内であり、上記のようにオブジェクトを更新しても引き継ぐ場所がありません。

mux.HandleFunc("/500", func(w http.ResponseWriter, r *http.Request) {
        err := errors.New("some error")
        r = WithError(r, err) // 引き継げない
        w.WriteHeader(http.StatusInternalServerError)
})

なので次のようにポインタの中身を書き換えるようにします。

func WithError(r *http.Request, err error) {
        if err == nil {
                return
        }
        r2 := r.WithContext(withError(r.Context(), err))
        *r = *r2
}

注意②. ミドルウェアの順番

*http.Requestオブジェクトを更新されるとErrorHandler()ミドルウェアのオブジェクトと別のものになってしまうため、このミドルウェアは最後に呼び出すと事故りにくいです。

例えば次のようにr = r.WithContext(ctx)*http.Requestオブジェクトを更新するミドルウェアが別であったとします。

func AuthHandler(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                ctx := WithUserID(r.Context(), "user001")
                r = r.WithContext(ctx)
                next.ServeHTTP(w, r)
        })
}

そして次のようにErrorHandlerの後ろにあると、

func main() {
        mux := chi.NewRouter()
        mux.Use(
                ErrorHandler(&reporter{}),
                AuthHandler,  // *http.Requestを更新してしまう
        )
        mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
                w.WriteHeader(http.StatusOK)
        })

        mux.HandleFunc("/500", func(w http.ResponseWriter, r *http.Request) {
                err := errors.New("some error")
                sendError(w, r, err)
        })

        if err := http.ListenAndServe(":8080", mux); err != nil {
                log.Panic(err)
        }
}

ErrorHandlerで使おうとしている*http.Requestオブジェクトと別ものになってしまうので、エラー詳細を引き出せません。

// ErrorHandler reports errors to the reporter.
func ErrorHandler(reporter Reporter) func(http.Handler) http.Handler {
        return func(next http.Handler) http.Handler {
                return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                        sw := &statusResponseWriter{ResponseWriter: w}
                        next.ServeHTTP(sw, r)  // このrはAuthミドルウェアで上書きされてhandlerに渡るものと別もの

                        if sw.status >= http.StatusInternalServerError {
                                err := errorFromContext(r.Context())
                                if err == nil {
                                        return
                                }
                                // notify
                                reporter.Report(r.Context(), err)
                        }
                })
        }
}

なのでミドルウェアの順番としては最後に置くのが良いです。

func main() {
        mux := chi.NewRouter()
        mux.Use(
                AuthHandler,
                ErrorHandler(&reporter{}),
        )

動作検証

200の場合

$ curl localhost:8080/

Reporterの分岐に入らずログは出ません。

$ go run .

500の場合

$ curl localhost:8080/500

Reporterの分岐に入りログが出るようになります。

$ go run .
2023/07/28 12:08:07 some error

その他

サンプルコード

今回のサンプルコードはこちらです。

github.com

http.ResponseWriterの注意点

http.ResponseWriterのメソッドは実行順序があり、

  1. w.Header() or Read Request.Body
  2. WriteHeader(statusCode)
  3. Write([]byte)

の順に使わないといけません。
なのでこれを知っていないと以下のような問題にぶつかります。

Changing the header map after a call to WriteHeader (or Write) has no effect

w.WriteHeaderを呼んでからw.Header()でヘッダーMapをいじっても変更できません。

If WriteHeader is not called explicitly, the first call to Write will trigger an implicit WriteHeader(http.StatusOK).

w.WriteHeader()を呼ぶ前にWrite()を呼ぶと、データを書き込む前に自動的にWriteHeader(http.StatusOK)を呼ばれます。

そしてWriteHeaderは一度呼ぶと変更はできず内部的に以下のエラーが表示されます。

http: superfluous response.WriteHeader call from main.(*statusResponseWriter).WriteHeader

Depending on the HTTP protocol version and the client, calling Write or WriteHeader may prevent future reads on the Request.Body.

w.Write or w.WriteHeaderを呼んだらRequest.Bodyは読めなくなります。

http package - net/http - Go Packages

まとめ

Custom ResponseWriterを使うことでmiddlewareの柔軟性を上げることができる例を紹介しました。