2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

マイクロサービスでも使えそうなエラー処理を考えてみた

Posted at

元ネタ

考えてみたといっても全くのオリジナルではなくて、GoogleさんのDeveloper PageのError Handlingみてたらこれ応用したら汎用的に使えそうだなという感じでつくってみました。

元ネタは以下のようなものです。

Pattern.1 Cloud API

ref. https://cloud.google.com/apis/design/errors

jsonだとこのようになります。

codeにHTTP Status
statusにgRPCのステータス
messageにエラーの内容
詳細は配列で複数記載できる様になっています

{
  "error": {
    "code": 401,
    "message": "Request had invalid credentials.",
    "status": "UNAUTHENTICATED",
    "details": [{
      "@type": "type.googleapis.com/google.rpc.RetryInfo",
      ...
    }]
  }
}

Protobuf だとこのように定義されてました

// The error schema for Google REST APIs. NOTE: this schema is not used for
// other wire protocols.
message Error {
  // This message has the same semantics as `google.rpc.Status`. It has an extra
  // field `status` for backward compatibility with Google API Client Library.
  message Status {
    // This corresponds to `google.rpc.Status.code`.
    int32 code = 1;
    // This corresponds to `google.rpc.Status.message`.
    string message = 2;
    // This is the enum version for `google.rpc.Status.code`.
    google.rpc.Code status = 4;
    // This corresponds to `google.rpc.Status.details`.
    repeated google.protobuf.Any details = 5;
  }
  // The actual error payload. The nested message structure is for backward
  // compatibility with Google API client libraries. It also makes the error
  // more readable to developers.
  Status error = 1;
}

Pattern.2 Calender API

ref. https://developers.google.com/calendar/v3/errors

多少の際はあるものの、考え方は同じなようです。
レスポンスがJSONになる前提なためかstatusはありません。

{
 "error": {
  "errors": [
   {
    "domain": "calendar",
    "reason": "fullSyncRequired",
    "message": "Sync token is no longer valid, a full sync is required.",
    "locationType": "parameter",
    "location": "syncToken",
    }
  ],
  "code": 410,
  "message": "Sync token is no longer valid, a full sync is required."
 }
}

コンセプト

jsonのレスポンス

基本形(単一サービスの場合)

元ネタの形とほとんど同じになります。 errorsの配列の中身と、他のエラー内容はほぼ一致してます。

{
 "error": {
  "errors": [
   {
    "domain": "サービスの識別名",
    "reason": "エラー原因の区分",
    "message": "エラーの説明"
   }
  ],
  "code": "HTTPステータス",
  "status": "gRPCのステータス",
  "message": "エラーの説明"
 }
}

複数のサービスを経由した場合

(ユーザのリクエスト) <-> サービスA <-> サービスB <-> サービスC という経路でリクエストがあった場合に、サービスBでエラーが発生したためにエラーを返すケースを考えてみます。

code、status、messageはほぼ同じですが、errrorsが異なります。
errorsはstack traceのような扱いで、サービスをまたいでエラーを追いかけられるようにしてみました。

{
 "error": {
  "errors": [
   {
    "domain": "サービスAの識別名",
    "reason": "サービスAのエラー原因の区分",
    "message": "サービスAのエラーの説明(サービスBでエラーが発生したためにサービスAで発生したエラー)"
   },
   {
    "domain": "サービスBの識別名",
    "reason": "サービスBのエラー原因の区分",
    "message": "サービスBのエラーの説明"
   }
  ],
  "code": "最終的にユーザに返却するHTTPステータス",
  "status": "最終的にユーザに返却するgRPCのステータス",
  "message": "サービスAのエラーの説明"
 }
}

エラーレスポンスをエラーとして扱う

上記のエラーレスポンスの受け渡しだけだと、このレスポンスを格納した変数やstructureをどこかに保存しておいて、受け渡しのたびにこれを使うことになります。
Goなどの言語ではエラー用のメソッドをはやしてあげることで、エラー扱いができます。
これによってエラーレスポンスをエラーとして引き回して最終的にJSONレスポンスとしてユーザに返却するだけで済みます。

実装

共通処理

Struct

メイン部分の構造体はこのようになります。
jsonレスポンスの一番外にあるerrorは普段あると不便なので、外しています。
こちらに関してはjsonのところで説明します。

ポイントとしてはCause.messageが、error型になっていることです。
ただのstringだと文字列で書かれている情報のみになってしまいますが、errorだと文字列以外の情報(エラーの発生場所など)も持てるのでこちらのほうが利点があります。

それに対してDetail.Messageは通常のstringです。
これは、サービス感で情報の受け渡しがあったときに、ここをerrorにしていたとしても文字列以外の情報はjsonレスポンスにした際に欠損してしまうので、errorにしていたところであまり利点がなく、逆に扱いづらくなってしまうためです。

// Cause ...
type Cause struct {
    code    int
    status  string
    message error
    details []Detail
}

// Detail ...
type Detail struct {
    Domain  string
    Reason  string
    Message string
}

エラー処理

先程の構造体をエラーとして取り扱えるようにしてあげます。
Goの場合はダックタイピングで func Error() string をはやしてあげるとエラー扱いになるので、それを用いてエラーにします。
Go 1.13からfmt.Errorf%wを使うことでエラーをWrapできるようになったのでこの機能を用いてエラー原因とメッセージを結合して出力してあげるようにしています

// Error return error message
func (c *Cause) Error() string {
    reason := ""
    if len(c.details) > 0 {
        reason = c.details[0].Reason
    }
    return fmt.Errorf("%s : %w", reason, c.message).Error()
}

ただそれだけだと扱いづらいので、Goの1.13から標準実装されたUnwrapも使用してmessageをそのまま出力してあげるものも作りました

// Unwrap implements errors.Unwrap method
func (c *Cause) Unwrap() error {
    return c.message
}

json

jsonレスポンスの受け渡しの説明です。

まずはjsonレスポンス用の構造体を作ってあげます。
外枠をjsonResponseで定義して、Causeに当たる部分をjsonResponseElement
Causeをそのまま使っていないのは、errorとかjsonで扱いづらいところがあるためで、jsonの受け渡し専用なので、扱いやすいように型などを一部変更しています。
最初Marshal/Unmarshal内で無名Structとして作っていたのですが、扱いづらかったので、名前をつけてあげました。

これで、jsonに変換したときに、コンセプトのときの形ができあがります。

// jsonResponse ...
type jsonResponse struct {
	Error jsonResponseElement `json:"error"`
}

// jsonResponseElement
type jsonResponseElement struct {
	Code    int      `json:"code"`
	Status  string   `json:"status"`
	Message string   `json:"message"`
	Details []Detail `json:"details"`
}

Marshalerの処理はとてもかんたんです。
Causeの内容をjsonResonseに置き換えてからjson.Marshalをかけてあげるだけです。

// MarshalJSON implements the json.Marshaler interface
func (c *Cause) MarshalJSON() ([]byte, error) {
    return json.Marshal(jsonResponse{
        Error: jsonResponseElement{
            Code:    c.code,
            Status:  c.status,
            Message: c.message.Error(),
            Details: c.details,
        },
    })
}

Unmarshalerはまず、レスポンスを格納した、jsonResponseを*Causeに変換してあげます。
その後、Appendでエラーをくっつけてあげます。
Appendは*Cause.detailsを参照して、呼び出し元のdetailsにappendするように作っています。

// UnmarshalJSON implements the json.Unmarshaler interface
func (c *Cause) UnmarshalJSON(b []byte) (err error) {
    var je jsonResponse
    if err = json.Unmarshal(b, &je); err != nil {
        return
    }
    ce := &Cause{
        code:    je.Error.Code,
        status:  je.Error.Status,
        message: errors.New(je.Error.Message),
        details: je.Error.Details,
    }
    c.Append(ce)
    return nil
}

一般的にAppendはjsonレスポンス経由で使われますが、手動でエラーをくっつけてあげることもできます。
その場合、エラーは必ずしも*Causeであるとは限りません。
なので、その場合は、デフォルトでBackend Error (Internal Server Error)にerrorを格納したDetailを生成してからdetailsにappendするようにしました。

この場合、引数となるerrorはdomainを知りません。
そのため、ServiceDomainというグローバル変数を用意しています。
これによってDomainを補完して設定することができます。

// Append one or more elements onto the end of details
func (c *Cause) Append(e error) {
    if c == nil || c.IsZero() {
        c.set(e)
        return
    }

    if v, ok := e.(*Cause); ok {
        c.details = append(c.details, v.details...)
        return
    }
    c.details = append(c.details, Detail{
        Domain:  ServiceDomain,
        Reason:  StatusBackendError,
        Message: e.Error(),
    })
}

Causeエラーの生成

Causeエラーを新規作成する処理を考えてみます。

func New(err error, domain string, code int, status string, reason string) error {
    return &Cause{
        code:    code,
        status:  status,
        message: err,
        details: []Detail{
            {
                Domain:  domain,
                Reason:  reason,
                Message: err.Error(),
            },
        },
    }
}

これだと、生成はできるものの引数が多いので扱いづらいです。
codestatusreasonはグルーピングできそうなので、グルーピングするstructを作ってあげました。
外部のパッケージでカスタマイズできるようにpublicにしています。

// ErrCase ...
type ErrCase struct {
    Code   int
    Status string
    Reason string
} 

// NewCause ...
func NewCause(err error, domain string, c ErrCase) error {
    return NewCauseWithStatus(err, domain, c.Code, c.Status, c.Reason)
}

// NewCauseWithStatus ...
func NewCauseWithStatus(err error, domain string, code int, status string, reason string) error {
    return &Cause{
        code:    code,
        status:  status,
        message: err,
        details: []Detail{
            {
                Domain:  domain,
                Reason:  reason,
                Message: err.Error(),
            },
        },
    }
}

全体像

package errors

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
)

// Status
const (
	// ServiceDomainGlobal ...
	ServiceDomainGlobal = "global"

	// Status

	// StatusUnauthenticated ...
	StatusUnauthenticated = "UNAUTHENTICATED"
	// StatusPermissionDenied ...
	StatusPermissionDenied = "PERMISSION_DENIED"
	// StatusNotFound ...
	StatusNotFound = "NOT_FOUND"
	// StatusAborted ...
	StatusAborted = "ABORTED"
	// StatusAlreadyExists ...
	StatusAlreadyExists = "ALREADY_EXISTS"
	// StatusResourceExhausted ...
	StatusResourceExhausted = "RESOURCE_EXHAUSTED"
	// StatusUnavailable ...
	StatusUnavailable = "UNAVAILABLE"
	// StatusBackendError ...
	StatusBackendError = "INTERNAL"

	// Reason

	// ReasonUnauthenticated ...
	ReasonUnauthenticated = "unauthenticated"
	// ReasonPermissionDenied ...
	ReasonPermissionDenied = "permissionDenied"
	// ReasonNotFound ...
	ReasonNotFound = "notFound"
	// ReasonAborted ...
	ReasonAborted = "abourtedRequest"
	// ReasonAlreadyExists ...
	ReasonAlreadyExists = "alreadyExists"
	// ReasonResourceExhausted ...
	ReasonResourceExhausted = "userRateLimitExceeded"
	// ReasonUnavailable ...
	ReasonUnavailable = "unavailable"
	// ReasonBackendError ...
	ReasonBackendError = "backendError"
)

var (
	// ServiceDomain is default service domain name
	ServiceDomain = ServiceDomainGlobal

	// CaseUnauthenticated ...
	CaseUnauthenticated = ErrCase{
		Code:   http.StatusUnauthorized,
		Status: StatusUnauthenticated,
		Reason: ReasonUnauthenticated,
	}

	// CasePermissionDenied ...
	CasePermissionDenied = ErrCase{
		Code:   http.StatusForbidden,
		Status: StatusPermissionDenied,
		Reason: ReasonPermissionDenied,
	}

	// CaseNotFound ...
	CaseNotFound = ErrCase{
		Code:   http.StatusNotFound,
		Status: StatusNotFound,
		Reason: ReasonNotFound,
	}

	// CaseAborted ...
	CaseAborted = ErrCase{
		Code:   http.StatusConflict,
		Status: StatusAborted,
		Reason: ReasonAborted,
	}

	// CaseAlreadyExists ...
	CaseAlreadyExists = ErrCase{
		Code:   http.StatusConflict,
		Status: StatusBackendError,
		Reason: ReasonBackendError,
	}

	// CaseResourceExhausted ...
	CaseResourceExhausted = ErrCase{
		Code:   http.StatusTooManyRequests,
		Status: StatusResourceExhausted,
		Reason: ReasonResourceExhausted,
	}

	// CaseUnavailable ...
	CaseUnavailable = ErrCase{
		Code:   http.StatusServiceUnavailable,
		Status: StatusUnavailable,
		Reason: ReasonUnavailable,
	}

	// CaseBackendError ...
	CaseBackendError = ErrCase{
		Code:   http.StatusInternalServerError,
		Status: StatusBackendError,
		Reason: ReasonBackendError,
	}
)

// Cause ...
type Cause struct {
	code    int
	status  string
	message error
	details []Detail
}

// Detail ...
type Detail struct {
	Domain  string
	Reason  string
	Message string
}

// ErrCase ...
type ErrCase struct {
	Code   int
	Status string
	Reason string
}

// jsonResponse ...
type jsonResponse struct {
	Error jsonResponseElement `json:"error"`
}

// jsonResponseElement
type jsonResponseElement struct {
	Code    int      `json:"code"`
	Status  string   `json:"status"`
	Message string   `json:"message"`
	Details []Detail `json:"details"`
}

// NewCause ...
func NewCause(err error, domain string, c ErrCase) error {
	return NewCauseWithStatus(err, domain, c.Code, c.Status, c.Reason)
}

// NewCauseWithStatus ...
func NewCauseWithStatus(err error, domain string, code int, status string, reason string) error {
	return &Cause{
		code:    code,
		status:  status,
		message: err,
		details: []Detail{
			{
				Domain:  domain,
				Reason:  reason,
				Message: err.Error(),
			},
		},
	}
}

// Error return error message
func (c *Cause) Error() string {
	reason := ""
	if len(c.details) > 0 {
		reason = c.details[0].Reason
	}
	return fmt.Errorf("%s : %w", reason, c.message).Error()
}

// Unwrap implements errors.Unwrap method
func (c *Cause) Unwrap() error {
	return c.message
}

// Append one or more elements onto the end of details
func (c *Cause) Append(e error) {
	if c == nil || c.IsZero() {
		c.set(e)
		return
	}

	if v, ok := e.(*Cause); ok {
		c.details = append(c.details, v.details...)
		return
	}
	c.details = append(c.details, Detail{
		Domain:  ServiceDomain,
		Reason:  StatusBackendError,
		Message: e.Error(),
	})
}

// MarshalJSON implements the json.Marshaler interface
func (c *Cause) MarshalJSON() ([]byte, error) {
	return json.Marshal(jsonResponse{
		Error: jsonResponseElement{
			Code:    c.code,
			Status:  c.status,
			Message: c.message.Error(),
			Details: c.details,
		},
	})
}

// UnmarshalJSON implements the json.Unmarshaler interface
func (c *Cause) UnmarshalJSON(b []byte) (err error) {
	var je jsonResponse
	if err = json.Unmarshal(b, &je); err != nil {
		return
	}
	ce := &Cause{
		code:    je.Error.Code,
		status:  je.Error.Status,
		message: errors.New(je.Error.Message),
		details: je.Error.Details,
	}
	c.Append(ce)
	return nil
}

// IsZero checks empty
func (c *Cause) IsZero() bool {
	return (c.code == 0 || c.status == "" || c.message == nil || len(c.details) == 0)
}

// set overwrites error
func (c *Cause) set(e error) {
	v, ok := e.(*Cause)
	if !ok {
		v = NewCause(e, ServiceDomain, CaseBackendError).(*Cause)
	}

	// c = v <== fail staticcheck. SA4006: this value of `c` is never used
	c.code = v.code
	c.status = v.status
	c.message = v.message
	c.details = v.details
}

各サービスごとの処理

今までは共通処理としてのエラーを見てきました。各サービスでは、ServiceDomainなどは固定できそうなので、そういった箇所をWrapしたエラーを用意します

const (
	// ServiceDomain ...
	ServiceDomain = "foobar"
)

func init() {
	errors.ServiceDomain = ServiceDomain
}

// NewCause ...
func NewCause(err error, c errors.ErrCase) error {
	return errors.NewCause(err, ServiceDomain, c)
}

// NewCauseWithStatus ...
func NewCauseWithStatus(err error, code int, status string, reason string) error {
	return errors.NewCauseWithStatus(err, ServiceDomain, code, status, reason)
}

後書き

土日に思いつきで作ったものなので、まだまだ改良の余地はありそうですが、よくあるCodeとメッセージだけのエラーなどよりは置いやすいのではないでしょうか?

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?