GolangにてAPIをリクエストした際、エラーとなって返却された際に間隔を空けてリトライ処理を行うようなAPIクライアントを実装しました。
はじめにAPIクライアントのコード全体を公開し、各処理毎の解説を後述します。
また、今回のAPIクライアントはGo 1.10.3
で実装しております。
package main
import (
"errors"
"io/ioutil"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
var (
ApiErrHttpClientError = errors.New("Http Client Error")
ApiErrBadRequest = errors.New("400:Bad Request")
ApiErrUnauthorized = errors.New("401:Unauthorized")
ApiErrForbidden = errors.New("403:Forbidden")
ApiErrNotFound = errors.New("404:Not Found")
ApiErrMethodNotAllowed = errors.New("405:Method Not Allowed")
ApiErrInternalServerError = errors.New("500:Internal Server Error")
ApiErrBadGateway = errors.New("502:Bad Gateway")
ApiErrServiceUnavailable = errors.New("503:Service Unavailable")
ApiErrGatewayTimeout = errors.New("504:Gateway Timeout")
)
type ApiClient struct {
client *http.Client
host string
port string
requestId string
retryLimit int
delayBaseSecond int
}
type ApiClientConfig struct {
TimeoutSecond int
RetryLimit int
DelayBaseSecond int
}
var defaultApiClientConfig = ApiClientConfig{
TimeoutSecond: 5,
RetryLimit: 3,
DelayBaseSecond: 1,
}
func NewApiClient() *ApiClient {
return NewApiClientWithConfig(defaultApiClientConfig)
}
func NewApiClientWithConfig(config ApiClientConfig) *ApiClient {
if config.TimeoutSecond < 0 {
config.TimeoutSecond = defaultApiClientConfig.TimeoutSecond
}
if config.RetryLimit < 0 {
config.RetryLimit = defaultApiClientConfig.RetryLimit
}
if config.DelayBaseSecond < 0 {
config.DelayBaseSecond = defaultApiClientConfig.DelayBaseSecond
}
return &ApiClient{
client: &http.Client{
Timeout: time.Duration(config.TimeoutSecond) * time.Second,
},
host: os.Getenv("API_HOST"),
port: os.Getenv("API_PORT"),
retryLimit: config.RetryLimit,
delayBaseSecond: config.DelayBaseSecond,
}
}
func (c *ApiClient) Get(path string, param map[string]interface{}) ([]byte, error) {
url := "http://" + c.host + ":" + c.port + path
req, _ := http.NewRequest("GET", url, nil)
req.URL.RawQuery = c.buildParam(param)
return c.requestWithRetry(req, c.retryLimit)
}
func (c *ApiClient) buildParam(param map[string]interface{}) string {
values := url.Values{}
for key, value := range param {
switch v := value.(type) {
case string:
values.Add(key, v)
case int:
values.Add(key, strconv.Itoa(v))
}
}
return values.Encode()
}
func (c *ApiClient) requestWithRetry(req *http.Request, retry int) ([]byte, error) {
body, err := c.request(req)
if c.shouldRetry(err, retry) {
time.Sleep(c.calcDelayTime(retry))
return c.requestWithRetry(req, retry-1)
}
return body, err
}
func (c *ApiClient) request(req *http.Request) ([]byte, error) {
res, err := c.client.Do(req)
if err != nil {
return []byte{}, ApiErrHttpClientError
}
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return []byte{}, err
}
return body, c.getError(res)
}
func (c *ApiClient) getError(res *http.Response) error {
switch res.StatusCode {
case http.StatusBadRequest: // 400
return ApiErrBadRequest
case http.StatusUnauthorized: // 401
return ApiErrUnauthorized
case http.StatusForbidden: // 403
return ApiErrForbidden
case http.StatusNotFound: // 404
return ApiErrNotFound
case http.StatusMethodNotAllowed: // 405
return ApiErrMethodNotAllowed
case http.StatusInternalServerError: // 500
return ApiErrInternalServerError
case http.StatusBadGateway: // 502
return ApiErrBadGateway
case http.StatusServiceUnavailable: // 503
return ApiErrServiceUnavailable
case http.StatusGatewayTimeout: // 504
return ApiErrGatewayTimeout
default:
return nil
}
}
func (c *ApiClient) shouldRetry(err error, retry int) bool {
if retry <= 0 {
return false
}
switch err {
case ApiErrBadGateway,
ApiErrServiceUnavailable,
ApiErrGatewayTimeout,
ApiErrHttpClientError:
return true
default:
return false
}
}
func (c *ApiClient) calcDelayTime(retry int) time.Duration {
return time.Duration(c.delayBaseSecond*(c.retryLimit-retry+1)) * time.Second
}
初期化
このAPIクライアントでは、以下の設定値が存在し、初期化の際にそれぞれ値がセットされます。
- リクエストのタイムアウト時間(秒)
- リクエストの最大リトライ回数
- 次回リクエストまでの待機時間(秒)
これらの設定値は、ApiClientConfig
に構造体として定義され、defaultApiClientConfig
にデフォルト値が設定されています。
type ApiClientConfig struct {
TimeoutSecond int
RetryLimit int
DelayBaseSecond int
}
var defaultApiClientConfig = ApiClientConfig{
TimeoutSecond: 5,
RetryLimit: 3,
DelayBaseSecond: 1,
}
初期化の方法は2種類あり、一つはデフォルト値を利用して初期化する方法、もう一つは設定値を指定して初期化する方法になります。
デフォルト値を利用して初期化
apiclient := NewApiClient()
設定値を指定して初期化
apiclient := NewApiClientWithConfig(ApiClientConfig{
TimeoutSecond: 10,
RetryLimit: 5,
DelayBaseSecond: 2,
})
各メソッドの解説
初期化の際ApiClient
を生成しており、APIクライアントの各処理はその構造体をレシーバとしたメソッドに切り出して実装しております。
以下からは、それぞれのメソッドについて解説いたします。
Get メソッド
このメソッドはAPIクライアント利用側の処理から呼び出されるグローバルなメソッドになっており、GETリクエストを行う際に実行されます。
body, err := apiclient.Get("/path", map[string]interface{}{})
Getメソッドでは渡されたパラメータを元にリクエスト情報の生成処理を行っております。
こちらのメソッドでの処理のスコープはあくまでリクエスト情報の生成のみとしており、リクエストの実行など以降の処理はrequestWithRetry
メソッドを呼び出しそちらに処理を移譲しております。
func (c *ApiClient) Get(path string, param map[string]interface{}) ([]byte, error) {
url := "http://" + c.host + ":" + c.port + path
req, _ := http.NewRequest("GET", url, nil)
req.URL.RawQuery = c.buildParam(param)
return c.requestWithRetry(req, c.retryLimit)
}
このような実装にしておくことで、POSTやDELETEリクエストの必要が出てきた際も、リクエスト情報の生成部分を各リクエスト毎実装するだけで、以降の処理は共通のものを利用することができます。
func (c *ApiClient) Post(path string, param map[string]interface{}) ([]byte, error) {
// ・・・
// header情報生成、POSTパラメータ生成 etc.
// ・・・
return c.requestWithRetry(req, c.retryLimit)
}
buildParam メソッド
このメソッドでは、APIへリクエストする際のパラメータの生成を行います。
こちらでメソッドに渡されたパラメータの型をチェックし、キャストしてvaluesにセットするため、呼び出し側ではパラメータの型を意識する必要がありません。
キャストする必要のある型に応じてswitch文にcaseを追加し、処理を実装します。
func (c *ApiClient) buildParam(param map[string]interface{}) string {
values := url.Values{}
for key, value := range param {
switch v := value.(type) {
case string:
values.Add(key, v)
case int:
values.Add(key, strconv.Itoa(v))
}
}
return values.Encode()
}
requestWithRetry メソッド
このメソッドは最大リトライ回数の設定に応じて再帰的にリクエスト処理を実行するためのメソッドです。
実際のリクエスト処理の実行は後述のrequest
メソッドにて行います。
APIレスポンスの結果を受け、shouldRetry
メソッドによりリトライの必要ありと判定された場合に再度自分自身を呼び出し、APIリクエストの実行を行います。
その際、すぐにリクエストは実行せずcalcDelayTime
メソッドにより算出された時間分、処理を待機させます。
func (c *ApiClient) requestWithRetry(req *http.Request, retry int) ([]byte, error) {
body, err := c.request(req)
if c.shouldRetry(err, retry) {
time.Sleep(c.calcDelayTime(retry))
return c.requestWithRetry(req, retry-1)
}
return body, err
}
パラメータで受けているretry
は残りリトライ回数で、再帰的に呼び出す際はこちらのカウントをデクリメントします。
request メソッド
このメソッドでは、渡されたリクエスト情報を元にHTTPリクエストの実行を行います。
返却値はAPIのレスポンスボディとエラー情報を返します。
func (c *ApiClient) request(req *http.Request) ([]byte, error) {
res, err := c.client.Do(req)
if err != nil {
return []byte{}, ApiErrHttpClientError
}
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return []byte{}, err
}
return body, c.getError(res)
}
エラー情報は後述のgetError
メソッドにより生成したもの、もしくはメソッド内の処理の中で発生したエラー情報になり、ハンドリングする必要のあるエラーに関しては、apiclient内で定義したerrorを返却してあげるようにします。
※ApiErrHttpClientError
がそれにあたります
getError メソッド
このメソッドはAPIレスポンスの情報を受け、ステータスコードを元にエラー情報へ変換し返却します。
func (c *ApiClient) getError(res *http.Response) error {
switch res.StatusCode {
case http.StatusBadRequest: // 400
return ApiErrBadRequest
case http.StatusUnauthorized: // 401
return ApiErrUnauthorized
case http.StatusForbidden: // 403
return ApiErrForbidden
case http.StatusNotFound: // 404
return ApiErrNotFound
case http.StatusMethodNotAllowed: // 405
return ApiErrMethodNotAllowed
case http.StatusInternalServerError: // 500
return ApiErrInternalServerError
case http.StatusBadGateway: // 502
return ApiErrBadGateway
case http.StatusServiceUnavailable: // 503
return ApiErrServiceUnavailable
case http.StatusGatewayTimeout: // 504
return ApiErrGatewayTimeout
default:
return nil
}
}
エラーとしてハンドリングする必要のあるステータスは、定義されたerror型の変数を返却し、それ以外は成功としてnilを返却します。
それぞれのerror型の変数はパッケージ内で定義をしております。
var (
ApiErrHttpClientError = errors.New("Http Client Error")
ApiErrBadRequest = errors.New("400:Bad Request")
ApiErrUnauthorized = errors.New("401:Unauthorized")
ApiErrForbidden = errors.New("403:Forbidden")
ApiErrNotFound = errors.New("404:Not Found")
ApiErrMethodNotAllowed = errors.New("405:Method Not Allowed")
ApiErrInternalServerError = errors.New("500:Internal Server Error")
ApiErrBadGateway = errors.New("502:Bad Gateway")
ApiErrServiceUnavailable = errors.New("503:Service Unavailable")
ApiErrGatewayTimeout = errors.New("504:Gateway Timeout")
)
shouldRetry メソッド
このメソッドではリトライ処理を実行する必要があるかどうかの判定を行います。
リトライの判定には、残りリトライ回数とエラー情報を利用し、1回以上リトライ可能で且つ指定したエラーが発生した際にリトライの必要ありと判定します。
func (c *ApiClient) shouldRetry(err error, retry int) bool {
if retry <= 0 {
return false
}
switch err {
case ApiErrBadGateway,
ApiErrServiceUnavailable,
ApiErrGatewayTimeout,
ApiErrHttpClientError:
return true
default:
return false
}
}
エラー情報の判定には、apiclient内で定義したerror型変数を元に行い、今回は以下の場合リトライを行うよう実装しました。
- Bad Gateway(502)
- Service Unavailable(503)
- Gateway Timeout(504)
- Golangのhttp.Clientエラー
こちらは必要に応じてハンドリングするエラーを追加してください。
calcDelayTime メソッド
このメソッドでは次のリクエストまでどれだけ遅延させるかの計算を行っております。
毎回一定時間遅延させるのではなく、リクエスト回数に応じて増分遅延するように時間を算出します。
初期化の際に設定した次回リクエストまでの待機時間(秒) * リトライ回数
を遅延時間として算出し、返却します。
func (c *ApiClient) calcDelayTime(retry int) time.Duration {
return time.Duration(c.delayBaseSecond*(c.retryLimit-retry+1)) * time.Second
}
まとめ
今回リトライ可能なAPIクライアントを実装するにあたって、各メソッドが単一の役割となるよう処理を切り分けて実装していきました。
要件に合わせて、担当するメソッド内の処理を修正してあげればよく、他のメソッドへの影響を抑えた上でカスタマイズしやすい構成になっているかと思います。
また、リトライ処理が不要な場合もrequestWithRetry
メソッドの代わりにrequest
メソッドを直接呼び出していただければ、APIクライアントとしてそのまま実装可能かと思うので、是非参考になればと思います!