231 lines
5.1 KiB
Go
231 lines
5.1 KiB
Go
package lib
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/juju/ratelimit"
|
|
)
|
|
|
|
const (
|
|
// Version of this libary
|
|
Version = "1.12.0"
|
|
|
|
// APIVersion of Vultr
|
|
APIVersion = "v1"
|
|
|
|
// DefaultEndpoint to be used
|
|
DefaultEndpoint = "https://api.vultr.com/"
|
|
|
|
mediaType = "application/json"
|
|
)
|
|
|
|
// retryableStatusCodes are API response status codes that indicate that
|
|
// the failed request can be retried without further actions.
|
|
var retryableStatusCodes = map[int]struct{}{
|
|
503: {}, // Rate limit hit
|
|
500: {}, // Internal server error. Try again at a later time.
|
|
}
|
|
|
|
// Client represents the Vultr API client
|
|
type Client struct {
|
|
// HTTP client for communication with the Vultr API
|
|
client *http.Client
|
|
|
|
// User agent for HTTP client
|
|
UserAgent string
|
|
|
|
// Endpoint URL for API requests
|
|
Endpoint *url.URL
|
|
|
|
// API key for accessing the Vultr API
|
|
APIKey string
|
|
|
|
// Max. number of request attempts
|
|
MaxAttempts int
|
|
|
|
// Throttling struct
|
|
bucket *ratelimit.Bucket
|
|
}
|
|
|
|
// Options represents optional settings and flags that can be passed to NewClient
|
|
type Options struct {
|
|
// HTTP client for communication with the Vultr API
|
|
HTTPClient *http.Client
|
|
|
|
// User agent for HTTP client
|
|
UserAgent string
|
|
|
|
// Endpoint URL for API requests
|
|
Endpoint string
|
|
|
|
// API rate limitation, calls per duration
|
|
RateLimitation time.Duration
|
|
|
|
// Max. number of times to retry API calls
|
|
MaxRetries int
|
|
}
|
|
|
|
// NewClient creates new Vultr API client. Options are optional and can be nil.
|
|
func NewClient(apiKey string, options *Options) *Client {
|
|
userAgent := "vultr-go/" + Version
|
|
transport := &http.Transport{
|
|
TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper),
|
|
}
|
|
client := http.DefaultClient
|
|
client.Transport = transport
|
|
endpoint, _ := url.Parse(DefaultEndpoint)
|
|
rate := 505 * time.Millisecond
|
|
attempts := 1
|
|
|
|
if options != nil {
|
|
if options.HTTPClient != nil {
|
|
client = options.HTTPClient
|
|
}
|
|
if options.UserAgent != "" {
|
|
userAgent = options.UserAgent
|
|
}
|
|
if options.Endpoint != "" {
|
|
endpoint, _ = url.Parse(options.Endpoint)
|
|
}
|
|
if options.RateLimitation != 0 {
|
|
rate = options.RateLimitation
|
|
}
|
|
if options.MaxRetries != 0 {
|
|
attempts = options.MaxRetries + 1
|
|
}
|
|
}
|
|
|
|
return &Client{
|
|
UserAgent: userAgent,
|
|
client: client,
|
|
Endpoint: endpoint,
|
|
APIKey: apiKey,
|
|
MaxAttempts: attempts,
|
|
bucket: ratelimit.NewBucket(rate, 1),
|
|
}
|
|
}
|
|
|
|
func apiPath(path string) string {
|
|
return fmt.Sprintf("/%s/%s", APIVersion, path)
|
|
}
|
|
|
|
func apiKeyPath(path, apiKey string) string {
|
|
if strings.Contains(path, "?") {
|
|
return path + "&api_key=" + apiKey
|
|
}
|
|
return path + "?api_key=" + apiKey
|
|
}
|
|
|
|
func (c *Client) get(path string, data interface{}) error {
|
|
req, err := c.newRequest("GET", apiPath(path), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, data)
|
|
}
|
|
|
|
func (c *Client) post(path string, values url.Values, data interface{}) error {
|
|
req, err := c.newRequest("POST", apiPath(path), strings.NewReader(values.Encode()))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, data)
|
|
}
|
|
|
|
func (c *Client) newRequest(method string, path string, body io.Reader) (*http.Request, error) {
|
|
relPath, err := url.Parse(apiKeyPath(path, c.APIKey))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
url := c.Endpoint.ResolveReference(relPath)
|
|
|
|
req, err := http.NewRequest(method, url.String(), body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Header.Add("User-Agent", c.UserAgent)
|
|
req.Header.Add("Accept", mediaType)
|
|
|
|
if req.Method == "POST" {
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
func (c *Client) do(req *http.Request, data interface{}) error {
|
|
// Throttle http requests to avoid hitting Vultr's API rate-limit
|
|
c.bucket.Wait(1)
|
|
|
|
var apiError error
|
|
for tryCount := 1; tryCount <= c.MaxAttempts; tryCount++ {
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
body, err := ioutil.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusOK {
|
|
if data != nil {
|
|
// avoid unmarshalling problem because Vultr API returns
|
|
// empty array instead of empty map when it shouldn't!
|
|
if string(body) == `[]` {
|
|
data = nil
|
|
} else {
|
|
if err := json.Unmarshal(body, data); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
apiError = errors.New(string(body))
|
|
if !isCodeRetryable(resp.StatusCode) {
|
|
break
|
|
}
|
|
|
|
delay := backoffDuration(tryCount)
|
|
time.Sleep(delay)
|
|
}
|
|
|
|
return apiError
|
|
}
|
|
|
|
// backoffDuration returns the duration to wait before retrying the request.
|
|
// Duration is an exponential function of the retry count with a jitter of ~0-30%.
|
|
func backoffDuration(retryCount int) time.Duration {
|
|
// Upper limit of delay at ~1 minute
|
|
if retryCount > 7 {
|
|
retryCount = 7
|
|
}
|
|
|
|
rand.Seed(time.Now().UnixNano())
|
|
delay := (1 << uint(retryCount)) * (rand.Intn(150) + 500)
|
|
return time.Duration(delay) * time.Millisecond
|
|
}
|
|
|
|
// isCodeRetryable returns true if the given status code means that we should retry.
|
|
func isCodeRetryable(statusCode int) bool {
|
|
if _, ok := retryableStatusCodes[statusCode]; ok {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|