| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391 |
- package prom
- import (
- "bytes"
- "context"
- "io"
- "math"
- "net/http"
- "net/url"
- "sync"
- "testing"
- "time"
- "github.com/opencost/opencost/core/pkg/util"
- "github.com/opencost/opencost/core/pkg/util/httputil"
- prometheus "github.com/prometheus/client_golang/api"
- )
- // ResponseAndBody is just a test objet used to hold predefined responses
- // and response bodies
- type ResponseAndBody struct {
- Response *http.Response
- Body []byte
- }
- // MockPromClient accepts a slice of responses and bodies to return on requests made.
- // It will cycle these responses linearly, then reset back to the first.
- // Also works with concurrent requests.
- type MockPromClient struct {
- sync.Mutex
- responses []*ResponseAndBody
- current int
- }
- // prometheus.Client URL()
- func (mpc *MockPromClient) URL(ep string, args map[string]string) *url.URL {
- return nil
- }
- // prometheus.Client Do
- func (mpc *MockPromClient) Do(context.Context, *http.Request) (*http.Response, []byte, error) {
- // fake latency
- time.Sleep(250 * time.Millisecond)
- mpc.Lock()
- defer mpc.Unlock()
- rnb := mpc.responses[mpc.current]
- mpc.current++
- if mpc.current >= len(mpc.responses) {
- mpc.current = 0
- }
- return rnb.Response, rnb.Body, nil
- }
- // Creates a new mock prometheus client
- func newMockPromClientWith(responses []*ResponseAndBody) prometheus.Client {
- return &MockPromClient{
- responses: responses,
- current: 0,
- }
- }
- // creates a ResponseAndBody representing a 200 status code
- func newSuccessfulResponse() *ResponseAndBody {
- body := []byte("Success")
- return &ResponseAndBody{
- Response: &http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewReader(body)),
- },
- Body: body,
- }
- }
- // creates a ResponseAndBody representing a 400 status code
- func newFailureResponse() *ResponseAndBody {
- body := []byte("Fail")
- return &ResponseAndBody{
- Response: &http.Response{
- StatusCode: 400,
- Body: io.NopCloser(bytes.NewReader(body)),
- },
- Body: body,
- }
- }
- // creates a ResponseAndBody representing a 429 status code and 'Retry-After' header
- func newNormalRateLimitedResponse(retryAfter string) *ResponseAndBody {
- body := []byte("Rate Limitted")
- return &ResponseAndBody{
- Response: &http.Response{
- StatusCode: 429,
- Header: http.Header{
- "Retry-After": []string{retryAfter},
- },
- Body: io.NopCloser(bytes.NewReader(body)),
- },
- Body: body,
- }
- }
- // creates a ResponseAndBody representing some amazon services ThrottlingException 400 status
- func newHackyAmazonRateLimitedResponse() *ResponseAndBody {
- body := []byte("<ThrottlingException>\n <Message>Rate exceeded</Message>\n</ThrottlingException>\n")
- return &ResponseAndBody{
- Response: &http.Response{
- StatusCode: 400,
- Body: io.NopCloser(bytes.NewReader(body)),
- },
- Body: body,
- }
- }
- func newTestRetryOpts() *RateLimitRetryOpts {
- return &RateLimitRetryOpts{
- MaxRetries: 5,
- DefaultRetryWait: 100 * time.Millisecond,
- }
- }
- func TestRateLimitedOnceAndSuccess(t *testing.T) {
- t.Parallel()
- // creates a prom client with hard coded responses for any requests that
- // are issued
- promClient := newMockPromClientWith([]*ResponseAndBody{
- newNormalRateLimitedResponse("2"),
- newSuccessfulResponse(),
- })
- client, err := NewRateLimitedClient(
- "TestClient",
- promClient,
- 1,
- nil,
- nil,
- newTestRetryOpts(),
- "",
- "",
- )
- if err != nil {
- t.Fatal(err)
- }
- req, err := http.NewRequest(http.MethodPost, "", nil)
- if err != nil {
- t.Fatal(err)
- }
- // we just need to execute this once to see retries in effect
- res, body, err := client.Do(context.Background(), req)
- if err != nil {
- t.Fatal(err)
- }
- if res.StatusCode != 200 {
- t.Fatalf("200 StatusCode expected. Got: %d", res.StatusCode)
- }
- if string(body) != "Success" {
- t.Fatalf("Expected 'Success' message body. Got: %s", string(body))
- }
- }
- func TestRateLimitedOnceAndFail(t *testing.T) {
- t.Parallel()
- // creates a prom client with hard coded responses for any requests that
- // are issued
- promClient := newMockPromClientWith([]*ResponseAndBody{
- newNormalRateLimitedResponse("2"),
- newFailureResponse(),
- })
- client, err := NewRateLimitedClient(
- "TestClient",
- promClient,
- 1,
- nil,
- nil,
- newTestRetryOpts(),
- "",
- "",
- )
- if err != nil {
- t.Fatal(err)
- }
- req, err := http.NewRequest(http.MethodPost, "", nil)
- if err != nil {
- t.Fatal(err)
- }
- // we just need to execute this once to see retries in effect
- res, body, err := client.Do(context.Background(), req)
- if err != nil {
- t.Fatal(err)
- }
- if res.StatusCode != 400 {
- t.Fatalf("400 StatusCode expected. Got: %d", res.StatusCode)
- }
- if string(body) != "Fail" {
- t.Fatalf("Expected 'fail' message body. Got: %s", string(body))
- }
- }
- func TestRateLimitedResponses(t *testing.T) {
- t.Parallel()
- dateRetry := time.Now().Add(5 * time.Second).Format(time.RFC1123)
- // creates a prom client with hard coded responses for any requests that
- // are issued
- promClient := newMockPromClientWith([]*ResponseAndBody{
- newNormalRateLimitedResponse("2"),
- newNormalRateLimitedResponse(dateRetry),
- newHackyAmazonRateLimitedResponse(),
- newHackyAmazonRateLimitedResponse(),
- newNormalRateLimitedResponse("3"),
- })
- client, err := NewRateLimitedClient(
- "TestClient",
- promClient,
- 1,
- nil,
- nil,
- newTestRetryOpts(),
- "",
- "",
- )
- if err != nil {
- t.Fatal(err)
- }
- req, err := http.NewRequest(http.MethodPost, "", nil)
- if err != nil {
- t.Fatal(err)
- }
- // we just need to execute this once to see retries in effect
- _, _, err = client.Do(context.Background(), req)
- if err == nil {
- t.Fatal("Expected a RateLimitedResponseError. Err was nil.")
- }
- rateLimitErr, ok := err.(*RateLimitedResponseError)
- if !ok {
- t.Fatal("Expected a RateLimitedResponseError. Got unexpected type.")
- }
- t.Logf("%s\n", rateLimitErr.Error())
- // RateLimitedResponseStatus checks just ensure that wait times were close configuration
- rateLimitRetries := rateLimitErr.RateLimitStatus
- if len(rateLimitRetries) != 5 {
- t.Fatalf("Expected 5 retries. Got: %d", len(rateLimitRetries))
- }
- // check 2s wait after
- seconds := rateLimitRetries[0].WaitTime.Seconds()
- if !util.IsApproximately(seconds, 2.0) {
- t.Fatalf("Expected 2.0 seconds. Got %.2f", seconds)
- }
- // check to see if fuzzed wait time for datetime parsing
- seconds = rateLimitRetries[1].WaitTime.Seconds()
- if math.Abs(seconds-2.0) > 3.0 {
- t.Fatalf("Expected delta between 2s and resulting wait time to be within 3s. Seconds: %.2f, Delta: %.2f", seconds, math.Abs(seconds-2.0))
- }
- // check 1s wait
- seconds = rateLimitRetries[2].WaitTime.Seconds()
- if !util.IsApproximately(seconds, 0.4) {
- t.Fatalf("Expected 0.4 seconds. Got %.2f", seconds)
- }
- // check 1s wait
- seconds = rateLimitRetries[3].WaitTime.Seconds()
- if !util.IsApproximately(seconds, 0.8) {
- t.Fatalf("Expected 0.8 seconds. Got %.2f", seconds)
- }
- // check 3s wait
- seconds = rateLimitRetries[4].WaitTime.Seconds()
- if !util.IsApproximately(seconds, 3.0) {
- t.Fatalf("Expected 3.0 seconds. Got %.2f", seconds)
- }
- }
- func AssertDurationEqual(t *testing.T, expected, actual time.Duration) {
- if actual != expected {
- t.Fatalf("Expected: %dms, Got: %dms", expected.Milliseconds(), actual.Milliseconds())
- }
- }
- func TestExponentialBackOff(t *testing.T) {
- var ExpectedResults = []time.Duration{
- 100 * time.Millisecond,
- 200 * time.Millisecond,
- 400 * time.Millisecond,
- 800 * time.Millisecond,
- 1600 * time.Millisecond,
- }
- w := 100 * time.Millisecond
- for retry := 0; retry < 5; retry++ {
- AssertDurationEqual(t, ExpectedResults[retry], httputil.ExponentialBackoffWaitFor(w, retry))
- }
- }
- func TestConcurrentRateLimiting(t *testing.T) {
- t.Parallel()
- // Set QueryConcurrency to 3 here, then add a few for total requests
- const QueryConcurrency = 3
- const TotalRequests = QueryConcurrency + 2
- dateRetry := time.Now().Add(5 * time.Second).Format(time.RFC1123)
- // creates a prom client with hard coded responses for any requests that
- // are issued
- promClient := newMockPromClientWith([]*ResponseAndBody{
- newNormalRateLimitedResponse("2"),
- newNormalRateLimitedResponse(dateRetry),
- newHackyAmazonRateLimitedResponse(),
- newHackyAmazonRateLimitedResponse(),
- newNormalRateLimitedResponse("3"),
- })
- client, err := NewRateLimitedClient(
- "TestClient",
- promClient,
- QueryConcurrency,
- nil,
- nil,
- newTestRetryOpts(),
- "",
- "",
- )
- if err != nil {
- t.Fatal(err)
- }
- errs := make(chan error, TotalRequests)
- for i := 0; i < TotalRequests; i++ {
- go func() {
- req, err := http.NewRequest(http.MethodPost, "", nil)
- if err != nil {
- errs <- err
- return
- }
- // we just need to execute this once to see retries in effect
- _, _, err = client.Do(context.Background(), req)
- errs <- err
- }()
- }
- for i := 0; i < TotalRequests; i++ {
- err := <-errs
- if err == nil {
- t.Fatal("Expected a RateLimitedResponseError. Err was nil.")
- }
- rateLimitErr, ok := err.(*RateLimitedResponseError)
- if !ok {
- t.Fatal("Expected a RateLimitedResponseError. Got unexpected type.")
- }
- t.Logf("%s\n", rateLimitErr.Error())
- }
- }
|