Skip to content

Commit 7aa2e59

Browse files
committed
Bridge API client
Issue: [sc-16285]
1 parent 50b699c commit 7aa2e59

File tree

2 files changed

+583
-0
lines changed

2 files changed

+583
-0
lines changed

internal/bridge/client.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/*
2+
Copyright 2021 - 2022 Crunchy Data Solutions, Inc.
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
*/
15+
16+
package bridge
17+
18+
import (
19+
"bytes"
20+
"context"
21+
"net/http"
22+
"net/url"
23+
"strconv"
24+
"time"
25+
26+
"k8s.io/apimachinery/pkg/util/uuid"
27+
"k8s.io/apimachinery/pkg/util/wait"
28+
)
29+
30+
const defaultAPI = "https://api.crunchybridge.com"
31+
32+
type Client struct {
33+
http.Client
34+
wait.Backoff
35+
36+
BaseURL url.URL
37+
Version string
38+
}
39+
40+
// NewClient creates a Client with backoff settings that amount to
41+
// ~10 attempts over ~2 minutes. A default is used when apiURL is not
42+
// an acceptable URL.
43+
func NewClient(apiURL, version string) *Client {
44+
// Use the default URL when the argument (1) does not parse at all, or
45+
// (2) has the wrong scheme, or (3) has no hostname.
46+
base, err := url.Parse(apiURL)
47+
if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Hostname() == "" {
48+
base, _ = url.Parse(defaultAPI)
49+
}
50+
51+
return &Client{
52+
Backoff: wait.Backoff{
53+
Duration: time.Second,
54+
Factor: 1.6,
55+
Jitter: 0.2,
56+
Steps: 10,
57+
Cap: time.Minute,
58+
},
59+
BaseURL: *base,
60+
Version: version,
61+
}
62+
}
63+
64+
// doWithBackoff performs HTTP requests until:
65+
// 1. ctx is cancelled,
66+
// 2. the server returns a status code below 500, "Internal Server Error", or
67+
// 3. the backoff is exhausted.
68+
//
69+
// Be sure to close the [http.Response] Body when the returned error is nil.
70+
// See [http.Client.Do] for more details.
71+
func (c *Client) doWithBackoff(
72+
ctx context.Context, method, path string, body []byte, headers http.Header,
73+
) (
74+
*http.Response, error,
75+
) {
76+
var response *http.Response
77+
78+
// Prepare a copy of the passed in headers so we can manipulate them.
79+
if headers = headers.Clone(); headers == nil {
80+
headers = make(http.Header)
81+
}
82+
83+
// Send a value that identifies this PATCH or POST request so it is safe to
84+
// retry when the server does not respond.
85+
// - https://docs.crunchybridge.com/api-concepts/idempotency/
86+
if method == http.MethodPatch || method == http.MethodPost {
87+
headers.Set("Idempotency-Key", string(uuid.NewUUID()))
88+
}
89+
90+
headers.Set("User-Agent", "PGO/"+c.Version)
91+
url := c.BaseURL.JoinPath(path).String()
92+
93+
err := wait.ExponentialBackoff(c.Backoff, func() (bool, error) {
94+
// NOTE: The [net/http] package treats an empty [bytes.Reader] the same as nil.
95+
request, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
96+
97+
if err == nil {
98+
request.Header = headers.Clone()
99+
100+
//nolint:bodyclose // This response is returned to the caller.
101+
response, err = c.Client.Do(request)
102+
}
103+
104+
// An error indicates there was no response from the server, and the
105+
// request may not have finished. The "Idempotency-Key" header above
106+
// makes it safe to retry in this case.
107+
finished := err == nil
108+
109+
// When the request finishes with a server error, discard the body and retry.
110+
// - https://docs.crunchybridge.com/api-concepts/getting-started/#status-codes
111+
if finished && response.StatusCode >= 500 {
112+
_ = response.Body.Close()
113+
finished = false
114+
}
115+
116+
// Stop when the context is cancelled.
117+
return finished, ctx.Err()
118+
})
119+
120+
// Discard the response body when there is a timeout from backoff.
121+
if response != nil && err != nil {
122+
_ = response.Body.Close()
123+
}
124+
125+
// Return the last response, if any.
126+
// Return the cancellation or timeout from backoff, if any.
127+
return response, err
128+
}
129+
130+
// doWithRetry performs HTTP requests until:
131+
// 1. ctx is cancelled,
132+
// 2. the server returns a status code below 500, "Internal Server Error",
133+
// that is not 429, "Too many requests", or
134+
// 3. the backoff is exhausted.
135+
//
136+
// Be sure to close the [http.Response] Body when the returned error is nil.
137+
// See [http.Client.Do] for more details.
138+
func (c *Client) doWithRetry(
139+
ctx context.Context, method, path string, body []byte, headers http.Header,
140+
) (
141+
*http.Response, error,
142+
) {
143+
response, err := c.doWithBackoff(ctx, method, path, body, headers)
144+
145+
// Retry the request when the server responds with "Too many requests".
146+
// - https://docs.crunchybridge.com/api-concepts/getting-started/#status-codes
147+
// - https://docs.crunchybridge.com/api-concepts/getting-started/#rate-limiting
148+
for err == nil && response.StatusCode == 429 {
149+
seconds, _ := strconv.Atoi(response.Header.Get("Retry-After"))
150+
151+
// Only retry when the response indicates how long to wait.
152+
if seconds <= 0 {
153+
break
154+
}
155+
156+
// Discard the "Too many requests" response body, and retry.
157+
_ = response.Body.Close()
158+
159+
// Create a channel that sends after the delay indicated by the API.
160+
timer := time.NewTimer(time.Duration(seconds) * time.Second)
161+
defer timer.Stop()
162+
163+
// Wait for the delay or context cancellation, whichever comes first.
164+
select {
165+
case <-timer.C:
166+
// Try the request again. Check it in the loop condition.
167+
response, err = c.doWithBackoff(ctx, method, path, body, headers)
168+
timer.Stop()
169+
170+
case <-ctx.Done():
171+
// Exit the loop and return the context cancellation.
172+
err = ctx.Err()
173+
}
174+
}
175+
176+
return response, err
177+
}

0 commit comments

Comments
 (0)