Skip to content

Commit

Permalink
Merge pull request #265 from PagerDuty/propagate_api_errors
Browse files Browse the repository at this point in the history
Provide a method for ferrying API errors back to the caller
  • Loading branch information
Scott McAllister committed Feb 9, 2021
2 parents eab6a10 + 31ab2fd commit 79871df
Show file tree
Hide file tree
Showing 3 changed files with 357 additions and 21 deletions.
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,46 @@ If you need to use your own HTTP client, for doing things like defining your own
transport settings, you can replace the default HTTP client with your own by
simply by setting a new value in the `HTTPClient` field.

#### API Error Responses

For cases where your request results in an error from the API, you can use the
`errors.As()` function from the standard library to extract the
`pagerduty.APIError` error value and inspect more details about the error,
including the HTTP response code and PagerDuty API Error Code.

```go
package main

import (
"fmt"
"github.com/PagerDuty/go-pagerduty"
)

var authtoken = "" // Set your auth token here

func main() {
client := pagerduty.NewClient(authtoken)
user, err := client.GetUser("NOTREAL", pagerduty.GetUserOptions{})
if err != nil {
var aerr pagerduty.APIError

if errors.As(err, &aerr) {
if aerr.RateLimited() {
fmt.Println("rate limited")
return
}

fmt.Println("unknown status code:", aerr.StatusCode)

return
}

panic(err)
}
fmt.Println(user)
}
```

## Contributing

1. Fork it ( https://github.com/PagerDuty/go-pagerduty/fork )
Expand Down
116 changes: 96 additions & 20 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net"
"net/http"
"runtime"
"strings"
"time"
)

Expand Down Expand Up @@ -56,10 +57,73 @@ type APIDetails struct {
Details string `json:"details,omitempty"`
}

type errorObject struct {
Code int `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Errors interface{} `json:"errors,omitempty"`
// APIErrorObject represents the object returned by the API when an error
// occurs. This includes messages that should hopefully provide useful context
// to the end user.
type APIErrorObject struct {
Code int `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Errors []string `json:"errors,omitempty"`
}

// APIError represents the error response received when an API call fails. The
// HTTP response code is set inside of the StatusCode field, with the APIError
// field being the structured JSON error object returned from the API.
//
// This type also provides some helper methods like .RateLimited(), .NotFound(),
// and .Temporary() to help callers reason about how to handle the error.
//
// You can read more about the HTTP status codes and API error codes returned
// from the API here: https://developer.pagerduty.com/docs/rest-api-v2/errors/
type APIError struct {
// StatusCode is the HTTP response status code
StatusCode int `json:"-"`

// APIError represents the object returned by the API when an error occurs.
// If the response has no error object present, this will be nil.
//
// This includes messages that should hopefully provide useful context to
// the end user.
APIError *APIErrorObject `json:"error"`

message string
}

// Error satisfies the error interface, and should contain the StatusCode,
// APIErrorObject.Message, and APIErrorObject.Code.
func (a APIError) Error() string {
if len(a.message) > 0 {
return a.message
}

if a.APIError == nil {
return fmt.Sprintf("HTTP response failed with status code %d and no JSON error object was present", a.StatusCode)
}

return fmt.Sprintf(
"HTTP response failed with status code %d, message: %s (code: %d)",
a.StatusCode, a.APIError.Message, a.APIError.Code,
)
}

// RateLimited returns whether the response had a 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 (a APIError) RateLimited() bool {
return a.StatusCode == http.StatusTooManyRequests
}

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

// NotFound returns whether this was an error where it seems like the resource
// was not found.
func (a APIError) NotFound() bool {
return a.StatusCode == http.StatusNotFound || a.APIError.Code == 2100
}

func newDefaultHTTPClient() *http.Client {
Expand Down Expand Up @@ -161,7 +225,6 @@ func (c *Client) delete(path string) (*http.Response, error) {
}

func (c *Client) put(path string, payload interface{}, headers *map[string]string) (*http.Response, error) {

if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
Expand Down Expand Up @@ -224,27 +287,40 @@ func (c *Client) checkResponse(resp *http.Response, err error) (*http.Response,
if err != nil {
return resp, fmt.Errorf("Error calling the API endpoint: %v", err)
}
if 199 >= resp.StatusCode || 300 <= resp.StatusCode {
var eo *errorObject
var getErr error
if eo, getErr = c.getErrorFromResponse(resp); getErr != nil {
return resp, fmt.Errorf("Response did not contain formatted error: %s. HTTP response code: %v. Raw response: %+v", getErr, resp.StatusCode, resp)
}
return resp, fmt.Errorf("Failed call API endpoint. HTTP response code: %v. Error: %v", resp.StatusCode, eo)

if resp.StatusCode < 200 || resp.StatusCode > 299 {
return resp, c.getErrorFromResponse(resp)
}

return resp, nil
}

func (c *Client) getErrorFromResponse(resp *http.Response) (*errorObject, error) {
var result map[string]errorObject
if err := c.decodeJSON(resp, &result); err != nil {
return nil, fmt.Errorf("Could not decode JSON response: %v", err)
func (c *Client) getErrorFromResponse(resp *http.Response) APIError {
// check whether the error response is declared as JSON
if !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
aerr := APIError{
StatusCode: resp.StatusCode,
message: fmt.Sprintf("HTTP response with status code %d does not contain Content-Type: application/json", resp.StatusCode),
}

return aerr
}
s, ok := result["error"]
if !ok {
return nil, fmt.Errorf("JSON response does not have error field")

var document APIError

// because of above check this probably won't fail, but it's possible...
if err := c.decodeJSON(resp, &document); err != nil {
aerr := APIError{
StatusCode: resp.StatusCode,
message: fmt.Sprintf("HTTP response with status code %d, JSON error object decode failed: %s", resp.StatusCode, err),
}

return aerr
}
return &s, nil

document.StatusCode = resp.StatusCode

return document
}

// responseHandler is capable of parsing a response. At a minimum it must
Expand Down

0 comments on commit 79871df

Please sign in to comment.