1
0
Fork 0
mirror of https://github.com/diamondburned/arikawa.git synced 2024-11-16 03:44:26 +00:00
arikawa/utils/httputil/client.go
Maximilian von Lindern 0a8b24339b
API: Added timeout if deadline is after rate limit (#173)
* Rate: don't sleep if sleep exceeds context deadline

* Httputil: add Client.Timeout

* Bot: set default API timeout to 5 minutes

* Rate: reduce calls to time.Now in Acquire

* API: Optimize to use deadline instead of recalculating

Co-authored-by: diamondburned <datutbrus@gmail.com>
2020-11-25 12:08:42 -08:00

236 lines
5.5 KiB
Go

// Package httputil provides abstractions around the common needs of HTTP. It
// also allows swapping in and out the HTTP client.
package httputil
import (
"bytes"
"context"
"io"
"mime/multipart"
"time"
"github.com/pkg/errors"
"github.com/diamondburned/arikawa/v2/utils/httputil/httpdriver"
"github.com/diamondburned/arikawa/v2/utils/json"
)
// StatusTooManyRequests is the HTTP status code discord sends on rate-limiting.
const StatusTooManyRequests = 429
// Retries is the default attempts to retry if the API returns an error before
// giving up. If the value is smaller than 1, then requests will retry forever.
var Retries uint = 5
type Client struct {
httpdriver.Client
SchemaEncoder
// OnRequest, if not nil, will be copied and prefixed on each Request.
OnRequest []RequestOption
// OnResponse is called after every Do() call. Response might be nil if Do()
// errors out. The error returned will override Do's if it's not nil.
OnResponse []ResponseFunc
// Timeout is the maximum amount of time the client will wait for a request
// to finish. If this is 0 or smaller the Client won't time out. Otherwise,
// the timeout will be used as deadline for context of every request.
Timeout time.Duration
// Default to the global Retries variable (5).
Retries uint
context context.Context
}
func NewClient() *Client {
return &Client{
Client: httpdriver.NewClient(),
SchemaEncoder: &DefaultSchema{},
Retries: Retries,
context: context.Background(),
}
}
// Copy returns a shallow copy of the client.
func (c *Client) Copy() *Client {
cl := new(Client)
*cl = *c
return cl
}
// WithContext returns a client copy of the client with the given context.
func (c *Client) WithContext(ctx context.Context) *Client {
c = c.Copy()
c.context = ctx
return c
}
// Context is a shared context for all future calls. It's Background by
// default.
func (c *Client) Context() context.Context {
return c.context
}
// applyOptions tries to apply all options. It does not halt if a single option
// fails, and the error returned is the latest error.
func (c *Client) applyOptions(r httpdriver.Request, extra []RequestOption) (e error) {
for _, opt := range c.OnRequest {
if err := opt(r); err != nil {
e = err
}
}
for _, opt := range extra {
if err := opt(r); err != nil {
e = err
}
}
return
}
func (c *Client) MeanwhileMultipart(
writer func(*multipart.Writer) error,
method, url string, opts ...RequestOption) (httpdriver.Response, error) {
r, w := io.Pipe()
body := multipart.NewWriter(w)
go func() { w.CloseWithError(writer(body)) }()
// Prepend the multipart writer and the correct Content-Type header options.
opts = PrependOptions(
opts,
WithBody(r),
WithContentType(body.FormDataContentType()),
)
// Request with the current client and our own context:
return c.Request(method, url, opts...)
}
func (c *Client) FastRequest(method, url string, opts ...RequestOption) error {
r, err := c.Request(method, url, opts...)
if err != nil {
return err
}
return r.GetBody().Close()
}
func (c *Client) RequestJSON(to interface{}, method, url string, opts ...RequestOption) error {
opts = PrependOptions(opts, JSONRequest)
r, err := c.Request(method, url, opts...)
if err != nil {
return err
}
var body, status = r.GetBody(), r.GetStatus()
defer body.Close()
// No content, working as intended (tm)
if status == httpdriver.NoContent {
return nil
}
if err := json.DecodeStream(body, to); err != nil {
return JSONError{err}
}
return nil
}
func (c *Client) Request(method, url string, opts ...RequestOption) (httpdriver.Response, error) {
var doErr error
var r httpdriver.Response
var status int
ctx := c.context
if c.Timeout > 0 {
var cancel func()
ctx, cancel = context.WithTimeout(ctx, c.Timeout)
defer cancel()
}
// The c.Retries < 1 check ensures that we retry forever if that field is
// less than 1.
for i := uint(0); c.Retries < 1 || i < c.Retries; i++ {
q, err := c.Client.NewRequest(ctx, method, url)
if err != nil {
return nil, RequestError{err}
}
if err := c.applyOptions(q, opts); err != nil {
// We failed to apply an option, so we should call all OnResponse
// handler to clean everything up.
for _, fn := range c.OnResponse {
fn(q, nil)
}
// Exit after cleaning everything up.
return nil, errors.Wrap(err, "failed to apply http request options")
}
r, doErr = c.Client.Do(q)
// Error that represents the latest error in the chain.
var onRespErr error
// Call OnResponse() even if the request failed.
for _, fn := range c.OnResponse {
// Be sure to call ALL OnResponse handlers.
if err := fn(q, r); err != nil {
onRespErr = err
}
}
if onRespErr != nil {
return nil, errors.Wrap(err, "OnResponse handler failed")
}
// Retry if the request failed.
if doErr != nil {
continue
}
if status = r.GetStatus(); status == StatusTooManyRequests || status >= 500 {
continue
}
break
}
// If all retries failed:
if doErr != nil {
return nil, RequestError{doErr}
}
// Response received, but with a failure status code:
if status < 200 || status > 299 {
// Try and parse the body.
var body = r.GetBody()
defer body.Close()
// This rarely happens, so we can (probably) make an exception for it.
buf := bytes.Buffer{}
buf.ReadFrom(body)
httpErr := &HTTPError{
Status: status,
Body: buf.Bytes(),
}
// Optionally unmarshal the error.
json.Unmarshal(httpErr.Body, &httpErr)
return nil, httpErr
}
return r, nil
}