Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Return EventsAPIV2Error from Events V2 API call. #368

Closed
wants to merge 13 commits into from
14 changes: 9 additions & 5 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const (
)

// APIObject represents generic api json response that is shared by most
// domain object (like escalation
// domain objects (like escalation)
type APIObject struct {
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Expand Down Expand Up @@ -90,13 +90,15 @@ type APIErrorObject struct {
// While the PagerDuty REST API is documented to always return the error object,
// we assume it's possible in exceptional failure modes for this to be omitted.
// As such, this wrapper type provides us a way to check if the object was
// provided while avoiding cosnumers accidentally missing a nil pointer check,
// provided while avoiding consumers accidentally missing a nil pointer check,
// thus crashing their whole program.
type NullAPIErrorObject struct {
Valid bool
ErrorObject APIErrorObject
}

var _ json.Unmarshaler = &NullAPIErrorObject{} // assert that it satisfies the json.Unmarshaler interface.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍, but also could you change this a little bit:

var _ json.Unmarshaler = (*NullAPIErrorObject)(nil)


// UnmarshalJSON satisfies encoding/json.Unmarshaler
func (n *NullAPIErrorObject) UnmarshalJSON(data []byte) error {
var aeo APIErrorObject
Expand Down Expand Up @@ -135,6 +137,8 @@ type APIError struct {
message string
}

var _ error = &APIError{} // assert that it implements the error interface.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like the other I commented on below I'm not sure there's much value in this specific interface check, could you remove it?


// Error satisfies the error interface, and should contain the StatusCode,
// APIErrorObject.Message, and APIErrorObject.Code.
func (a APIError) Error() string {
Expand Down Expand Up @@ -165,7 +169,7 @@ func (a APIError) Error() string {
func apiErrorsDetailString(errs []string) string {
switch n := len(errs); n {
case 0:
panic("errs slice is empty")
return ""
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be returned back to a panic(), especially when the the dangling : issue is addressed below and so this should never receive a 0 length slice.


case 1:
return errs[0]
Expand Down Expand Up @@ -319,7 +323,7 @@ const (
DebugCaptureLastRequest DebugFlag = 1 << 0

// DebugCaptureLastResponse captures the last HTTP response from the API (if
// there was one) and makes it available via the LastAPIReponse() method.
// there was one) and makes it available via the LastAPIResponse() method.
//
// This may increase memory usage / GC, as we'll be making a copy of the
// full HTTP response body on each request and capturing it for inspection.
Expand Down Expand Up @@ -546,7 +550,7 @@ func (c *Client) decodeJSON(resp *http.Response, payload interface{}) error {

func (c *Client) checkResponse(resp *http.Response, err error) (*http.Response, error) {
if err != nil {
return resp, fmt.Errorf("Error calling the API endpoint: %v", err)
return resp, fmt.Errorf("error calling the API endpoint: %v", err)
Copy link
Collaborator

@theckman theckman Nov 9, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even though I personally agree with this change, could you undo it? I plan to lowercase all of the errors for v2, but the style of this library (within v1) is to capitalize the first word of an error message.

}

if resp.StatusCode < 200 || resp.StatusCode > 299 {
Expand Down
122 changes: 117 additions & 5 deletions event_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,106 @@ func ManageEvent(e V2Event) (*V2EventResponse, error) {
return ManageEventWithContext(context.Background(), e)
}

// EventsAPIV2Error represents the error response received when an Events API V2 call fails. The
// HTTP response code is set inside of the StatusCode field, with the EventsAPIV2Error
// field being the wrapper around the JSON error object returned from the Events API V2.
//
// This type also provides some helper methods like .BadRequest(), .RateLimited(),
// and .Temporary() to help callers reason about how to handle the error.
type EventsAPIV2Error struct {
// StatusCode is the HTTP response status code.
StatusCode int `json:"-"`

// EventsAPIV2Error represents the object returned by the API when an error occurs,
// which includes messages that should hopefully provide useful context
// to the end user.
//
// If the API response did not contain an error object, the .Valid field of
// EventsAPIV2Error will be false. If .Valid is true, the .ErrorObject field is
// valid and should be consulted.
EventsAPIV2Error NullEventsAPIV2ErrorObject
Copy link
Collaborator

@theckman theckman Nov 9, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you be willing to make this struct field APIError. While the names aligned in the other type, it was more to make it clear it wasn't just a standard Go error (by the name).


message string
}

var _ error = &EventsAPIV2Error{} // assert that it satisfies the error interface.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this assertion is necessary as the compiler will flag it elsewhere. The json.Unmarshaler one is a good check though, because that wouldn't be caught until runtime. Could you remove just this error one?

var _ json.Unmarshaler = &EventsAPIV2Error{} // assert that it satisfies the json.Unmarshaler interface.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 here too, but also could you change this a little bit:

var _ json.Unmarshaler = (*EventsAPIV2Error)(nil)


// Error satisfies the error interface, and should contain the StatusCode,
// EventsAPIV2Error.Message, EventsAPIV2Error.ErrorObject.Status, and EventsAPIV2Error.Errors.
func (e EventsAPIV2Error) Error() string {
if len(e.message) > 0 {
return e.message
}

if !e.EventsAPIV2Error.Valid {
return fmt.Sprintf("HTTP response failed with status code %d and no JSON error object was present", e.StatusCode)
}

return fmt.Sprintf(
"HTTP response failed with status code: %d, message: %s, status: %s: %s",
e.StatusCode,
e.EventsAPIV2Error.ErrorObject.Message,
e.EventsAPIV2Error.ErrorObject.Status,
apiErrorsDetailString(e.EventsAPIV2Error.ErrorObject.Errors),
)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the Errors slice is empty, this will print HTTP response failed with status code: CODE, message: MSG, status: STATUS: .

That doesn't seem like a very ideal error message, so could you handle the empty errors case separately to avoid the dangling :?

}

// UnmarshalJSON satisfies encoding/json.Unmarshaler.
func (e *EventsAPIV2Error) UnmarshalJSON(data []byte) error {
var eaeo EventsAPIV2ErrorObject
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOL. Say that five times fast...

While you're in there making changes, I'm ok with just eo. But you don't need to change it.

if err := json.Unmarshal(data, &eaeo); err != nil {
return err
}

e.EventsAPIV2Error.ErrorObject = eaeo
e.EventsAPIV2Error.Valid = true

return nil
}

// BadRequest returns whether the event request was rejected by PagerDuty as an
// incorrect or invalid event structure.
func (e EventsAPIV2Error) BadRequest() bool {
return e.StatusCode == http.StatusBadRequest
}
theckman marked this conversation as resolved.
Show resolved Hide resolved

// RateLimited returns whether the response had e status of 429, and as such the
// client is rate limited. The PagerDuty rate limits should reset once per
// minute, and for the REST API they are an account-wide rate limit (not per
// API key or IP).
func (e EventsAPIV2Error) RateLimited() bool {
return e.StatusCode == http.StatusTooManyRequests
}

// Temporary returns whether it was a temporary error, one of which is a
// RateLimited error.
func (e EventsAPIV2Error) Temporary() bool {
return e.RateLimited() || (e.StatusCode >= 500 && e.StatusCode < 600)
}

// NullEventsAPIV2ErrorObject is a wrapper around the EventsAPIV2ErrorObject type. If the Valid
// field is true, the API response included a structured error JSON object. This
// structured object is then set on the ErrorObject field.
//
// We assume it's possible in exceptional failure modes for error objects to be omitted by PagerDuty.
// As such, this wrapper type provides us a way to check if the object was
// provided while avoiding consumers accidentally missing a nil pointer check,
// thus crashing their whole program.
type NullEventsAPIV2ErrorObject struct {
Valid bool
ErrorObject EventsAPIV2ErrorObject
}

// EventsAPIV2ErrorObject represents the object returned by the Events V2 API when an error
// occurs. This includes messages that should hopefully provide useful context
// to the end user.
type EventsAPIV2ErrorObject struct {
Status string `json:"status,omitempty"`
Message string `json:"message,omitempty"`
Errors []string `json:"errors,omitempty"`
}

// ManageEventWithContext handles the trigger, acknowledge, and resolve methods for an event.
func ManageEventWithContext(ctx context.Context, e V2Event) (*V2EventResponse, error) {
data, err := json.Marshal(e)
Expand All @@ -73,21 +173,33 @@ func ManageEventWithContext(ctx context.Context, e V2Event) (*V2EventResponse, e
}

defer func() { _ = resp.Body.Close() }() // explicitly discard error

if resp.StatusCode != http.StatusAccepted {
bytes, err := ioutil.ReadAll(resp.Body)
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("HTTP Status Code: %d", resp.StatusCode)
return nil, EventsAPIV2Error{
StatusCode: resp.StatusCode,
message: fmt.Sprintf("HTTP response with status code: %d: error: %s", resp.StatusCode, err),
}
abhishekrb19 marked this conversation as resolved.
Show resolved Hide resolved
}
// now try to decode the response body into the error object.
var eae EventsAPIV2Error
err = json.Unmarshal(b, &eae)
if err != nil {
eae = EventsAPIV2Error{
StatusCode: resp.StatusCode,
message: fmt.Sprintf("HTTP response with status code: %d, JSON unmarshal object body failed: %s, body: %s", resp.StatusCode, err, string(b)),
}
return nil, eae
}

return nil, fmt.Errorf("HTTP Status Code: %d, Message: %s", resp.StatusCode, string(bytes))
eae.StatusCode = resp.StatusCode
return nil, eae
}

var eventResponse V2EventResponse
if err := json.NewDecoder(resp.Body).Decode(&eventResponse); err != nil {
return nil, err
}

return &eventResponse, nil
}

Expand Down
175 changes: 175 additions & 0 deletions event_v2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,178 @@ func TestEventV2_ManageEvent(t *testing.T) {

testEqual(t, want, res)
}

func TestEventsAPIV2Error_BadRequest(t *testing.T) {
tests := []struct {
name string
e EventsAPIV2Error
want bool
}{
{
name: "bad_request",
e: EventsAPIV2Error{
StatusCode: http.StatusBadRequest,
EventsAPIV2Error: NullEventsAPIV2ErrorObject{
Valid: true,
ErrorObject: EventsAPIV2ErrorObject{
Status: "invalid",
Message: "Event object is invalid",
Errors: []string{"Length of 'routing_key' is incorrect (should be 32 characters)", "'event_action' is missing or blank"},
},
},
},
want: true,
},
{
name: "rate_limited",
e: EventsAPIV2Error{
StatusCode: http.StatusTooManyRequests,
EventsAPIV2Error: NullEventsAPIV2ErrorObject{
Valid: true,
ErrorObject: EventsAPIV2ErrorObject{
Status: "throttle exceeded",
Message: "Requests for this service are arriving too quickly. Please retry later.",
Errors: []string{"Enhance Your Calm."},
},
},
},
want: false,
},
{
name: "InternalServerError",
e: EventsAPIV2Error{
StatusCode: http.StatusInternalServerError,
},
want: false,
},
{
name: "ServiceUnavailable",
e: EventsAPIV2Error{
StatusCode: http.StatusServiceUnavailable,
},
want: false,
},
}

for _, tt := range tests {
tt := tt

t.Run(tt.name, func(t *testing.T) {
if got := tt.e.BadRequest(); got != tt.want {
t.Fatalf("tt.e.BadRequest() = %t, want %t", got, tt.want)
}
})
}
}

func TestEventsAPIV2Error_RateLimited(t *testing.T) {
tests := []struct {
name string
e EventsAPIV2Error
want bool
}{
{
name: "rate_limited",
e: EventsAPIV2Error{
StatusCode: http.StatusTooManyRequests,
EventsAPIV2Error: NullEventsAPIV2ErrorObject{
Valid: true,
ErrorObject: EventsAPIV2ErrorObject{
Status: "throttle exceeded",
Message: "Requests for this service are arriving too quickly. Please retry later.",
Errors: []string{"Enhance Your Calm"},
},
},
},
want: true,
},
{
name: "not_found",
e: EventsAPIV2Error{
StatusCode: http.StatusNotFound,
EventsAPIV2Error: NullEventsAPIV2ErrorObject{
Valid: true,
ErrorObject: EventsAPIV2ErrorObject{
Status: "Not Found",
Message: "Not Found",
Errors: []string{"Not Found"},
},
},
},
want: false,
},
}

for _, tt := range tests {
tt := tt

t.Run(tt.name, func(t *testing.T) {
if got := tt.e.RateLimited(); got != tt.want {
t.Fatalf("tt.e.RateLimited() = %t, want %t", got, tt.want)
}
})
}
}

func TestEventsAPIV2Error_Temporary(t *testing.T) {
tests := []struct {
name string
e EventsAPIV2Error
want bool
}{
{
name: "rate_limited",
e: EventsAPIV2Error{
StatusCode: http.StatusTooManyRequests,
EventsAPIV2Error: NullEventsAPIV2ErrorObject{
Valid: true,
ErrorObject: EventsAPIV2ErrorObject{
Status: "throttle exceeded",
Message: "Requests for this service are arriving too quickly. Please retry later.",
Errors: []string{"Enhance Your Calm"},
},
},
},
want: true,
},
{
name: "InternalServerError",
e: EventsAPIV2Error{
StatusCode: http.StatusInternalServerError,
},
want: true,
},
{
name: "ServiceUnavailable",
e: EventsAPIV2Error{
StatusCode: http.StatusServiceUnavailable,
},
want: true,
},
{
name: "not_found",
e: EventsAPIV2Error{
StatusCode: http.StatusNotFound,
EventsAPIV2Error: NullEventsAPIV2ErrorObject{
Valid: true,
ErrorObject: EventsAPIV2ErrorObject{
Status: "Not Found",
Message: "Not Found",
Errors: []string{"Not Found"},
},
},
},
want: false,
},
}

for _, tt := range tests {
tt := tt

t.Run(tt.name, func(t *testing.T) {
if got := tt.e.Temporary(); got != tt.want {
t.Fatalf("tt.e.Temporary() = %t, want %t", got, tt.want)
}
})
}
}