Works by

Ren's blog

@rennnosuke_rk 技術ブログです

Go HTTPクライアントの前処理・後処理を `RoundTripper` で実装する

Goal

Go の http.RoundTripper を使用して、http.Client パッケージのリクエストの前処理・後処理を追加できるようにしてみます。

Background

HTTPクライアントを利用した実装において、リクエストやレスポンスに関連した処理を書きたいことはよくあります。

  • リクエスト時にログやトレースを仕込みたい
  • メソッドやendpointごとにRate Limitを設けたい
  • 特定のステータスコードに対してリトライしたい

このような場合、 http.Client が持つ Transport フィールドを書き換え、リクエスト時に処理を実行してもらうことができます。

http.RoundTripper

http.ClientTransport フィールドは http.RoundTripper 型です。

http.RoundTripper は単一のHTTPトランザクションを実行する RoundTrip メソッドを定義します。

go.dev

// RoundTripper is an interface representing the ability to execute a
// single HTTP transaction, obtaining the [Response] for a given [Request].
//
// A RoundTripper must be safe for concurrent use by multiple
// goroutines.
type RoundTripper interface {
    // RoundTrip executes a single HTTP transaction, returning
    // a Response for the provided Request.
    //
    // RoundTrip should not attempt to interpret the response. In
    // particular, RoundTrip must return err == nil if it obtained
    // a response, regardless of the response's HTTP status code.
    // A non-nil err should be reserved for failure to obtain a
    // response. Similarly, RoundTrip should not attempt to
    // handle higher-level protocol details such as redirects,
    // authentication, or cookies.
    //
    // RoundTrip should not modify the request, except for
    // consuming and closing the Request's Body. RoundTrip may
    // read fields of the request in a separate goroutine. Callers
    // should not mutate or reuse the request until the Response's
    // Body has been closed.
    //
    // RoundTrip must always close the body, including on errors,
    // but depending on the implementation may do so in a separate
    // goroutine even after RoundTrip returns. This means that
    // callers wanting to reuse the body for subsequent requests
    // must arrange to wait for the Close call before doing so.
    //
    // The Request's URL and Header fields must be initialized.
    RoundTrip(*Request) (*Response, error)
}

一度HTTPクライアントで使用したい機能を http.RoundTripper 実装として用意すれば、同一の機能を複数の http.Client で利用することができます。

RoundTripperを利用した前処理・後処理の実行

http.RoundTripper をwrapして前処理・後処理を挿入できる DecorateRoundTripper を実装してみます。

  1. DecorateRoundTripper 構造体は RoundTripper インターフェースを実装しています。 フィールドには前処理を行う before 関数と後処理を行う after 関数を持ちます。
  2. RoundTrip メソッドは、リクエストを送信する前に before 関数を実行し、レスポンスを受信した後に after 関数を実行します。
// 1.
type DecorateRoundTripper struct {
    base   http.RoundTripper
    before func(*http.Request) error
    after  func(*http.Response) error
}

// 2.
func (d *DecorateRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
    if d.before != nil {
        if err := d.before(req); err != nil {
            return nil, err
        }
    }
    resp, err := d.base.RoundTrip(req)
    if err != nil {
        return nil, err
    }
    if d.after != nil {
        if err := d.after(resp); err != nil {
            return nil, err
        }
    }
    return resp, nil

使い方としては http.ClientTransport フィールドにDecorateRoundTripper 参照を渡し、wrapしたいRoundTripperを base に、追加したい前処理・後処理を before after それぞれに渡すだけです。

今回の例ではリクエスト前・リクエスト後にログを出力する処理を入れています。

var cli = http.Client{
    Transport: &DecorateRoundTripper{
        // wrapするRoundTripper
        base: http.DefaultTransport,
        // 前処理
        before: func(r *http.Request) error {
            slog.InfoContext(r.Context(), "before request")
            return nil
        },
        // 後処理
        after: func(r *http.Response) error {
            slog.InfoContext(r.Request.Context(), "after response")
            return nil
        },
    },
    Timeout: 30,
}

http.RoundTrip を実装する上で気をつける点

http.RoundTripper のGoDocにあるように、 http.RoundTripper の実装にはいくつかの注意点があります。

goroutine safeであること

複数のgoroutineからのconcurrent accessに対して安全である必要があります。 何かの状態、例えばグローバルな変数やフィールドなどへの参照・更新がなければ問題ありませんが、それらを取り扱う場合は排他制御の必要があります。

上記の前処理・後処理の例では、ログの出力のみでフィールドなどの変更を行っていないため、問題ありません。

レスポンスを解釈しない

GoDocによると、 RoundTrip 内でのレスポンスの解釈は推奨されていません。

  • レスポンスを取得した場合、レスポンスのHTTPステータスコードに関係なくerr == nilを返すこと
  • nilでないerrを返す場合は、レスポンスを取得できなかった場合とすること
  • リダイレクト、認証、クッキーなど、より高レベルのプロトコルの詳細を処理しようとしないこと
    // RoundTrip should not attempt to interpret the response. In
    // particular, RoundTrip must return err == nil if it obtained
    // a response, regardless of the response's HTTP status code.
    // A non-nil err should be reserved for failure to obtain a
    // response. Similarly, RoundTrip should not attempt to
    // handle higher-level protocol details such as redirects,
    // authentication, or cookies.

上記の前処理・後処理の例では、レスポンスを読み取り、読み取った内容によって処理を分岐するなどの実装を行っていないため、問題ありません。

レスポンスの解釈について
この規約によってレスポンスの読み取りができないと思ってしまいますが、一方で `http.RoundTrip` 実装でもある `http.Transport` では、レスポンスの読み取りやその内容によって処理を変更しています。 実装は以下のようになっています。
func (t *Transport) RoundTrip(req *Request) (*Response, error) {
    return t.roundTrip(req)
}
これは個人的な解釈ですが、以下のように `http.RoundTripper` としての実装と、 `http.Transport` としての実装を分けるためにこのような書き方をしているのでは、と考えています。
  • `RoundTrip` メソッドは `http.RoundTrip` として、リクエストを送信しその結果のレスポンスを単に返すことを目的としており、レスポンスの中身を解釈する責任は負わない
  • `roundTrip` メソッドは `http.Transport` の実装として、レスポンスを解釈する処理も行う

リクエストを修正しない

リクエストの修正も推奨されていません。

ただし、リクエストのBodyを消費してcloseすることは許可しています。

また呼び出し側は ResponseBody が閉じられるまで、リクエストを変更したり再利用することができません。

上記の前処理・後処理の例ではリクエストの変更はなく、これも問題ありません。

常にボディを閉じる

http.RoundTripper には常にリクエスト・レスポンスボディを閉じる責務があります。

上記の前処理・後処理の例ではこの責務を base に委譲しています。

DecorateRoundTripper 前処理・後処理で気をつける点

リクエスト・レスポンスボディを読み取る場合、ボディの内容を再利用可能にする

例えばリクエストボディの内容を一度 before で読み取ってしまうと、base.RoundTrip 内でリクエストボディを読み取れなくなってしまいます。

また一度 after でレスポンスボディを読み取ってしまうと、 DecorateRoundTripper を使用するクライアントのレスポンスからボディが読み取れなくなってしまいます。

そのため、前処理・後処理でリクエスト・レスポンスボディを読み取る場合は、一度ボディの内容を退避し、読み取られた後にボディを再設定して、後続の処理でもボディが読み取れるようにするとよいでしょう。

1. c.before 呼び出し前に、ボディの内容を読み取り一次保存

req.GetBody()req.Body のコピーを取得し、一時保存します。
req.Body 読み取り後に req.GetBody() を呼び出すとコピーを取得することができないので、必ず req.Body 読み取り処理前に実行してください。

c.before でリクエストボディを読み取った後、一次保存したボディのコピーを req.Body に再度設定します。

2. c.after 呼び出し前に、ボディの内容を読み取り一次保存

io.ReadAllresp.Body のコピーを取得し、一時保存します。

io.ReadAll の読み取りで resp.Body が閉じられるため、 一度保存したレスポンスボディバイナリを使用して bytes.NewReader を初期化し、 io.NopCloser でラップして再度設定します。

func (c *ConsumableDecorateRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
    if c.before != nil {
        // 1.
        rb, err := req.GetBody()
        if err != nil {
            return nil, err
        }
        if err := c.before(req); err != nil {
            return nil, err
        }
        req.Body = rb
    }
    resp, err := c.base.RoundTrip(req)
    if err != nil {
        return nil, err
    }
    if c.after != nil {
        // 2.
        rb, err := io.ReadAll(resp.Body)
        if err != nil {
            return nil, err
        }
        resp.Body = io.NopCloser(bytes.NewReader(rb))
        if err := c.after(resp); err != nil {
            return nil, err
        }
    }
    return resp, nil
}

レスポンスボディを読み取る場合、末尾バイトまで読み取りcloseする

http.ResponseBody を完了まで読み取り、closeしなかった場合、keep-aliveによってハンドシェイク済みのTCPコネクションを再利用できません。

go.dev

// Body represents the response body.
    //
    // The response body is streamed on demand as the Body field
    // is read. If the network connection fails or the server
    // terminates the response, Body.Read calls return an error.
    //
    // The http Client and Transport guarantee that Body is always
    // non-nil, even on responses without a body or responses with
    // a zero-length body. It is the caller's responsibility to
    // close Body. The default HTTP client's Transport may not
    // reuse HTTP/1.x "keep-alive" TCP connections if the Body is
    // not read to completion and closed.
    //
    // The Body is automatically dechunked if the server replied
    // with a "chunked" Transfer-Encoding.
    //
    // As of Go 1.12, the Body will also implement io.Writer
    // on a successful "101 Switching Protocols" response,
    // as used by WebSockets and HTTP/2's "h2c" mode.
    Body io.ReadCloser

そのためレスポンスボディの読み取りが発生したら、Bodyを使い終わった後末尾バイトまで読み取りcloseするとよいでしょう。

       after: func(r *http.Response) error {
            // レスポンスボディを読み切って閉じる
            defer func() {
                _, _ = io.Copy(io.Discard, r.Body)
                _ = r.Body.Close()
            }()
            var v Resp
            if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
                return err
            }
            fmt.Printf("response: %+v\n", v)
            return nil
        },

Summary

  • Goの http.RoundTripper を用いて、http.Clientのリクエスト前後に処理を追加できるようにしました。
  • 例として http.RoundTripper をwrapする DecorateRoundTripper を実装し、リクエスト前後にログ出力などの処理を追加しました。
  • リクエストやレスポンスボディを再利用可能にしたり、レスポンスボディの完全な読み取りとcloseなど、実装においていくつか注意する点があります。

Appendix

pkg.go.dev

github.com