blob: d5dab7c6f313c79caa862d7f25623fed004898cb [file] [log] [blame] [edit]
package wrapngo
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/packethost/packngo"
"k8s.io/klog/v2"
)
// wrap a given fn in some reliability-increasing duct tape: context support and
// exponential backoff retries for intermittent connectivity issues. This allows
// us to use packngo code instead of writing our own API stub for Equinix Metal.
//
// The given fn will be retried until it returns a 'permanent' Equinix error (see
// isPermanentEquinixError) or the given context expires. Additionally, fn will
// be called with a brand new packngo client tied to the context of the wrap
// call. Finally, the given client will also have some logging middleware
// attached to it which can be activated by setting verbosity 5 (or greater) on
// this file.
//
// The wrapped fn can be either just a plain packngo method or some complicated
// idempotent logic, as long as it cooperates with the above contract.
func wrap[U any](ctx context.Context, cl *client, fn func(*packngo.Client) (U, error)) (U, error) {
var zero U
if err := cl.serializer.up(ctx); err != nil {
return zero, err
}
defer cl.serializer.down()
bc := backoff.WithContext(cl.o.BackOff(), ctx)
pngo, err := cl.clientForContext(ctx)
if err != nil {
// Generally this shouldn't happen other than with programming errors, so we
// don't back this off.
return zero, fmt.Errorf("could not crate equinix client: %w", err)
}
var res U
err = backoff.Retry(func() error {
res, err = fn(pngo)
if isPermanentEquinixError(err) {
return backoff.Permanent(err)
}
return err
}, bc)
if err != nil {
return zero, err
}
return res, nil
}
type injectContextRoundTripper struct {
ctx context.Context
original http.RoundTripper
metrics *metricsSet
}
func (r *injectContextRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
klog.V(5).Infof("Request -> %v", req.URL.String())
start := time.Now()
res, err := r.original.RoundTrip(req.WithContext(r.ctx))
latency := time.Since(start)
r.metrics.onAPIRequestDone(req, res, err, latency)
if err != nil {
klog.V(5).Infof("HTTP error <- %v", err)
} else {
klog.V(5).Infof("Response <- %v", res.Status)
}
return res, err
}
func (c *client) clientForContext(ctx context.Context) (*packngo.Client, error) {
httpcl := &http.Client{
Transport: &injectContextRoundTripper{
ctx: ctx,
original: http.DefaultTransport,
metrics: c.metrics,
},
}
return packngo.NewClient(packngo.WithAuth(c.username, c.token), packngo.WithHTTPClient(httpcl))
}
// httpStatusCode extracts the status code from error values returned by
// packngo methods.
func httpStatusCode(err error) int {
var er *packngo.ErrorResponse
if err != nil && errors.As(err, &er) {
return er.Response.StatusCode
}
return -1
}
// IsNotFound returns true if the given error is an Equinix packngo/wrapngo 'not
// found' error.
func IsNotFound(err error) bool {
return httpStatusCode(err) == http.StatusNotFound
}
func isPermanentEquinixError(err error) bool {
// Invalid argument/state errors from wrapping.
if errors.Is(err, ErrRaceLost) {
return true
}
if errors.Is(err, ErrNoReservationProvided) {
return true
}
// Real errors returned from equinix.
st := httpStatusCode(err)
switch st {
case http.StatusUnauthorized:
return true
case http.StatusForbidden:
return true
case http.StatusNotFound:
return true
case http.StatusUnprocessableEntity:
return true
}
return false
}