blob: c9e156be5fb94bea6b54da51b6809c9ff83f5e8c [file] [log] [blame]
Mateusz Zalega6a058e72022-11-30 18:03:07 +01001package wrapngo
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "net/http"
8
9 "github.com/cenkalti/backoff/v4"
10 "github.com/packethost/packngo"
11 "k8s.io/klog/v2"
12)
13
14// wrap a given fn in some reliability-increasing duct tape: context support and
15// exponential backoff retries for intermittent connectivity issues. This allows
16// us to use packngo code instead of writing our own API stub for Equinix Metal.
17//
18// The given fn will be retried until it returns a 'permanent' Equinix error (see
19// isPermanentEquinixError) or the given context expires. Additionally, fn will
20// be called with a brand new packngo client tied to the context of the wrap
21// call. Finally, the given client will also have some logging middleware
22// attached to it which can be activated by setting verbosity 5 (or greater) on
23// this file.
24//
25// The wrapped fn can be either just a plain packngo method or some complicated
26// idempotent logic, as long as it cooperates with the above contract.
27func wrap[U any](ctx context.Context, cl *client, fn func(*packngo.Client) (U, error)) (U, error) {
28 var zero U
29 select {
30 case cl.serializer <- struct{}{}:
31 case <-ctx.Done():
32 return zero, ctx.Err()
33 }
34 defer func() {
35 <-cl.serializer
36 }()
37
38 bc := backoff.WithContext(cl.o.BackOff(), ctx)
39 pngo, err := cl.clientForContext(ctx)
40 if err != nil {
41 // Generally this shouldn't happen other than with programming errors, so we
42 // don't back this off.
43 return zero, fmt.Errorf("could not crate equinix client: %w", err)
44 }
45
46 var res U
47 err = backoff.Retry(func() error {
48 res, err = fn(pngo)
49 if isPermanentEquinixError(err) {
50 return backoff.Permanent(err)
51 }
52 return err
53 }, bc)
54 if err != nil {
55 return zero, err
56 }
57 return res, nil
58}
59
60type injectContextRoundTripper struct {
61 ctx context.Context
62 original http.RoundTripper
63}
64
65func (r *injectContextRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
66 klog.V(5).Infof("Request -> %v", req.URL.String())
67 res, err := r.original.RoundTrip(req.WithContext(r.ctx))
68 klog.V(5).Infof("Response <- %v", res.Status)
69 return res, err
70}
71
72func (c *client) clientForContext(ctx context.Context) (*packngo.Client, error) {
73 httpcl := &http.Client{
74 Transport: &injectContextRoundTripper{
75 ctx: ctx,
76 original: http.DefaultTransport,
77 },
78 }
79 return packngo.NewClient(packngo.WithAuth(c.username, c.token), packngo.WithHTTPClient(httpcl))
80}
81
82// httpStatusCode extracts the status code from error values returned by
83// packngo methods.
84func httpStatusCode(err error) int {
85 var er *packngo.ErrorResponse
86 if err != nil && errors.As(err, &er) {
87 return er.Response.StatusCode
88 }
89 return -1
90}
91
92// IsNotFound returns true if the given error is an Equinix packngo/wrapngo 'not
93// found' error.
94func IsNotFound(err error) bool {
95 return httpStatusCode(err) == http.StatusNotFound
96}
97
98func isPermanentEquinixError(err error) bool {
99 // Invalid argument/state errors from wrapping.
100 if errors.Is(err, ErrRaceLost) {
101 return true
102 }
103 if errors.Is(err, ErrNoReservationProvided) {
104 return true
105 }
106 // Real errors returned from equinix.
107 st := httpStatusCode(err)
108 switch st {
109 case http.StatusUnauthorized:
110 return true
111 case http.StatusForbidden:
112 return true
113 case http.StatusNotFound:
114 return true
115 case http.StatusUnprocessableEntity:
116 return true
117 }
118 return false
119}