blob: 21581ce3f32e64aed034b91301d73d3fccdf853d [file] [log] [blame]
Tim Windelschmidt6d33a432025-02-04 14:34:25 +01001// Copyright The Monogon Project Authors.
2// SPDX-License-Identifier: Apache-2.0
3
Mateusz Zalega6a058e72022-11-30 18:03:07 +01004package wrapngo
5
6import (
7 "context"
8 "errors"
9 "fmt"
10 "net/http"
Serge Bazanskidea7cd02023-04-26 13:58:17 +020011 "time"
Mateusz Zalega6a058e72022-11-30 18:03:07 +010012
13 "github.com/cenkalti/backoff/v4"
14 "github.com/packethost/packngo"
15 "k8s.io/klog/v2"
16)
17
18// wrap a given fn in some reliability-increasing duct tape: context support and
19// exponential backoff retries for intermittent connectivity issues. This allows
20// us to use packngo code instead of writing our own API stub for Equinix Metal.
21//
22// The given fn will be retried until it returns a 'permanent' Equinix error (see
23// isPermanentEquinixError) or the given context expires. Additionally, fn will
24// be called with a brand new packngo client tied to the context of the wrap
25// call. Finally, the given client will also have some logging middleware
26// attached to it which can be activated by setting verbosity 5 (or greater) on
27// this file.
28//
29// The wrapped fn can be either just a plain packngo method or some complicated
30// idempotent logic, as long as it cooperates with the above contract.
31func wrap[U any](ctx context.Context, cl *client, fn func(*packngo.Client) (U, error)) (U, error) {
32 var zero U
Serge Bazanskidea7cd02023-04-26 13:58:17 +020033 if err := cl.serializer.up(ctx); err != nil {
34 return zero, err
Mateusz Zalega6a058e72022-11-30 18:03:07 +010035 }
Serge Bazanskidea7cd02023-04-26 13:58:17 +020036 defer cl.serializer.down()
Mateusz Zalega6a058e72022-11-30 18:03:07 +010037
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
Serge Bazanskidea7cd02023-04-26 13:58:17 +020063 metrics *metricsSet
Mateusz Zalega6a058e72022-11-30 18:03:07 +010064}
65
66func (r *injectContextRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
67 klog.V(5).Infof("Request -> %v", req.URL.String())
Serge Bazanskidea7cd02023-04-26 13:58:17 +020068 start := time.Now()
Mateusz Zalega6a058e72022-11-30 18:03:07 +010069 res, err := r.original.RoundTrip(req.WithContext(r.ctx))
Serge Bazanskidea7cd02023-04-26 13:58:17 +020070 latency := time.Since(start)
71 r.metrics.onAPIRequestDone(req, res, err, latency)
72
Serge Bazanskib78919e2023-04-17 15:51:58 +020073 if err != nil {
74 klog.V(5).Infof("HTTP error <- %v", err)
75 } else {
76 klog.V(5).Infof("Response <- %v", res.Status)
77 }
Mateusz Zalega6a058e72022-11-30 18:03:07 +010078 return res, err
79}
80
81func (c *client) clientForContext(ctx context.Context) (*packngo.Client, error) {
82 httpcl := &http.Client{
83 Transport: &injectContextRoundTripper{
84 ctx: ctx,
85 original: http.DefaultTransport,
Serge Bazanskidea7cd02023-04-26 13:58:17 +020086 metrics: c.metrics,
Mateusz Zalega6a058e72022-11-30 18:03:07 +010087 },
88 }
89 return packngo.NewClient(packngo.WithAuth(c.username, c.token), packngo.WithHTTPClient(httpcl))
90}
91
92// httpStatusCode extracts the status code from error values returned by
93// packngo methods.
94func httpStatusCode(err error) int {
95 var er *packngo.ErrorResponse
96 if err != nil && errors.As(err, &er) {
97 return er.Response.StatusCode
98 }
99 return -1
100}
101
102// IsNotFound returns true if the given error is an Equinix packngo/wrapngo 'not
103// found' error.
104func IsNotFound(err error) bool {
105 return httpStatusCode(err) == http.StatusNotFound
106}
107
108func isPermanentEquinixError(err error) bool {
109 // Invalid argument/state errors from wrapping.
110 if errors.Is(err, ErrRaceLost) {
111 return true
112 }
113 if errors.Is(err, ErrNoReservationProvided) {
114 return true
115 }
116 // Real errors returned from equinix.
117 st := httpStatusCode(err)
118 switch st {
119 case http.StatusUnauthorized:
120 return true
121 case http.StatusForbidden:
122 return true
123 case http.StatusNotFound:
124 return true
125 case http.StatusUnprocessableEntity:
126 return true
127 }
128 return false
129}