Plan 9とGo言語のブログ

主にPlan 9やGo言語の日々気づいたことを書きます。

Google Cloud Client Library for Goでのリトライとエラー

Google Cloud Client Library for Goはデフォルトでリトライするので、あまり意識する必要はないと思いますが、場合によってはリトライを細かく制御したくなることはあるかもしれません。この記事では、リトライのために必要そうなオプションをまとめました。

リトライ

クライアントライブラリで実装された一部のAPI、例えばmonitoring.MetricClient.ListTimeSeriesなど、可変長引数でgax.CallOptionを取るものがあります。

import (
    "context"

    gax "github.com/googleapis/gax-go/v2"
    monitoringpb "google.golang.org/genproto/googleapis/monitoring/v3"
)

func (c *MetricClient) ListTimeSeries(ctx context.Context, req *monitoringpb.ListTimeSeriesRequest, opts ...gax.CallOption) *TimeSeriesIterator

これらの関数はgax.CallOptionとしてリトライオプションを持っていて、gax.WithRetryRetryerを渡すことでオプションを生成します。Functional Optionパターンな実装になっていますね。

package gax

import "time"

type CallSettings struct {
    // Retry returns a Retryer to be used to control retry logic of a method call.
    // If Retry is nil or the returned Retryer is nil, the call will not be retried.
    Retry func() Retryer

    // CallOptions to be forwarded to GRPC.
    GRPC []grpc.CallOption
}

type CallOption interface {
    Resolve(cs *CallSettings)
}

type Retryer interface {
    Retry(err error) (pause time.Duration, shouldRetry bool)
}

func WithRetry(fn func() Retryer) CallOption

Retryerを自分で全部実装するのは意外と面倒なんですが、指数バックオフを行うRetryerはクライアントライブラリが用意してくれているので、これを使うといいでしょう。

import (
    "time"

    gax "github.com/googleapis/gax-go/v2"
    "google.golang.org/grpc/codes"
)

var RetryableCodes = []codes.Code{
    codes.Canceled,
    codes.Unknown,
    codes.DeadlineExceeded,
    codes.ResourceExhausted,
    codes.Aborted,
    codes.Internal,
    codes.Unavailable,
    codes.DataLoss,
}

func DefaultRetryOption() gax.Retryer {
    // This configuration performs to retry 3 times; 200ms, 400ms, 800ms
    return gax.OnCodes(RetryableCodes, gax.Backoff{
        Initial:    200 * time.Millisecond,
        Max:        1 * time.Second,
        Multiplier: 2.0,
    })
}

c.ListTimeSeries(ctx, &monitoringpb.ListTimeSeriesRequest{...}, gax.WithRetry(DefaultRetryOption))

ところで、上に引用したCallSettingsのコメントでは、Retrynilの場合はリトライしないと書いていますが、monitoring.MetricClientなどクライアントライブラリで実装されたクライアントは、デフォルトでリトライオプションを持っているので、デフォルトで支障がなければオプションを渡す必要はありません。具体的には、上で例に挙げたListTimeSeriesのデフォルトは以下のような内容です。

import (
    "time"

    gax "github.com/googleapis/gax-go/v2"
    "google.golang.org/grpc/codes"
)

gax.WithRetry(func() gax.Retryer {
    return gax.OnCodes([]codes.Code{
        codes.DeadlineExceeded,
        codes.Unavailable,
    }, gax.Backoff{
        Initial:    100 * time.Millisecond,
        Max:        30000 * time.Millisecond,
        Multiplier: 1.30,
    })
})

このデフォルト値は、それぞれのクライアント構造体メンバー変数に持っています。以下はmonitoring.MetricClientの例ですが、他のクライアントもだいたい同じ作りになっているようにみえます。

type MetricClient struct {
    CallOptions *MetricCallOptions
}

type MetricCallOptions struct {
    ListMonitoredResourceDescriptors []gax.CallOption
    GetMonitoredResourceDescriptor   []gax.CallOption
    ListMetricDescriptors            []gax.CallOption
    GetMetricDescriptor              []gax.CallOption
    CreateMetricDescriptor           []gax.CallOption
    DeleteMetricDescriptor           []gax.CallOption
    ListTimeSeries                   []gax.CallOption
    CreateTimeSeries                 []gax.CallOption
}

エラーの詳細を取得する

Retryerでリトライしても解決しない場合、エラーの内容によって、メッセージキューに戻すか破棄するか、など判断したいことがあるかもしれません。その場合、クライアントライブラリはほとんどの場合にgRPCのエラーを返すので、status.FromErrorなどを使うとgRPCのステータスコードなど詳細を調べられます。

import "google.golang.org/grpc/status"

s, ok := status.FromError(err) // status.Convertでも良い
if !ok {
    return
}
code := s.Code()

ただし、アカウントの認証に失敗したとか、APIが無効になっているなどgRPCへ到達する前のエラーが発生することもありますが、こういったエラーではstatus.FromErrorを使っても詳細を取得できません。その場合はgoogleapi.Errorを使うと、HTTP/1.1ステータスコードなどの詳細な情報を確認できます。

import (
    "errors"

    "google.golang.org/api/googleapi"
)

var e *googleapi.Error
if errors.As(err, &e) {
    return e.Code
}

codes.Codeのコメントによると、gRPCのエラーコードはHTTP/1.1のステータスコードに置き換え可能なので、まとめて扱えるようにしておくと便利かもしれませんね。

import (
    "errors"
    "net/http"

    "google.golang.org/api/googleapi"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

// https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto
var codeMappings = map[codes.Code]int{
    codes.OK:                 http.StatusOK,
    codes.Canceled:           499, // Go 1.16のnet/httpには定数がない
    codes.Unknown:            http.StatusInternalServerError,
    codes.InvalidArgument:    http.StatusBadRequest,
    codes.DeadlineExceeded:   http.StatusGatewayTimeout,
    codes.NotFound:           http.StatusNotFound,
    codes.AlreadyExists:      http.StatusConflict,
    codes.PermissionDenied:   http.StatusForbidden,
    codes.ResourceExhausted:  http.StatusTooManyRequests,
    codes.FailedPrecondition: http.StatusBadRequest,
    codes.Aborted:            http.StatusConflict,
    codes.OutOfRange:         http.StatusBadRequest,
    codes.Unimplemented:      http.StatusNotImplemented,
    codes.Internal:           http.StatusInternalServerError,
    codes.Unavailable:        http.StatusServiceUnavailable,
    codes.DataLoss:           http.StatusInternalServerError,
    codes.Unauthenticated:    http.StatusUnauthorized,
}

// Code returns HTTP/1.1 Status Code.
func Code(err error) int {
    if err == nil {
        return http.StatusOK
    }
    if v := errors.Unwrap(err); v != nil {
        err = v
    }
    var e *googleapi.Error
    if errors.As(err, &e) {
        return e.Code
    }
    s, ok := status.FromError(err)
    if !ok {
        return http.StatusInternalServerError
    }
    c, ok := codeMappings[s.Code()]
    if !ok {
        return http.StatusInternalServerError
    }
    return c
}

// IsPermissionError returns true if err is an error categorised of permission denied.
func IsPermissionError(err error) bool {
    switch Code(err) {
    case http.StatusUnauthorized, http.StatusForbidden:
        return true
    default:
        return false
    }
}

他にも何か気づいたら追記します。おわり。