元ネタ
考えてみた
といっても全くのオリジナルではなくて、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(),
},
},
}
}
これだと、生成はできるものの引数が多いので扱いづらいです。
code
、status
、reason
はグルーピングできそうなので、グルーピングする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とメッセージだけのエラーなどよりは置いやすいのではないでしょうか?