-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Handle validation errors for 204 endpoints
When the successful response to a client request is 204, it is possible that we'll parse an error response into successful (empty) result and not detect that it is an error. This change adds the validation error handler we've used in other tools such as the catalog-importer so that we detect errors properly. This was a report from a customer who saw that Terraform trying to delete a catalog type resulted in a validation error from our servers but the catalog type was never deleted (because the endpoint had 422'd).
- Loading branch information
1 parent
2182f3a
commit 88dcc84
Showing
2 changed files
with
124 additions
and
50 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
package client | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"io" | ||
"net/http" | ||
"time" | ||
|
||
"github.com/deepmap/oapi-codegen/pkg/securityprovider" | ||
"github.com/hashicorp/go-retryablehttp" | ||
"github.com/pkg/errors" | ||
) | ||
|
||
func New(ctx context.Context, apiKey, apiEndpoint, version string, opts ...ClientOption) (*ClientWithResponses, error) { | ||
bearerTokenProvider, bearerTokenProviderErr := securityprovider.NewSecurityProviderBearerToken(apiKey) | ||
if bearerTokenProviderErr != nil { | ||
return nil, bearerTokenProviderErr | ||
} | ||
|
||
retryClient := retryablehttp.NewClient() | ||
retryClient.RetryMax = maxRetries | ||
retryClient.Backoff = attentiveBackoff | ||
|
||
base := retryClient.StandardClient() | ||
|
||
// The generated client won't turn validation errors into actual errors, so we do this | ||
// inside of a generic middleware. | ||
base.Transport = Wrap(base.Transport, func(req *http.Request, next http.RoundTripper) (*http.Response, error) { | ||
resp, err := next.RoundTrip(req) | ||
if err != nil { | ||
return nil, err | ||
} | ||
if err == nil && resp.StatusCode > 299 { | ||
data, err := io.ReadAll(resp.Body) | ||
if err != nil { | ||
return nil, fmt.Errorf("status %d: no response body", resp.StatusCode) | ||
} | ||
|
||
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(data)) | ||
} | ||
|
||
return resp, err | ||
}) | ||
|
||
clientOpts := append([]ClientOption{ | ||
WithHTTPClient(base), | ||
WithRequestEditorFn(bearerTokenProvider.Intercept), | ||
// Add a user-agent so we can tell which version these requests came from. | ||
WithRequestEditorFn(func(ctx context.Context, req *http.Request) error { | ||
req.Header.Add("user-agent", fmt.Sprintf("terraform-provider-incident/%s", version)) | ||
return nil | ||
}), | ||
}, opts...) | ||
|
||
client, err := NewClientWithResponses(apiEndpoint, clientOpts...) | ||
if err != nil { | ||
return nil, errors.Wrap(err, "creating client") | ||
} | ||
|
||
return client, nil | ||
} | ||
|
||
const ( | ||
maxRetries = 10 | ||
) | ||
|
||
func attentiveBackoff(minDuration, maxDuration time.Duration, attemptNum int, resp *http.Response) time.Duration { | ||
// Retry for rate limits and server errors. | ||
if resp != nil && resp.StatusCode == http.StatusTooManyRequests { | ||
// Check for a 'Retry-After' header. | ||
retryAfter := resp.Header.Get("Retry-After") | ||
if retryAfter != "" { | ||
retryAfterDate, err := time.Parse(time.RFC1123, retryAfter) | ||
if err != nil { | ||
// If we can't parse the Retry-After, lets just wait for 10 seconds | ||
return 10 | ||
} | ||
|
||
timeToWait := time.Until(retryAfterDate) | ||
|
||
if timeToWait < 1*time.Second { | ||
// by default lets back off at least 1 second | ||
return 1 * time.Second | ||
} | ||
|
||
return timeToWait | ||
} | ||
|
||
} | ||
// otherwise use the default backoff | ||
return retryablehttp.DefaultBackoff(minDuration, maxDuration, attemptNum, resp) | ||
} | ||
|
||
// WithReadOnly restricts the client to GET requests only, useful when creating a client | ||
// for the purpose of dry-running. | ||
func WithReadOnly() ClientOption { | ||
return WithRequestEditorFn(func(ctx context.Context, req *http.Request) error { | ||
if req.Method != http.MethodGet { | ||
return fmt.Errorf("read-only client tried to make mutating request: %s %s", req.Method, req.URL.String()) | ||
} | ||
|
||
return nil | ||
}) | ||
} | ||
|
||
// RoundTripperFunc wraps a function to implement the RoundTripper interface, allowing | ||
// easy wrapping of existing round-trippers. | ||
type RoundTripperFunc func(req *http.Request) (*http.Response, error) | ||
|
||
func (f RoundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { | ||
return f(req) | ||
} | ||
|
||
// Wrap allows easy wrapping of an existing RoundTripper with a function that can | ||
// optionally call the original, or do its own thing. | ||
func Wrap(next http.RoundTripper, apply func(req *http.Request, next http.RoundTripper) (*http.Response, error)) http.RoundTripper { | ||
return RoundTripperFunc(func(req *http.Request) (*http.Response, error) { | ||
return apply(req, next) | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters