curlでリトライとHTTPステータス確認 

シェルスクリプトでリトライとHTTPステータス確認する方法を調査したのでまとめました。

## 最終的なcurlコマンド
```bash
$ curl --version
curl 7.64.1 (x86_64-apple-darwin19.0) libcurl/7.64.1 (SecureTransport) LibreSSL/2.8.3 zlib/1.2.11 nghttp2/1.39.2
Release-Date: 2019-03-27
Protocols: dict file ftp ftps gopher http https imap imaps ldap ldaps pop3 pop3s rtsp smb smbs smtp smtps telnet tftp 
Features: AsynchDNS GSS-API HTTP2 HTTPS-proxy IPv6 Kerberos Largefile libz MultiSSL NTLM NTLM_WB SPNEGO SSL UnixSockets

## GETの場合
curl --silent --retry 3 --include  http://example.com | grep "200 OK"
[ $? -ne 0 ] && (echo "error"; exit 1)

## POSTの場合(JSON)
curl -X POST --silent --retry 3 --include --retry 3 http://example.com --data @- <<EOF | grep "201 Created"
{
  "data": "なんらかのデータ"
}
EOF
[ $? -ne 0 ] && (echo "error"; exit 1)
```

各オプションについて解説します。
curlの動作確認はGoでHTTPサーバを立てて行いました。以降はこのサーバ(localhost:8080)を呼び出す例で説明します。

<details><summary>実験用のHTTPサーバのコード</summary><div>

```go:main.go
package main

import (
	"net/http"
)

func ok(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(200)
	w.Header().Set("Content-Type", "application/json; charset=UTF-8")
	w.Write([]byte("{ \"data\": \"なんらかのデータ\" }\n"))
}

func created(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(201)
}

func errorResponse(w http.ResponseWriter, status int, message string) {
	w.WriteHeader(status)
	w.Header().Set("Content-Type", "application/json; charset=UTF-8")
	w.Write([]byte("{ \"message\": \"" + message + "\" }\n"))
}

func badRequest(w http.ResponseWriter, r *http.Request) {
	errorResponse(w, 400, "なんらかの入力エラー")
}

func internalServerError(w http.ResponseWriter, r *http.Request) {
	errorResponse(w, 500, "なんらかのエラー")
}

var count = 0

// 3回に一回しか成功しない不安定なAPI
func unstable(w http.ResponseWriter, r *http.Request) {
	count++
	if count%3 == 0 {
		w.WriteHeader(201)
	} else {
		errorResponse(w, 500, "なんらかのエラー")
	}
}

func main() {
	http.HandleFunc("/ok", ok)
	http.HandleFunc("/created", created)
	http.HandleFunc("/bad-request", badRequest)
	http.HandleFunc("/internal-server-error", internalServerError)
	http.HandleFunc("/unstable", unstable)
	http.ListenAndServe(":8080", nil)
}
```

</div></details>


## --silent オプション
curlのプログレスバーの出力を抑制するオプション。シェルスクリプトの場合とりえあえず指定する

## --retry n オプション
nで指定した回数だけリトライするオプション。以下のケースでリトライを行ってくれる。

```bash
# curlコマンド自体がエラーとなるケース（存在しないホスト名指定）
$ curl --retry 3 http://xxxxxxxxx
Warning: Transient problem: timeout Will retry in 1 seconds. 3 retries left.
Warning: Transient problem: timeout Will retry in 2 seconds. 2 retries left.
Warning: Transient problem: timeout Will retry in 4 seconds. 1 retries left.
curl: (6) Could not resolve host: xxxxxxxxx

# HTTPステータス5XXが返却されるケース
$ curl --retry 3 http://localhost:8080/internal-server-error
Warning: Transient problem: HTTP error Will retry in 1 seconds. 3 retries 
Warning: left.
Warning: Transient problem: HTTP error Will retry in 2 seconds. 2 retries 
Warning: left.
Warning: Transient problem: HTTP error Will retry in 4 seconds. 1 retries 
Warning: left.
{ "message": "なんらかのエラー" }{ "message": "なんらかのエラー" }{ "message": "なんらかのエラー" }{ "message": "なんらかのエラー" }
```
--retry 3 を指定した場合、最初の1回と合わせて1+3=4回実行される。また、失敗するごとに1,2,4秒後と時間をあけてリトライしてくれる。

成功した場合やHTTPステータスが4XX系のエラーの場合はリトライせずに一回で終了する。 

```bash
$ curl --retry 3 http://localhost:8080/ok
{ "data": "なんらかのデータ" }
$ curl --retry 3 http://localhost:8080/bad-request
{ "message": "なんらかの入力エラー" }
```
4XX系のエラーが発生するケースはクライアント側の操作に問題があり、リトライしても成功する可能性が低いため概ね期待通りの挙動。

## --include オプションと grep "OK 200"
--includeオプションはHTTPヘッダーを出力してくれるオプション。
grepコマンドは検索文字列がヒットした場合は戻り値($?)が0,ヒットしなかった場合は戻り値1となるため、組み合わせることで目当てのHTTPステータスが返却されたかチェックすることができる。

```bash
$ curl --silent --include http://localhost:8080/ok
HTTP/1.1 200 OK
Date: Thu, 16 Jul 2020 14:05:41 GMT
Content-Length: 38
Content-Type: text/plain; charset=utf-8

{ "data": "なんらかのデータ" }
$ curl --silent --include http://localhost:8080/ok | grep "200 OK"
HTTP/1.1 200 OK
$ echo $?
0

# 500エラーを返すAPIの場合
$ curl --silent --include http://localhost:8080/internal-server-error | grep "200 OK"
$ echo $?
1
```

grepは単なる文字列検索をしているだけなので、5XX系エラーだけどHTTPボディの中に"200 OK"が含まれるみたいなケースでは正しく判定できないので注意が必要です。200 or 201の場合、成功のようなケースではegrepを使って複数文字列指定すれば実現できます。目当てのHTTPステータスに対応する文字列は[MDN](https://developer.mozilla.org/ja/docs/Web/HTTP/Status)などで確認してください。

## オプションを組み合わせて使用する
これまでに説明したオプションを組み合わせると冒頭で紹介したcurlコマンドとなる。

```bash
# 3回に1回成功するAPI
$ curl --retry 3 --include http://localhost:8080/unstable
..略..
{ "message": "なんらかのエラー" }
Warning: Transient problem: HTTP error Will retry in 1 seconds. 3 retries 
..略..
{ "message": "なんらかのエラー" }
Warning: Transient problem: HTTP error Will retry in 2 seconds. 2 retries 
Warning: left.
HTTP/1.1 201 Created
Content-Length: 0

# オプションを組み合わせて使用する
$ curl --silent --retry 3 --include http://localhost:8080/unstable | grep "201 Created"
HTTP/1.1 201 Created
# 結果確認 -> OK
$ echo $?
0

# 成功しないAPIの場合は当然成功にならない
$ curl --silent --retry 3 --include http://localhost:8080/internal-server-error | grep "201 Created"
HTTP/1.1 201 Created
# 結果確認 -> NG
$ echo $?
1
```


## 最後に
curlのオプションを組み合わせれば、リトライして〜、失敗したらエラー通知して〜のようなよくあるスクリプトの要件はなんとか満たせそうです。
ただ、このくらいのオプション数でもコマンドが長くなってしまい可読性が低いです。それぞれのオプションには1文字の短いオプションも用意されていますが、それはそれで可読性と検索性が下がってしまうのが悩ましいですね。
複雑になるケースではシェルスクリプトをあきらめてGoやPythonで書くほうが楽かも知れません。