🍮

Goのhttp.RoundTripperでレート制御とリトライの機能を追加する方法

2022/09/09に公開

はじめに

Goで外部へhttpリクエストを行う際には、多くの場合でnet/httpを利用すると思います。
net/httpには通常利用する分には必要な機能が備わっていますが、独自に拡張して使いたい場合はhttp.RoundTripperというインターフェースを利用できます。
利用方法の解説としては、こちらの「Go http.RoundTripper 実装ガイド」という記事に詳細に解説がありましたので参考にさせて頂き、
今回はより具体的な利用例にフォーカスして実装例を交えて紹介したいと思います。

やりたいこと

素のnet/httpではリクエストがエラーになった際のリトライや、秒間リクエストの最大値を制限するようなレート制御の機能はありません。
なので今回は、

  • リトライ処理
  • レート制御

この2つをhttp.RoundTripperを使って拡張実装したいと思います。

※今回のサンプルコードの完成品はこちらにupしています。

事前準備

今回実装するhttp.RoundTripperの枠を用意します。

http.RoundTripperをフィールドに持つ構造体(MyTransport)を定義し、
初期化用の関数を定義します。

さらに、MayTransportのレシーバー関数にRoundTrip(req *http.Request) (*http.Response, error)を追加することで、暗黙的にhttp.RoundTripperインターフェースに準拠させます。

import (
	"net/http"
)

type MyTransport struct {
	wrapped http.RoundTripper
}

func NewMyTransport(transport http.RoundTripper) *MyTransport {
	return &MyTransport{
		wrapped: transport,
	}
}

func (t *MyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	return t.wrapped.RoundTrip(req)
}

RoundTripに処理を追加することで、httpリクエストの前後処理を追加することができます。

リトライ制御

RoundTrip(req *http.Request) (*http.Response, error)にリトライ処理を追加していきましょう。

MyTransportのフィールドに、最大リトライ数とリトライした数を追加します。

type MyTransport struct {
	wrapped http.RoundTripper

	maxRetryCounts int // 最大リトライ数
	retryCounts    int // リトライした数
}

初期化時にmaxRetryCountsの値を渡せるようにします。

func NewLimitedTransport(transport http.RoundTripper, maxRetryCounts int) *MyTransport {
	return &MyTransport{
		wrapped:        transport,
		maxRetryCounts: maxRetryCounts,
	}
}

次にRoundTrip内にリトライ処理を追加していきます。

レスポンスのステータスコードが50x系以上の場合はリトライするようにします。
また、maxRetryCountsで指定した上限までリトライし、Exponential BackOffのアルゴリズムで指数関数的リトライ間隔を増加させます。

func (t *MyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	var res *http.Response
	var err error
	for {
		// 実際のhttpリクエストはここ
		res, err = lt.wrapped.RoundTrip(req)

		// 50x系以上はリトライ
		if res != nil && res.StatusCode < http.StatusInternalServerError {
			break
		}

		// リトライ数の上限チェック
		lt.retryCounts++
		if lt.retryCounts > lt.maxRetryCounts {
			break
		}

		// Exponential BackOff でウェイトを入れる
		time.Sleep(time.Second * time.Duration(math.Pow(2, float64(lt.retryCounts))))
	}
	lt.retryCounts = 0
	return res, err
}

レート制御

RoundTrip(req *http.Request) (*http.Response, error)にレート処理を追加していきましょう。

今回はFixed Window Counterというアルゴリズムでレート制御を実装してみます。
簡単にいうと、経過する時間を固定の期間で区切り、期間ごとに許可するリクエスト数を制限するアルゴリズムです。

固定期間を表す構造体(Window)を定義し、単位時間あたりのリクエスト数上限、単位時間(ms)、現在のwindowをMyTransportのフィールドに追加します。

type MyTransport struct {
... 省略
	maxRequestCounts int    // 単位時間あたりのリクエスト数上限
	perMilliSecond   int64  // 単位時間(ms)
	window           Window // 現在のwindow
}

// 固定期間(Window)の構造体
type Window struct {
	key           int64 // windowのキー
	requestCounts int   // window内のリクエスト数
}

MyTransportの初期化時ににこれらを初期化できるようにします。

func NewMyTransport(transport http.RoundTripper, maxRetryCounts int, maxRequestCounts int, perMilliSecond int64) *MyTransport {
	return &MyTransport{
		wrapped:          transport,
		maxRequestCounts: maxRequestCounts,
		perMilliSecond:   perMilliSecond,
		maxRetryCounts:   maxRetryCounts,
		retryCounts:      0,
		window: Window{
			key:           int64(0),
			requestCounts: 0,
		},
	}
}

最後にレート制御の処理をRoundTripに追加します。

現在時刻をperMilliSecondで割って切り捨てた数をwindowのキーとします。
このkeyはperMilliSecond経過毎に新しい値になりますので、同じwindow内での一意なキーとなります。

func (t *MyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	for {
		now := time.Now().UnixMilli()
		cKey := now / t.perMilliSecond

		// 新しい固定期間(window)になったらwaitなしでリクエストする
		if t.window.key != cKey {
			t.window = Window{
				key:           cKey,
				requestCounts: 0,
			}
			break
		}

		// 単位時間あたりのリクエスト数上限まではwaitなしでリクエストする
		if t.window.requestCounts < t.maxRequestCounts {
			break
		}

		// リクエスト数上限を超えていたら、waitする
		wait := t.perMilliSecond - now%t.perMilliSecond
		time.Sleep(time.Millisecond * time.Duration(wait))
	}
	t.window.requestCounts++

...省略、以下でリクエスト処理を行う
	return res, err
}

テスト

それでは、完成したMyTransportを使ってテストをしてみます。

今回は以下のような設定でテストをしてみます

  • 最大リトライ数は5
  • 4.5秒毎に最大3リクエストまで
client := http.Client{
	Transport: NewMyTransport(
		http.DefaultTransport,
		5,    // 最大リトライ数
		3,    // 単位時間あたりのリクエスト数上限
		4500, // 単位時間(ms)
	),
}

この設定で500が返ってくるエンドポイントにリクエストを送ってみます

resp, _ := client.Get(url)
defer resp.Body.Close()

結果はこちら、リトライ間隔を徐々に開けつつ5回までりトライされていることがわかります

{"time":"2022-09-09T08:59:01.521892211Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"500"}
{"time":"2022-09-09T08:59:03.577460181Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"500"}
{"time":"2022-09-09T08:59:07.642723871Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"500"}
{"time":"2022-09-09T08:59:15.713717009Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"500"}
{"time":"2022-09-09T08:59:31.822121165Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"500"}
{"time":"2022-09-09T09:00:03.914825907Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"500"}

次に、200が返ってくるエンドポイントに20回連続してリクエストを送ってみます

for i := 0; i < 20; i++ {
	resp, _ := client.Get(url)
	defer resp.Body.Close()
}

結果はこちら。
少し見づらいですが、大体4.5秒毎に3リクエストのペースでリクエストされていることがわかります

{"time":"2022-09-09T09:00:04.006523686Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"200"}
{"time":"2022-09-09T09:00:04.103094697Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"200"}
{"time":"2022-09-09T09:00:04.189073231Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"200"}
{"time":"2022-09-09T09:00:04.552413101Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"200"}
{"time":"2022-09-09T09:00:04.647212881Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"200"}
{"time":"2022-09-09T09:00:04.727567042Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"200"}
{"time":"2022-09-09T09:00:09.051296264Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"200"}
{"time":"2022-09-09T09:00:09.149187824Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"200"}
{"time":"2022-09-09T09:00:09.239032885Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"200"}
{"time":"2022-09-09T09:00:13.549064137Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"200"}
{"time":"2022-09-09T09:00:13.636816718Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"200"}
{"time":"2022-09-09T09:00:13.724980151Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"200"}
{"time":"2022-09-09T09:00:18.054925814Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"200"}
{"time":"2022-09-09T09:00:18.15239561Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"200"}
{"time":"2022-09-09T09:00:18.231450035Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"200"}
{"time":"2022-09-09T09:00:22.566128516Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"200"}
{"time":"2022-09-09T09:00:22.663002133Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"200"}
{"time":"2022-09-09T09:00:22.750378144Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"200"}
{"time":"2022-09-09T09:00:27.06199479Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"200"}
{"time":"2022-09-09T09:00:27.156784826Z","level":"INFO","prefix":"-","file":"http.go","line":"69","message":"200"}

まとめ

今回はGoにおけるhttpリクエストのレート制御とリトライの機能を、
http.RoundTripperというインターフェースを拡張することで実現しました。
外部のAPIを利用するときに、「毎秒○回まで」のような利用制限が設けられている場合がよくありますが、そんな時に役立てれば幸いです。

Discussion