Skip to content

Commit 4e97308

Browse files
authored
Merge pull request #208 from kaijietti/master
feat(appstore): return detailed error information out to caller
2 parents 62ad3e6 + f022f02 commit 4e97308

File tree

5 files changed

+216
-0
lines changed

5 files changed

+216
-0
lines changed

appstore/api/error.go

+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
)
7+
8+
type Error struct {
9+
// Only errorCode and errorMessage are returned by App Store Server API.
10+
errorCode int
11+
errorMessage string
12+
}
13+
14+
func newError(errorCode int, errorMessage string) *Error {
15+
return &Error{
16+
errorCode: errorCode,
17+
errorMessage: errorMessage,
18+
}
19+
}
20+
21+
type appStoreAPIErrorResp struct {
22+
ErrorCode int `json:"errorCode"`
23+
ErrorMessage string `json:"errorMessage"`
24+
}
25+
26+
func newErrorFromJSON(b []byte) (*Error, bool) {
27+
if len(b) == 0 {
28+
return nil, false
29+
}
30+
var rErr appStoreAPIErrorResp
31+
if err := json.Unmarshal(b, &rErr); err != nil {
32+
return nil, false
33+
}
34+
if rErr.ErrorCode == 0 {
35+
return nil, false
36+
}
37+
return &Error{errorCode: rErr.ErrorCode, errorMessage: rErr.ErrorMessage}, true
38+
}
39+
40+
func (e *Error) Error() string {
41+
return fmt.Sprintf("errorCode: %d, errorMessage: %s", e.errorCode, e.errorMessage)
42+
}
43+
44+
func (e *Error) As(target interface{}) bool {
45+
if targetErr, ok := target.(*Error); ok {
46+
*targetErr = *e
47+
return true
48+
}
49+
return false
50+
}
51+
52+
func (e *Error) Is(target error) bool {
53+
if other, ok := target.(*Error); ok && other.errorCode == e.errorCode {
54+
return true
55+
}
56+
return false
57+
}
58+
59+
func (e *Error) ErrorCode() int {
60+
return e.errorCode
61+
}
62+
63+
func (e *Error) ErrorMessage() string {
64+
return e.errorMessage
65+
}
66+
67+
func (e *Error) Retryable() bool {
68+
// NOTE:
69+
// RateLimitExceededError[1] could also be considered as a retryable error.
70+
// But limits are enforced on an hourly basis[2], so you should handle exceeded rate limits gracefully instead of retrying immediately.
71+
// Refs:
72+
// [1] https://developer.apple.com/documentation/appstoreserverapi/ratelimitexceedederror
73+
// [2] https://developer.apple.com/documentation/appstoreserverapi/identifying_rate_limits
74+
switch e.errorCode {
75+
case 4040002, 4040004, 5000001, 4040006:
76+
return true
77+
default:
78+
return false
79+
}
80+
}
81+
82+
// All Error lists in https://developer.apple.com/documentation/appstoreserverapi/error_codes.
83+
var (
84+
// Retryable errors
85+
AccountNotFoundRetryableError = newError(4040002, "Account not found. Please try again.")
86+
AppNotFoundRetryableError = newError(4040004, "App not found. Please try again.")
87+
GeneralInternalRetryableError = newError(5000001, "An unknown error occurred. Please try again.")
88+
OriginalTransactionIdNotFoundRetryableError = newError(4040006, "Original transaction id not found. Please try again.")
89+
// Errors
90+
AccountNotFoundError = newError(4040001, "Account not found.")
91+
AppNotFoundError = newError(4040003, "App not found.")
92+
FamilySharedSubscriptionExtensionIneligibleError = newError(4030007, "Subscriptions that users obtain through Family Sharing can't get a renewal date extension directly.")
93+
GeneralInternalError = newError(5000000, "An unknown error occurred.")
94+
GeneralBadRequestError = newError(4000000, "Bad request.")
95+
InvalidAppIdentifierError = newError(4000002, "Invalid request app identifier.")
96+
InvalidEmptyStorefrontCountryCodeListError = newError(4000027, "Invalid request. If provided, the list of storefront country codes must not be empty.")
97+
InvalidExtendByDaysError = newError(4000009, "Invalid extend by days value.")
98+
InvalidExtendReasonCodeError = newError(4000010, "Invalid extend reason code.")
99+
InvalidOriginalTransactionIdError = newError(4000008, "Invalid original transaction id.")
100+
InvalidRequestIdentifierError = newError(4000011, "Invalid request identifier.")
101+
InvalidRequestRevisionError = newError(4000005, "Invalid request revision.")
102+
InvalidRevokedError = newError(4000030, "Invalid request. The revoked parameter is invalid.")
103+
InvalidStatusError = newError(4000031, "Invalid request. The status parameter is invalid.")
104+
InvalidStorefrontCountryCodeError = newError(4000028, "Invalid request. A storefront country code was invalid.")
105+
InvalidTransactionIdError = newError(4000006, "Invalid transaction id.")
106+
OriginalTransactionIdNotFoundError = newError(4040005, "Original transaction id not found.")
107+
RateLimitExceededError = newError(4290000, "Rate limit exceeded.")
108+
StatusRequestNotFoundError = newError(4040009, "The server didn't find a subscription-renewal-date extension request for this requestIdentifier and productId combination.")
109+
SubscriptionExtensionIneligibleError = newError(4030004, "Forbidden - subscription state ineligible for extension.")
110+
SubscriptionMaxExtensionError = newError(4030005, "Forbidden - subscription has reached maximum extension count.")
111+
TransactionIdNotFoundError = newError(4040010, "Transaction id not found.")
112+
// Notification test and history errors
113+
InvalidEndDateError = newError(4000016, "Invalid request. The end date is not a timestamp value represented in milliseconds.")
114+
InvalidNotificationTypeError = newError(4000018, "Invalid request. The notification type or subtype is invalid.")
115+
InvalidPaginationTokenError = newError(4000014, "Invalid request. The pagination token is invalid.")
116+
InvalidStartDateError = newError(4000015, "Invalid request. The start date is not a timestamp value represented in milliseconds.")
117+
InvalidTestNotificationTokenError = newError(4000020, "Invalid request. The test notification token is invalid.")
118+
InvalidInAppOwnershipTypeError = newError(4000026, "Invalid request. The in-app ownership type parameter is invalid.")
119+
InvalidProductIdError = newError(4000023, "Invalid request. The product id parameter is invalid.")
120+
InvalidProductTypeError = newError(4000022, "Invalid request. The product type parameter is invalid.")
121+
InvalidSortError = newError(4000021, "Invalid request. The sort parameter is invalid.")
122+
InvalidSubscriptionGroupIdentifierError = newError(4000024, "Invalid request. The subscription group identifier parameter is invalid.")
123+
MultipleFiltersSuppliedError = newError(4000019, "Invalid request. Supply either a transaction id or a notification type, but not both.")
124+
PaginationTokenExpiredError = newError(4000017, "Invalid request. The pagination token is expired.")
125+
ServerNotificationURLNotFoundError = newError(4040007, "No App Store Server Notification URL found for provided app. Check that a URL is configured in App Store Connect for this environment.")
126+
StartDateAfterEndDateError = newError(4000013, "Invalid request. The end date precedes the start date or the dates are the same.")
127+
StartDateTooFarInPastError = newError(4000012, "Invalid request. The start date is earlier than the allowed start date.")
128+
TestNotificationNotFoundError = newError(4040008, "Either the test notification token is expired or the notification and status are not yet available.")
129+
)

appstore/api/error_test.go

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package api
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestError_As(t *testing.T) {
12+
tests := []struct {
13+
SrcError error
14+
ExpectedAs bool // Check If SrcError can be 'As' to Error.
15+
}{
16+
{SrcError: AccountNotFoundError, ExpectedAs: true},
17+
{SrcError: AppNotFoundError, ExpectedAs: true},
18+
{SrcError: fmt.Errorf("custom error"), ExpectedAs: false},
19+
{SrcError: fmt.Errorf("wrapping: %w", AccountNotFoundError), ExpectedAs: true},
20+
{SrcError: errors.Unwrap(fmt.Errorf("wrapping: %w", AccountNotFoundError)), ExpectedAs: true},
21+
}
22+
23+
for _, test := range tests {
24+
var apiErr *Error
25+
as := errors.As(test.SrcError, &apiErr)
26+
assert.Equal(t, test.ExpectedAs, as)
27+
if test.ExpectedAs {
28+
assert.NotZero(t, apiErr.errorCode)
29+
assert.NotZero(t, apiErr.errorMessage)
30+
} else {
31+
assert.Nil(t, apiErr)
32+
}
33+
}
34+
35+
}
36+
37+
func TestError_Is(t *testing.T) {
38+
tests := []struct {
39+
ErrBytes []byte
40+
TargetError error
41+
ExpectedIs bool // Check if error (constructed by ErrBytes) Is TargetError Or not.
42+
}{
43+
{ErrBytes: []byte(`{"errorCode": 4040001, "errorMessage": "Account not found."}`), TargetError: AccountNotFoundError, ExpectedIs: true},
44+
{ErrBytes: []byte(`{"errorCode": 4040001, "errorMessage": "Account not found."}`), TargetError: AppNotFoundError, ExpectedIs: false},
45+
{ErrBytes: []byte(`{"errorCode": 4040001, "errorMessage": "Account not found."}`), TargetError: fmt.Errorf("custom error"), ExpectedIs: false},
46+
{ErrBytes: []byte(`{"errorCode": 4040001, "errorMessage": "Account not found."}`), TargetError: fmt.Errorf("wrapping: %w", AccountNotFoundError), ExpectedIs: false},
47+
{ErrBytes: []byte(`{"errorCode": 4040001, "errorMessage": "Account not found."}`), TargetError: errors.Unwrap(fmt.Errorf("wrapping: %w", AccountNotFoundError)), ExpectedIs: true},
48+
}
49+
for _, test := range tests {
50+
err, ok := newErrorFromJSON(test.ErrBytes)
51+
assert.True(t, ok)
52+
assert.Equal(t, test.ExpectedIs, errors.Is(err, test.TargetError))
53+
}
54+
}
55+
56+
func TestError_Is2(t *testing.T) {
57+
tests := []struct {
58+
SrcError error
59+
TargetError error
60+
ExpectedIs bool // Check if SrcError is TargetError or not.
61+
}{
62+
{SrcError: AccountNotFoundError, TargetError: AccountNotFoundError, ExpectedIs: true},
63+
{SrcError: AppNotFoundError, TargetError: AccountNotFoundError, ExpectedIs: false},
64+
{SrcError: fmt.Errorf("custom error"), TargetError: AccountNotFoundError, ExpectedIs: false},
65+
{SrcError: fmt.Errorf("wrapping: %w", AccountNotFoundError), TargetError: AccountNotFoundError, ExpectedIs: true},
66+
{SrcError: errors.Unwrap(fmt.Errorf("wrapping: %w", AccountNotFoundError)), TargetError: AccountNotFoundError, ExpectedIs: true},
67+
}
68+
for _, test := range tests {
69+
assert.Equal(t, test.ExpectedIs, errors.Is(test.SrcError, test.TargetError))
70+
}
71+
}

appstore/api/store.go

+7
Original file line numberDiff line numberDiff line change
@@ -472,5 +472,12 @@ func (a *StoreClient) Do(ctx context.Context, method string, url string, body io
472472
return resp.StatusCode, nil, fmt.Errorf("appstore read http body err %w", err)
473473
}
474474

475+
if resp.StatusCode != http.StatusOK {
476+
// try to extract detailed error.
477+
if rErr, ok := newErrorFromJSON(bytes); ok {
478+
return resp.StatusCode, bytes, rErr
479+
}
480+
}
481+
475482
return resp.StatusCode, bytes, err
476483
}

go.mod

+4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
github.com/golang-jwt/jwt/v4 v4.3.0
77
github.com/golang/mock v1.6.0
88
github.com/google/uuid v1.3.0
9+
github.com/stretchr/testify v1.8.1
910
golang.org/x/oauth2 v0.7.0
1011
google.golang.org/api v0.118.0
1112
google.golang.org/appengine v1.6.7
@@ -14,11 +15,13 @@ require (
1415
require (
1516
cloud.google.com/go/compute v1.19.0 // indirect
1617
cloud.google.com/go/compute/metadata v0.2.3 // indirect
18+
github.com/davecgh/go-spew v1.1.1 // indirect
1719
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
1820
github.com/golang/protobuf v1.5.3 // indirect
1921
github.com/google/s2a-go v0.1.0 // indirect
2022
github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
2123
github.com/googleapis/gax-go/v2 v2.8.0 // indirect
24+
github.com/pmezard/go-difflib v1.0.0 // indirect
2225
go.opencensus.io v0.24.0 // indirect
2326
golang.org/x/crypto v0.1.0 // indirect
2427
golang.org/x/net v0.9.0 // indirect
@@ -27,4 +30,5 @@ require (
2730
google.golang.org/genproto v0.0.0-20230403163135-c38d8f061ccd // indirect
2831
google.golang.org/grpc v1.54.0 // indirect
2932
google.golang.org/protobuf v1.30.0 // indirect
33+
gopkg.in/yaml.v3 v3.0.1 // indirect
3034
)

go.sum

+5
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWH
1818
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
1919
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
2020
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
21+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2122
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2223
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
2324
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -69,6 +70,7 @@ github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5
6970
github.com/googleapis/gax-go/v2 v2.8.0 h1:UBtEZqx1bjXtOQ5BVTkuYghXrr3N4V123VKJK67vJZc=
7071
github.com/googleapis/gax-go/v2 v2.8.0/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI=
7172
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
73+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
7274
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
7375
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
7476
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
@@ -79,6 +81,7 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
7981
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
8082
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
8183
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
84+
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
8285
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
8386
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
8487
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
@@ -187,10 +190,12 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
187190
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
188191
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
189192
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
193+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
190194
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
191195
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
192196
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
193197
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
198+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
194199
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
195200
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
196201
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

0 commit comments

Comments
 (0)