2021-12-08

golang: 一時的/恒久的なエラーの隙間をハンドリングする

これはGoアドベントカレンダー3の8日目の記事です。

外部のAPIと連携して仕事をするサービスを長年運用・開発しています。 エラーが発生しある仕事が完了できないことがあり、その場合はエラーレポートを見て手動対応します。 なるべく運用負荷を減らすため、エラーの自動対応を進めていく過程で見つけた方針・実装テクがあるので紹介します。

ポイント

  • 一時的なエラー、恒久的なエラーのあいだにはグラデーションがある
  • エラー自身にエラー対応をさせると複雑さが減る


エラーのグラデーション

エラーハンドリングの記事を探すと、func Temporary(err) bool で一時的なエラーか判別してリトライをかますのをよく見ます。 一時的なエラーとは例えばネットワークのTimeoutなど。時間をおけば自動的に回復ししそうなもの。 それ以外はぜんぶ恒久的(Permanent)なエラーとしてレポートを上げてしまう。

実際に運用していると上がってきたエラーにはなにか対応が必要なので手を取られてしまいます。 可能ならば自動対応してエラーレポートしないようにしたい。

運用をつづけていて一時的なエラーと恒久的なエラーのあいだに次のようなエラーの種類があることに気づきました。

  • そのままではリトライしても成功しないけれど、何かの操作をすれば成功する状態に持っていける回復可能なエラー
  • どうやってもリトライ成功にはできないけど、失敗した状況をマシにしたり詳細なレポートを作れるフォロー可能なエラー

単に一時的なエラーかそうでないかだけでなく、これらを区別してハンドリングすることで成功率を上げて手動対応を減らすことを目指します。

区分 自動対応 レポート 説明
一時的 不要
回復可能 不要 なにか操作することで回復可能
フォロー可能 不可 必要 回復不能だがより良い状態に出来る
恒久的 不可 必要



エラーハンドリングを丁寧にやるとごちゃごちゃになる

なんらかの仕事をする関数があり、そのなかで複数のAPIにリクエストを投げるとします。 この仕事はサービスにとってコアなもので毎日大量に発生しているので、一度の実行でなるべく成功してほしいです。 なお説明の簡単のため FooBarWork() は冪等性があるとおもってください(なので何度リトライしても結果は変わらない)。

func FooBarWork(params api.Params) error {
	result, err := api.RequestFoo(params)
	if err != nil {
		return fmt.Errorf("api.RequestFoo() failed: %w", err)
	}

	params2 := calcSomething(result)

	_, err := api.RequestBar(params2)
	if err != nil {
		return fmt.Errorf("api.RequestBar() failed: %w", err)
	}

	return nil
}

運用していくあいだに api.RequestFoo(), api.RequestBar() がタイムアウトした場合は0.5秒おいてリトライすると成功する確率が高いとわかりました。 なので次のようにエラーハンドリング部分を書き直します。

func FooBarWork(params api.Params) error {
	result, err := api.RequestFoo(params)
	if errors.Is(err, api.ErrTimeout) {
		time.Sleep(time.Second/2) // 0.5秒置いてリトライ
		result, err = api.RequestFoo(params)
		if err != nil {
			return fmt.Errorf("api.RequestBar() failed: %w", err)
		}
	} else if err != nil {
		return fmt.Errorf("api.RequestFoo() failed: %w", err)
	}

	params2 := calcSomething(result)

	_, err := api.RequestBar(params2)
	if errors.Is(err, api.ErrTimeout) {
		time.Sleep(time.Second/2) // 0.5秒置いてリトライ
		_, err = api.RequestBar(params)
		if err != nil {
			return fmt.Errorf("api.RequestBar() failed: %w", err)
		}
	} else if err != nil {
		return fmt.Errorf("api.RequestBar() failed: %w", err)
	}

	return nil
}

さらに api.RequestBar()ErrHogeHoge を返す場合は api.RequestHoge() を叩くと回復できるとわかりました。 書き直しましょう。

func FooBarWork(params api.Params) error {
	result, err := api.RequestFoo(params)
	if errors.Is(err, api.ErrTimeout) {
		time.Sleep(time.Second/2) // 0.5秒置いてリトライ
		result, err = api.RequestFoo(params)
		if err != nil {
			return fmt.Errorf("api.RequestBar() failed: %w", err)
		}
	} else if err != nil {
		return fmt.Errorf("api.RequestFoo() failed: %w", err)
	}

	params2 := calcSomething(result)

	_, err := api.RequestBar(params2)
	if errors.Is(err, api.ErrTimeout) {
		time.Sleep(time.Second/2) // 0.5秒置いてリトライ
		_, err = api.RequestBar(params)
		if err != nil {
			return fmt.Errorf("api.RequestBar() failed: %w", err)
		}
	} else if errors.Is(err, api.ErrHogeHoge) {
		if hogeErr := api.RequestHoge(); hogeErr != nil {
			return fmt.Errorf("api.RequestHoge() failed: err=%w, hogeErr=%v", err, hogeErr)
		}
		_, err = api.RequestBar(params)
		if err != nil {
			return fmt.Errorf("api.RequestBar() failed: %w", err)
		}
	} else if err != nil {
		return fmt.Errorf("api.RequestBar() failed: %w", err)
	}

	return nil
}

よかった! これで運用しやすくなりました。 でも最初のコードと比べると…

  • エラーハンドリングが異様にごちゃごちゃしている
  • 分岐が増えたのでテストコードもゴッチャゴチャになっている(はず)
  • 時間にシビアな状況の場合、勝手に0.5秒待たれるのは困る

などあり、面倒な事態になっています。

コードのシンプルさを保ったまま、同じことをさせるにはどうしたらいいでしょう?

エラー自身に対応させる

まずエラーハンドリングのifのなかで何かの処理をするのをやめます。リトライなどは呼び出し側にやらせます。 これによりコードとテストのごちゃごちゃが軽減されます。

func XXXHandler(params api.Params) {
	err := FooBarWork(params)
	if errors.Is(err, api.ErrTimeout) {
		log.Warnf("FooBarWork() failed: %v", err)
		time.Sleep(time.Second/2)
		err := FooBarWork(params)
		if err != nil {
			log.Errorf("Retry FooBarWork() failed: %v", err)
		}
	} else if errors.Is(err, api.ErrHogeHoge) {
		log.Warnf("FooBarWork() failed: %v", err)
		if err := api.RequestHoge(); err != nil {
			log.Errorf("api.RequestHoge() failed: %v", err)
		} else {
			err := FooBarWork(params)
			if err != nil {
				log.Errorf("Retry FooBarWork() failed: %v", err)
			}
		}
	} else if err != nil {
		log.Errorf("FooBarWork() failed: %v", err)
	}
}

func FooBarWork(params api.Params) error {
	result, err := api.RequestFoo(params)
	if err != nil {
		return fmt.Errorf("api.RequestFoo() failed: %w", err)
	}

	params2 := calcSomething(result)

	_, err := api.RequestBar(params2)
	if err != nil {
		return fmt.Errorf("api.RequestBar() failed: %w", err)
	}

	return nil
}

FooBarWork() はシンプルになりました。このままだと呼び出し側にゴチャゴチャを移動しただけなのでもうひと工夫します。

エラーには一時的/回復可能/フォロー可能/恒久的なエラーがあると書いたのを思い出してください。 この分類に沿って振る舞いによるエラーハンドリングができるようにしてみましょう。 つぎのようなinterfaceを定義してみます。

// これは api.ErrTimeout で実装済みとする
type Temporary interface {
	Temporary() bool
}

// 回復可能
type Recoverer interface {
	Recover() error
}

RequestBar() のエラーのラッパーをカスタムエラーとして作ります。 このカスタムエラー自身に回復処理のメソッドを生やしてしまいます1

// RequestBar() のエラーを回復するようのWrapper
type HogeErrorWrapper struct {
	Err error
}

func (e *HogeErrorWrapper) Error() string { return e.Err.Error() }
func (e *HogeErrorWrapper) Unwrap() error { return e.Err }
func (e *HogeErrorWrapper) Recover() error {
	if err := api.RequestHoge(); err != nil {
		return fmt.Errorf("api.RequestHoge() failed: %w", err)
	}
	return nil
}

これを使い書き換えれば、呼び出し側はエラー対応を詳細を知らなくてOKになります。

func XXXHandler(params api.Params) {
	err := FooBarWork(params)
	var temporary Temporary
	var recoverer Recoverer
	if errors.As(err, &temporary) && temporary.Temporary() {
		log.Warnf("FooBarWork() failed: %v", err)
		time.Sleep(time.Second/2)
		err := FooBarWork(params)
		if err != nil {
			log.Errorf("Retry FooBarWork() failed: %v", err)
		}
	} else if errors.As(err, &recoverer) {
		log.Warnf("FooBarWork() failed: %v", err)
		if rerr := recoverer.Recover(); rerr != nil {
			log.Errorf("FooBarWork() recover failed: %v", err)
		} else {
			err := FooBarWork(params)
			if err != nil {
				log.Errorf("Retry FooBarWork() failed: %v", err)
			}
		}
	} else if err != nil {
		log.Errorf("FooBarWork() failed: %v", err)
	}
}

func FooBarWork(params api.Params) error {
	result, err := api.RequestFoo(params)
	if errors.Is(err, api.ErrHogeHoge) {
		return &HogeErrorWrapper{Err:err}
	} else if err != nil {
		return fmt.Errorf("api.RequestFoo() failed: %w", err)
	}

	params2 := calcSomething(result)

	_, err := api.RequestBar(params2)
	if errors.Is(err, api.ErrHogeHoge) {
		return &HogeErrorWrapper{Err:err}
	} else if err != nil {
		return fmt.Errorf("api.RequestBar() failed: %w", err)
	}

	return nil
}

FooBarWork() 側はエラーの種類に応じてカスタムエラーでWrapするようにします。テストコードはエラーが起こる状況で特定のカスタムエラーが返っているかだけチェックすれば良くなりました。

フォロー可能エラー

コード上では回復可能エラーとほぼ同じなので例示はしません。 回復可能エラーと違ってリトライしても成功しないものなのでエラーレポートが必須です。

type FollowUpper interface {
	FollowUp() string
}

FollowUp() はエラーレポートの補足情報を返します。エラー発生時点での params などを整形したり、対応に必要な情報をDBから引っ張ってきたりします。 ログ以外にエラーレポートシステム2を使っているところは多いと思いますが、そういうところで見やすく便利な情報があると運用が楽になります。

そのほかにもリソースを確保するようなAPIの場合に指定したすべてのリソースだと失敗するからparamsを編集してリトライなどする場合もあります(全部は確保できてないからその旨レポート必要)。 回復可能エラーより使用頻度は高いかもしれません。

カスタムエラーラッパーの利点

ざっと見ただけだと大してシンプルになっていないように思えるかもしれません。 しかし最初の方法で既存のコードにエラー自動対応を埋め込んでいくことを続けていると、すぐに手に負えないレベルの複雑さになってしまいます。 運用の負荷を下げたい、コードの複雑さも抑えたいと考えるとこの方法がバランス取れています。

  • 回復作業自体は関数のなかではやらないのでテストコードがゴチャゴチャしない。特定のカスタムエラーが返っているかチェックするだけで良い。
    • なので後付で自動対応を追加するときもテストコードの複雑さに怯まず実装できる(開発者として心休まる大事ポイント)
  • なにか一つの原因で複数のエラーが起こっている場合がよくある。回復可能エラーを別に作っておくことで再利用できる。
  • 回復に必要な情報を関数の中から外へ受け渡せるのでエラーレポートから対応へスムーズに受け渡せる

呼び出し側がゴチャっているように見えますが、振る舞いによるエラーハンドリングを行っているので共通化できます。 ここではやってませんが backoff のような仕組みで簡単化できるでしょう。

まとめ

エラーには一時的/回復可能/フォロー可能/恒久的などの種類があり、それぞれ対応することで成功率を上げられます。 その際の実装の複雑さはエラー自身に対応メソッドを生やすのと振る舞いによるエラーハンドリングで下げられます。 サービス運用で現実の複雑さと戦っているひとは、このような方針を取り入れてみると楽になるかもしれません。

おわり。


  1. エラーにエラー対応のメソッドを生やせばいいと気づいたのは私にとって目からウロコでした。Errors are valuesは偉大。  ↩

  2. 私の関わっているシステムではエラーが発生するとTrelloにカードが作られる仕組みがあります。そのまま対応者アサインなどに繋げられるのでオススメ。  ↩

0 件のコメント: