« ^ »

GoのHTTPリクエストライブラリRestyを使う

所要時間: 約 5分

GoのHTTPライブラリにRestyというものがある。しばしばこのライブラリを使用する機会があるため、調べたことを備忘録的に記述する。そのため網羅的な内容ではない。

RestyでHTTPリクエスト送信のリトライ処理を行う

何度失敗すれば諦めていいのか、これは人生にとって重要な問題だ。1度の失敗で諦めていいのか、もしくは無限に諦めず挑戦しつづけてもいいのか、これを判断するのはとても難しい。この難しい問題を考えるため、今回はRestyのリトライ処理の実装方法を確認する。

基本的なリトライの仕方は全て https://github.com/go-resty/resty に記載されている。

ダミーサーバーを実装する

まずはリクエストを送信するためのダミーサーバーを実装する。

URL設計

ダミーサーバーはURLによって、それぞれ異なるHTTPレスポンスを返すことにする。検証として使用するための以下のURLを実装する。

  • GET /succes 成功
  • GET /error HTTPレスポンスコードとしてInternal Server Errorを返す
  • GET /success-but-fail HTTPレスポンスコードとしてOKを返すが、 {"status": "failed"} というBODYを返す。

実装

package main

import (
	"log"
	"net/http"
)

func main () {
	http.HandleFunc("/success", func (w http.ResponseWriter, r *http.Request) {
		log.Print("Receive /success")
		w.Header().Set("Content-Type","text/plain")
		w.WriteHeader(http.StatusOK)
		w.Write([]byte(`Success!!`))
	})

	http.HandleFunc("/error", func (w http.ResponseWriter, r *http.Request) {
		log.Print("Receive /error")
		w.Header().Set("Content-Type","text/plain")
		w.WriteHeader(http.StatusInternalServerError)
		w.Write([]byte(`Error!!`))
	})

	http.HandleFunc("/success-but-fail", func (w http.ResponseWriter, r *http.Request) {
		log.Print("Receive /success-but-fail")
		w.Header().Set("Content-Type","application/json")
		w.WriteHeader(http.StatusInternalServerError)
		w.Write([]byte(`{"status": "failed"}`))
	})

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

リクエストが受けとれることを確認する

package main

import (
	"fmt"
	"github.com/go-resty/resty/v2"
)

func main () {
	resp, err := resty.New().R().Get("http://localhost:8000/success")
	if err != nil {
		panic(err)
	}
	fmt.Println(resp)
}

リトライ処理を実装する

HTTPステータスによってリトライする

リトライする条件はAddRetryCondition関数で登録できる。この関数がtrueを返すとリトライが実行される。この実装ではレスポンスのステータスコードが200でなければ、1秒間隔で最大3回のリクエストを送信する。

package main

import (
	"fmt"
	"time"
	"github.com/go-resty/resty/v2"
)

func main () {
	c := resty.New()
	resp, err := c.SetRetryCount(2).
		SetRetryWaitTime(1 * time.Second).
		AddRetryCondition(func (r *resty.Response, err error) bool {
			return r.StatusCode() != 200
		}).
		R().
		Get("http://localhost:8000/error")

	if err != nil {
		panic(err)
	}
	fmt.Println(resp.StatusCode())
}

HTTPレスポンスのBODYによってリトライする

リトライする条件で扱えるものはレスポンスのステータスコードだけではなく、 他のさまざまな値を利用できる。今度はレスポンスボディの値を元にリトライ処理を実行する。

Web APIによっては、そのWeb API呼び出しが成功しているかどうかを示す方法としてHTTPレスポンスのステータスコードを用いることもあれば、ステータスコードは200 OKを返しレスポンスのエラーコードを含むJSONをボディ部として返すということもある。

今回は後者を想定し、レスポンスのボディ部のJSONのstatus属性がfailedであればリクエストの送信をリトライさせる。つまり {"status": "failed"} というJSONが返されたらリトライする。

package main

import (
	"fmt"
	"time"
	"encoding/json"
	"github.com/go-resty/resty/v2"
)

type ResponseBody struct {
	Status string `json:"status,omitempty"`
}

func main () {
	c := resty.New()
	b := &ResponseBody{}
	resp, err := c.SetRetryCount(2).
		SetRetryWaitTime(1 * time.Second).
		AddRetryCondition(func (r *resty.Response, err error) bool {
			json.Unmarshal(r.Body(), b)
			return b.Status == "failed"
		}).
		R().
		Get("http://localhost:8000/success-but-fail")
	if err != nil {
		panic(err)
	}
	fmt.Println(resp.StatusCode())
	fmt.Println(b.Status)
}

Exponential Backoff

よくあるリクエストのリトライ処理のパターンとして Exponential Backoff という方法がある。リトライするたびに次のリクエストを送信するまでの時間を長くするという方法だ。たとえば1回目のリトライを2秒後、2回目のリトライを4秒後、3回目のリトライを8秒後といったように、指数関数的に待ち時間を増やしていく。RestyではデフォルトのリトライのロジックにExponential Backoffアルゴリズムを採用している。

そのためリトライ回数やリトライの最大秒数などの値の設定だけでよい。

package main

import (
	"fmt"
	"time"
	"github.com/go-resty/resty/v2"
)

func main () {
	c := resty.New()
	resp, err := c.SetRetryCount(5).
		SetRetryWaitTime(1 * time.Second).
		SetRetryMaxWaitTime(20 * time.Second).
		AddRetryCondition(func (r *resty.Response, err error) bool {
			return r.StatusCode() != 200
		}).
		R().
		Get("http://localhost:8000/error")

	if err != nil {
		panic(err)
	}
	fmt.Println(resp.StatusCode())
}

Go Modules

go.mod::

module example

go 1.17

require github.com/go-resty/resty/v2 v2.7.0

require golang.org/x/net v0.7.0 // indirect

go.sum::

github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY=
github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

プロキシを中継してRestyでのリクエスト送信を行う

RestyはHTTP_PROXY環境変数やHTTPS_PROXY環境変数が設定されていれば、設定 されているプロキシにリクエストを中継させる。

export HTTP_PROXY=http://localhost:8080
export HTTPS_PROXY=http://localhost:8080
package main

import (
	"fmt"
	"github.com/go-resty/resty/v2"
)

func main() {
	c := resty.New()
	resp, err :=  c.R().Get("https://example.com")
	fmt.Println(err)
	fmt.Println(resp)
}

他にクライアントにSetProxyで設定する方法もある。

https://github.com/go-resty/resty#proxy-settings—client-as-well-as-at-request-level

各種HTTPレスポンスステータス時のRestyの挙動を確認する

RestyでHTTPリクエストを送信した時、レスポンスステータスによってどのような挙動になるのかを確認する。ダミーサーバーにはmitmproxyを使用する。

from mitmproxy import http

def request(flow: http.HTTPFlow) -> None:
    flow.response = http.Response.make(503, content=b"foo")

次のように起動する。

mitmproxy -s _main.py

http.Response.make の第1引数を変更することでステータスを変更する。

クライアントはシンプルなGETリクエストを送信し、そのerrを標準出力に表示する。

**

go run client.go
<nil>
200 SUCCESS
go run client.go
<nil>
201 Created
go run client.go
<nil>
204 No Content
go run client.go
Get "http://127.0.0.1:8080": 301 response missing Location header
301 Moved Permanently
go run client.go
Get "http://127.0.0.1:8080": 302 response missing Location header
302 Found
go run client.go
<nil>
400 Bad Request
go run client.go
<nil>
403 Forbidden
go run client.go
<nil>
404 Not Found
go run client.go
<nil>
500 Internal Server Error
go run client.go
<nil>
502 Bad Gateway
go run client.go
<nil>
503 Service Unavailable

おおまかにHTTPステータスをざっと確認したが、HTTP的な問題が発生しない場合は err には nil が返される。 3xx は非nilが返されているが、 3xx系に必要なレスポンスヘッダーが無いため、HTTPとして不正だから非nilになっているのだろう。

Pythonでは2xx系以外のレスポンスの場合に例外を送出するライブラリやメソッドが用意されていることもあるが、restyの場合はそうではない。そのため次のように愚直に返却されたものをチェックする必要がある。

  1. HTTP的なエラーが発生していないか?
  2. HTTPレスポンスのステータスコードは期待値か?
  3. HTTPレスポンスのヘッダーやボディは期待通りか?

様々なクライアントライブラリを触っていると、この当たりの挙動を思い出せず曖昧なまま実装し、結果として甘いエラー処理になりがちになる。きちんと挙動を調べ、確実な事を積み重ねようと思う。

まとめ

要求に応じて適切なリトライ処理を実装しよう。エラー時の挙動を確認しよう。