diff --git a/client.go b/client.go index abfb849f..969b8a20 100644 --- a/client.go +++ b/client.go @@ -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"` @@ -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. + // UnmarshalJSON satisfies encoding/json.Unmarshaler func (n *NullAPIErrorObject) UnmarshalJSON(data []byte) error { var aeo APIErrorObject @@ -135,6 +137,8 @@ type APIError struct { message string } +var _ error = &APIError{} // assert that it implements the error interface. + // Error satisfies the error interface, and should contain the StatusCode, // APIErrorObject.Message, and APIErrorObject.Code. func (a APIError) Error() string { @@ -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 "" case 1: return errs[0] @@ -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. @@ -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) } if resp.StatusCode < 200 || resp.StatusCode > 299 { diff --git a/event_v2.go b/event_v2.go index 1408362c..f7661af2 100644 --- a/event_v2.go +++ b/event_v2.go @@ -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 + + message string +} + +var _ error = &EventsAPIV2Error{} // assert that it satisfies the error interface. +var _ json.Unmarshaler = &EventsAPIV2Error{} // assert that it satisfies the json.Unmarshaler interface. + +// 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), + ) +} + +// UnmarshalJSON satisfies encoding/json.Unmarshaler. +func (e *EventsAPIV2Error) UnmarshalJSON(data []byte) error { + var eaeo EventsAPIV2ErrorObject + 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 +} + +// 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) @@ -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), + } + } + // 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 } diff --git a/event_v2_test.go b/event_v2_test.go index d590959c..30095cd0 100644 --- a/event_v2_test.go +++ b/event_v2_test.go @@ -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) + } + }) + } +}