Golangでリトライ可能なAPIクライアントを実装する

GolangにてAPIをリクエストした際、エラーとなって返却された際に間隔を空けてリトライ処理を行うようなAPIクライアントを実装しました。

はじめにAPIクライアントのコード全体を公開し、各処理毎の解説を後述します。

また、今回のAPIクライアントは```Go 1.10.3```で実装しております。

```golang:apiclient.go
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```にデフォルト値が設定されています。

```golang
type ApiClientConfig struct {
    TimeoutSecond   int
    RetryLimit      int
    DelayBaseSecond int
}

var defaultApiClientConfig = ApiClientConfig{
    TimeoutSecond:   5,
    RetryLimit:      3,
    DelayBaseSecond: 1,
}
```

初期化の方法は2種類あり、一つはデフォルト値を利用して初期化する方法、もう一つは設定値を指定して初期化する方法になります。

**デフォルト値を利用して初期化**

```golang
apiclient := NewApiClient()
```

**設定値を指定して初期化**

```golang
apiclient := NewApiClientWithConfig(ApiClientConfig{
    TimeoutSecond:   10,
    RetryLimit:      5,
    DelayBaseSecond: 2,
})
```

# 各メソッドの解説
初期化の際```ApiClient```を生成しており、APIクライアントの各処理はその構造体をレシーバとしたメソッドに切り出して実装しております。
以下からは、それぞれのメソッドについて解説いたします。

## Get メソッド
このメソッドはAPIクライアント利用側の処理から呼び出されるグローバルなメソッドになっており、GETリクエストを行う際に実行されます。

```golang
body, err := apiclient.Get("/path", map[string]interface{}{})
```

Getメソッドでは渡されたパラメータを元にリクエスト情報の生成処理を行っております。
こちらのメソッドでの処理のスコープはあくまでリクエスト情報の生成のみとしており、リクエストの実行など以降の処理は```requestWithRetry```メソッドを呼び出しそちらに処理を移譲しております。

```golang
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リクエストの必要が出てきた際も、リクエスト情報の生成部分を各リクエスト毎実装するだけで、以降の処理は共通のものを利用することができます。

```golang
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を追加し、処理を実装します。

```golang
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```メソッドにより算出された時間分、処理を待機させます。

```golang
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のレスポンスボディとエラー情報を返します。

```golang
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レスポンスの情報を受け、ステータスコードを元にエラー情報へ変換し返却します。

```golang
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型の変数はパッケージ内で定義をしております。

```golang
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回以上リトライ可能で且つ指定したエラーが発生した際にリトライの必要ありと判定します。

```golang
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 メソッド
このメソッドでは次のリクエストまでどれだけ遅延させるかの計算を行っております。
毎回一定時間遅延させるのではなく、リクエスト回数に応じて増分遅延するように時間を算出します。

初期化の際に設定した```次回リクエストまでの待機時間（秒） * リトライ回数```を遅延時間として算出し、返却します。

```golang
func (c *ApiClient) calcDelayTime(retry int) time.Duration {
    return time.Duration(c.delayBaseSecond*(c.retryLimit-retry+1)) * time.Second
}
```

# まとめ
今回リトライ可能なAPIクライアントを実装するにあたって、各メソッドが単一の役割となるよう処理を切り分けて実装していきました。

要件に合わせて、担当するメソッド内の処理を修正してあげればよく、他のメソッドへの影響を抑えた上でカスタマイズしやすい構成になっているかと思います。

また、リトライ処理が不要な場合も```requestWithRetry```メソッドの代わりに```request```メソッドを直接呼び出していただければ、APIクライアントとしてそのまま実装可能かと思うので、是非参考になればと思います！