1// Copyright 2016 Circonus, Inc. All rights reserved.
2// Use of this source code is governed by a BSD-style
3// license that can be found in the LICENSE file.
4
5package api
6
7import (
8	"bytes"
9	"context"
10	crand "crypto/rand"
11	"crypto/tls"
12	"crypto/x509"
13	"errors"
14	"fmt"
15	"io/ioutil"
16	"log"
17	"math"
18	"math/big"
19	"math/rand"
20	"net"
21	"net/http"
22	"net/url"
23	"os"
24	"strings"
25	"sync"
26	"time"
27
28	"github.com/hashicorp/go-retryablehttp"
29)
30
31func init() {
32	n, err := crand.Int(crand.Reader, big.NewInt(math.MaxInt64))
33	if err != nil {
34		rand.Seed(time.Now().UTC().UnixNano())
35		return
36	}
37	rand.Seed(n.Int64())
38}
39
40const (
41	// a few sensible defaults
42	defaultAPIURL = "https://api.circonus.com/v2"
43	defaultAPIApp = "circonus-gometrics"
44	minRetryWait  = 1 * time.Second
45	maxRetryWait  = 15 * time.Second
46	maxRetries    = 4 // equating to 1 + maxRetries total attempts
47)
48
49// TokenKeyType - Circonus API Token key
50type TokenKeyType string
51
52// TokenAppType - Circonus API Token app name
53type TokenAppType string
54
55// TokenAccountIDType - Circonus API Token account id
56type TokenAccountIDType string
57
58// CIDType Circonus object cid
59type CIDType *string
60
61// IDType Circonus object id
62type IDType int
63
64// URLType submission url type
65type URLType string
66
67// SearchQueryType search query (see: https://login.circonus.com/resources/api#searching)
68type SearchQueryType string
69
70// SearchFilterType search filter (see: https://login.circonus.com/resources/api#filtering)
71type SearchFilterType map[string][]string
72
73// TagType search/select/custom tag(s) type
74type TagType []string
75
76// Config options for Circonus API
77type Config struct {
78	// URL defines the API URL - default https://api.circonus.com/v2/
79	URL string
80
81	// TokenKey defines the key to use when communicating with the API
82	TokenKey string
83
84	// TokenApp defines the app to use when communicating with the API
85	TokenApp string
86
87	TokenAccountID string
88
89	// CACert deprecating, use TLSConfig instead
90	CACert *x509.CertPool
91
92	// TLSConfig defines a custom tls configuration to use when communicating with the API
93	TLSConfig *tls.Config
94
95	Log   *log.Logger
96	Debug bool
97}
98
99// API Circonus API
100type API struct {
101	apiURL                  *url.URL
102	key                     TokenKeyType
103	app                     TokenAppType
104	accountID               TokenAccountIDType
105	caCert                  *x509.CertPool
106	tlsConfig               *tls.Config
107	Debug                   bool
108	Log                     *log.Logger
109	useExponentialBackoff   bool
110	useExponentialBackoffmu sync.Mutex
111}
112
113// NewClient returns a new Circonus API (alias for New)
114func NewClient(ac *Config) (*API, error) {
115	return New(ac)
116}
117
118// NewAPI returns a new Circonus API (alias for New)
119func NewAPI(ac *Config) (*API, error) {
120	return New(ac)
121}
122
123// New returns a new Circonus API
124func New(ac *Config) (*API, error) {
125
126	if ac == nil {
127		return nil, errors.New("Invalid API configuration (nil)")
128	}
129
130	key := TokenKeyType(ac.TokenKey)
131	if key == "" {
132		return nil, errors.New("API Token is required")
133	}
134
135	app := TokenAppType(ac.TokenApp)
136	if app == "" {
137		app = defaultAPIApp
138	}
139
140	acctID := TokenAccountIDType(ac.TokenAccountID)
141
142	au := string(ac.URL)
143	if au == "" {
144		au = defaultAPIURL
145	}
146	if !strings.Contains(au, "/") {
147		// if just a hostname is passed, ASSume "https" and a path prefix of "/v2"
148		au = fmt.Sprintf("https://%s/v2", ac.URL)
149	}
150	if last := len(au) - 1; last >= 0 && au[last] == '/' {
151		// strip off trailing '/'
152		au = au[:last]
153	}
154	apiURL, err := url.Parse(au)
155	if err != nil {
156		return nil, err
157	}
158
159	a := &API{
160		apiURL:    apiURL,
161		key:       key,
162		app:       app,
163		accountID: acctID,
164		caCert:    ac.CACert,
165		tlsConfig: ac.TLSConfig,
166		Debug:     ac.Debug,
167		Log:       ac.Log,
168		useExponentialBackoff: false,
169	}
170
171	a.Debug = ac.Debug
172	a.Log = ac.Log
173	if a.Debug && a.Log == nil {
174		a.Log = log.New(os.Stderr, "", log.LstdFlags)
175	}
176	if a.Log == nil {
177		a.Log = log.New(ioutil.Discard, "", log.LstdFlags)
178	}
179
180	return a, nil
181}
182
183// EnableExponentialBackoff enables use of exponential backoff for next API call(s)
184// and use exponential backoff for all API calls until exponential backoff is disabled.
185func (a *API) EnableExponentialBackoff() {
186	a.useExponentialBackoffmu.Lock()
187	a.useExponentialBackoff = true
188	a.useExponentialBackoffmu.Unlock()
189}
190
191// DisableExponentialBackoff disables use of exponential backoff. If a request using
192// exponential backoff is currently running, it will stop using exponential backoff
193// on its next iteration (if needed).
194func (a *API) DisableExponentialBackoff() {
195	a.useExponentialBackoffmu.Lock()
196	a.useExponentialBackoff = false
197	a.useExponentialBackoffmu.Unlock()
198}
199
200// Get API request
201func (a *API) Get(reqPath string) ([]byte, error) {
202	return a.apiRequest("GET", reqPath, nil)
203}
204
205// Delete API request
206func (a *API) Delete(reqPath string) ([]byte, error) {
207	return a.apiRequest("DELETE", reqPath, nil)
208}
209
210// Post API request
211func (a *API) Post(reqPath string, data []byte) ([]byte, error) {
212	return a.apiRequest("POST", reqPath, data)
213}
214
215// Put API request
216func (a *API) Put(reqPath string, data []byte) ([]byte, error) {
217	return a.apiRequest("PUT", reqPath, data)
218}
219
220func backoff(interval uint) float64 {
221	return math.Floor(((float64(interval) * (1 + rand.Float64())) / 2) + .5)
222}
223
224// apiRequest manages retry strategy for exponential backoffs
225func (a *API) apiRequest(reqMethod string, reqPath string, data []byte) ([]byte, error) {
226	backoffs := []uint{2, 4, 8, 16, 32}
227	attempts := 0
228	success := false
229
230	var result []byte
231	var err error
232
233	for !success {
234		result, err = a.apiCall(reqMethod, reqPath, data)
235		if err == nil {
236			success = true
237		}
238
239		// break and return error if not using exponential backoff
240		if err != nil {
241			if !a.useExponentialBackoff {
242				break
243			}
244			if strings.Contains(err.Error(), "code 403") {
245				break
246			}
247		}
248
249		if !success {
250			var wait float64
251			if attempts >= len(backoffs) {
252				wait = backoff(backoffs[len(backoffs)-1])
253			} else {
254				wait = backoff(backoffs[attempts])
255			}
256			attempts++
257			a.Log.Printf("[WARN] API call failed %s, retrying in %d seconds.\n", err.Error(), uint(wait))
258			time.Sleep(time.Duration(wait) * time.Second)
259		}
260	}
261
262	return result, err
263}
264
265// apiCall call Circonus API
266func (a *API) apiCall(reqMethod string, reqPath string, data []byte) ([]byte, error) {
267	reqURL := a.apiURL.String()
268
269	if reqPath == "" {
270		return nil, errors.New("Invalid URL path")
271	}
272	if reqPath[:1] != "/" {
273		reqURL += "/"
274	}
275	if len(reqPath) >= 3 && reqPath[:3] == "/v2" {
276		reqURL += reqPath[3:]
277	} else {
278		reqURL += reqPath
279	}
280
281	// keep last HTTP error in the event of retry failure
282	var lastHTTPError error
283	retryPolicy := func(ctx context.Context, resp *http.Response, err error) (bool, error) {
284		if ctxErr := ctx.Err(); ctxErr != nil {
285			return false, ctxErr
286		}
287
288		if err != nil {
289			lastHTTPError = err
290			return true, err
291		}
292		// Check the response code. We retry on 500-range responses to allow
293		// the server time to recover, as 500's are typically not permanent
294		// errors and may relate to outages on the server side. This will catch
295		// invalid response codes as well, like 0 and 999.
296		// Retry on 429 (rate limit) as well.
297		if resp.StatusCode == 0 || // wtf?!
298			resp.StatusCode >= 500 || // rutroh
299			resp.StatusCode == 429 { // rate limit
300			body, readErr := ioutil.ReadAll(resp.Body)
301			if readErr != nil {
302				lastHTTPError = fmt.Errorf("- response: %d %s", resp.StatusCode, readErr.Error())
303			} else {
304				lastHTTPError = fmt.Errorf("- response: %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
305			}
306			return true, nil
307		}
308		return false, nil
309	}
310
311	dataReader := bytes.NewReader(data)
312
313	req, err := retryablehttp.NewRequest(reqMethod, reqURL, dataReader)
314	if err != nil {
315		return nil, fmt.Errorf("[ERROR] creating API request: %s %+v", reqURL, err)
316	}
317	req.Header.Add("Accept", "application/json")
318	req.Header.Add("X-Circonus-Auth-Token", string(a.key))
319	req.Header.Add("X-Circonus-App-Name", string(a.app))
320	if string(a.accountID) != "" {
321		req.Header.Add("X-Circonus-Account-ID", string(a.accountID))
322	}
323
324	client := retryablehttp.NewClient()
325	if a.apiURL.Scheme == "https" {
326		var tlscfg *tls.Config
327		if a.tlsConfig != nil { // preference full custom tls config
328			tlscfg = a.tlsConfig
329		} else if a.caCert != nil {
330			tlscfg = &tls.Config{RootCAs: a.caCert}
331		}
332		client.HTTPClient.Transport = &http.Transport{
333			Proxy: http.ProxyFromEnvironment,
334			Dial: (&net.Dialer{
335				Timeout:   30 * time.Second,
336				KeepAlive: 30 * time.Second,
337			}).Dial,
338			TLSHandshakeTimeout: 10 * time.Second,
339			TLSClientConfig:     tlscfg,
340			DisableKeepAlives:   true,
341			MaxIdleConnsPerHost: -1,
342			DisableCompression:  true,
343		}
344	} else {
345		client.HTTPClient.Transport = &http.Transport{
346			Proxy: http.ProxyFromEnvironment,
347			Dial: (&net.Dialer{
348				Timeout:   30 * time.Second,
349				KeepAlive: 30 * time.Second,
350			}).Dial,
351			TLSHandshakeTimeout: 10 * time.Second,
352			DisableKeepAlives:   true,
353			MaxIdleConnsPerHost: -1,
354			DisableCompression:  true,
355		}
356	}
357
358	a.useExponentialBackoffmu.Lock()
359	eb := a.useExponentialBackoff
360	a.useExponentialBackoffmu.Unlock()
361
362	if eb {
363		// limit to one request if using exponential backoff
364		client.RetryWaitMin = 1
365		client.RetryWaitMax = 2
366		client.RetryMax = 0
367	} else {
368		client.RetryWaitMin = minRetryWait
369		client.RetryWaitMax = maxRetryWait
370		client.RetryMax = maxRetries
371	}
372
373	// retryablehttp only groks log or no log
374	if a.Debug {
375		client.Logger = a.Log
376	} else {
377		client.Logger = log.New(ioutil.Discard, "", log.LstdFlags)
378	}
379
380	client.CheckRetry = retryPolicy
381
382	resp, err := client.Do(req)
383	if err != nil {
384		if lastHTTPError != nil {
385			return nil, lastHTTPError
386		}
387		return nil, fmt.Errorf("[ERROR] %s: %+v", reqURL, err)
388	}
389
390	defer resp.Body.Close()
391	body, err := ioutil.ReadAll(resp.Body)
392	if err != nil {
393		return nil, fmt.Errorf("[ERROR] reading response %+v", err)
394	}
395
396	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
397		msg := fmt.Sprintf("API response code %d: %s", resp.StatusCode, string(body))
398		if a.Debug {
399			a.Log.Printf("[DEBUG] %s\n", msg)
400		}
401
402		return nil, fmt.Errorf("[ERROR] %s", msg)
403	}
404
405	return body, nil
406}
407