package http import ( "fmt" "io" "log" "net/http" "net/url" "os" "strings" ) // ErrNoAvailableHost indicates that the client could not find an available host to send the request to. var ErrNoAvailableHost = fmt.Errorf("no host available to perform the request") // ErrTooManyRedirects indicates that the client exceeded the maximum number of redirects var ErrTooManyRedirects = fmt.Errorf("maximum leader redirect limit exceeded") // HostChangedError indicates that the underlying request was executed on a different host // different from the caller anticipated type HostChangedError struct { NewHost string } func (he *HostChangedError) Error() string { return fmt.Sprintf("HostChangedErr: new host is '%s'", he.NewHost) } type ConfigFunc func(*Client) // Client is a wrapper around stock `http.Client` that adds "retry on another host" behaviour // based on the supplied configuration. // // The client will fall back and try other nodes when the current node is unavailable, and would stop trying // after exhausting the list of supplied hosts. // // Note: // // This type is not goroutine safe. // A node is considered unavailable if the client is not reachable via the network. // TODO: make the unavailability condition for the client more dynamic. type Client struct { *http.Client scheme string hosts []string Prefix string // creds stores the http basic authentication username and password creds string logger *log.Logger // currentHost keeps track of the last available host currentHost int maxRedirect int } // NewClient creates a default client that sends `execute` and query `requests` against the // rqlited nodes supplied via `hosts` argument. func NewClient(client *http.Client, hosts []string, configFuncs ...ConfigFunc) *Client { cl := &Client{ Client: client, hosts: hosts, scheme: "http", maxRedirect: 21, Prefix: "/", logger: log.New(os.Stderr, "[client] ", log.LstdFlags), } for _, f := range configFuncs { f(cl) } return cl } // WithScheme changes the default scheme used i.e "http". func WithScheme(scheme string) ConfigFunc { return func(client *Client) { client.scheme = scheme } } // WithPrefix sets the prefix to be used when issuing HTTP requests against one of // the rqlited nodes. func WithPrefix(prefix string) ConfigFunc { return func(client *Client) { client.Prefix = prefix } } // WithLogger changes the default logger to the one provided. func WithLogger(logger *log.Logger) ConfigFunc { return func(client *Client) { client.logger = logger } } // WithBasicAuth adds basic authentication behaviour to the client's request. func WithBasicAuth(creds string) ConfigFunc { return func(client *Client) { client.creds = creds } } // Query sends GET requests to one of the hosts known to the client. func (c *Client) Query(url url.URL) (*http.Response, error) { return c.execRequest(http.MethodGet, url, nil) } // Execute sends POST requests to one of the hosts known to the client func (c *Client) Execute(url url.URL, body io.Reader) (*http.Response, error) { return c.execRequest(http.MethodPost, url, body) } func (c *Client) execRequest(method string, url url.URL, body io.Reader) (*http.Response, error) { triedHosts := 0 for triedHosts < len(c.hosts) { host := c.hosts[c.currentHost] url.Scheme = c.scheme url.Host = host urlStr := url.String() resp, err := c.requestFollowRedirect(method, urlStr, body) // Found a responsive node if err == nil { if triedHosts > 0 { return resp, &HostChangedError{NewHost: host} } return resp, nil } // If we did too many redirects, we will consider the host as unavailable as well, // and we will retry the request from another host if err == ErrTooManyRedirects { c.logger.Printf("too many redirects from host: '%s'", host) } c.logger.Printf("host '%s' is unavailable, retrying with the next available host", host) triedHosts++ c.nextHost() } c.logger.Printf("none of the available hosts are responsive") return nil, ErrNoAvailableHost } func (c *Client) nextHost() { c.currentHost = (c.currentHost + 1) % len(c.hosts) } func (c *Client) requestFollowRedirect(method string, urlStr string, body io.Reader) (*http.Response, error) { nRedirects := 0 for { req, err := http.NewRequest(method, urlStr, body) if err != nil { return nil, err } err = c.setBasicAuth(req) if err != nil { return nil, err } resp, err := c.Client.Do(req) if err != nil { return nil, err } if resp.StatusCode == http.StatusMovedPermanently { nRedirects++ if nRedirects > c.maxRedirect { return resp, ErrTooManyRedirects } urlStr = resp.Header["Location"][0] continue } return resp, nil } } func (c *Client) setBasicAuth(req *http.Request) error { if c.creds == "" { return nil } creds := strings.Split(c.creds, ":") if len(creds) != 2 { return fmt.Errorf("invalid Basic Auth credential format") } req.SetBasicAuth(creds[0], creds[1]) return nil }