Skip to content

Commit c326a47

Browse files
authored
feat: implement rate limiting for API requests with token bucket (#1055)
* feat: implement rate limiting for API requests with token bucket algorithm * refactor: replace custom rate limiter with golang.org/x/time/rate implementation * chore: remove redundant comments from rate limiter implementation * refactor: simplify rate limiting by removing wrapper class and using rate.Limiter directly * chore: remove redundant comment for createRateLimiter function * refactor: move rate limit default value to single location in HTTP client * fix: handle rate limiter wait error in HTTP client request method * style: add newline before rate limiter wait call in http client * style: remove trailing whitespace in request function * test: add rate limiter tests and refactor HTTP client test setup * chore: remove redundant comment about rate limiting in client test * refactor: simplify rate limiter tests to use HTTP client directly * test: replace rate limiter config test with default rate limit test * test: remove redundant burst request rate limit test case * test: add rate limit queue and refresh test for HTTP client * style: add newlines for improved code readability in test files * style: remove trailing whitespace in http client test files * refactor: update rate limiter to use time.Minute instead of per-second calculation * refactor: simplify rate limiter creation by inlining function * remove sleep in test * refactor: update rate limiter configuration with accumulate rate and lower default limits * feat: add rate limiting for AWS cloud configuration requests * add empty lines * fix linting error * ci: add 15-minute timeout to Go test workflow step * ci: increase test timeout to 20 minutes for both job and go test
1 parent 5a0267d commit c326a47

File tree

7 files changed

+432
-93
lines changed

7 files changed

+432
-93
lines changed

.github/workflows/ci.yml

Lines changed: 75 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,76 @@
1-
name: CI
2-
3-
on:
4-
pull_request:
5-
types: [opened, synchronize]
6-
7-
env:
8-
ENV0_API_ENDPOINT: ${{ secrets.ENV0_API_ENDPOINT }}
9-
ENV0_API_KEY: ${{ secrets.TF_PROVIDER_INTEGRATION_TEST_API_KEY }} # API Key for organization 'TF-provider-integration-tests' @ dev
10-
ENV0_API_SECRET: ${{ secrets.TF_PROVIDER_INTEGRATION_TEST_API_SECRET }}
11-
GO_VERSION: "1.24"
12-
TERRAFORM_VERSION: 1.11.4
13-
14-
jobs:
15-
unit-tests:
16-
name: Unit Tests
17-
timeout-minutes: 15
18-
runs-on: ubuntu-24.04
19-
steps:
20-
- name: Install Go
21-
uses: actions/setup-go@v5
22-
with:
23-
go-version: ${{ env.GO_VERSION }}
24-
- name: Checkout code
25-
uses: actions/checkout@v4
26-
- name: Generate mocks
27-
run: |
28-
go install go.uber.org/mock/[email protected]
29-
go generate client/api_client.go
30-
- name: Go fmt
31-
run: |
32-
! go fmt ./... | read
33-
- name: Go vet
34-
run: |
35-
! go vet ./... | read
36-
- name: Install Terraform
37-
uses: hashicorp/setup-terraform@v3
38-
with:
39-
terraform_version: ${{ env.TERRAFORM_VERSION }}
40-
- name: Verify Terraform installation
41-
run: |
42-
terraform version
43-
which terraform
44-
echo "TF_PATH=$(which terraform)" >> $GITHUB_ENV
45-
# Make sure terraform is executable
46-
chmod +x $(which terraform)
47-
ls -la $(which terraform)
48-
- name: golangci-lint
49-
uses: golangci/golangci-lint-action@v7
50-
with:
51-
version: v2.0
52-
- name: Go Test
53-
run: |
54-
echo "Using Terraform at: $TF_PATH"
55-
echo "Terraform version: $TERRAFORM_VERSION"
56-
# Run tests with detailed logging
57-
TF_LOG=DEBUG go test -v ./...
58-
env:
59-
TF_ACC: true
60-
TF_ACC_TERRAFORM_PATH: ${{ env.TF_PATH }}
61-
TF_ACC_TERRAFORM_VERSION: ${{ env.TERRAFORM_VERSION }}
62-
63-
# See terraform-provider-env0 README for integration tests prerequisites
64-
integration-tests:
65-
name: Integration Tests
66-
runs-on: ubuntu-24.04
67-
container: golang:1.24-alpine
68-
timeout-minutes: 20
69-
steps:
70-
- name: Install Opentofu
71-
run: apk add opentofu
72-
- name: Checkout code
73-
uses: actions/checkout@v4
74-
- name: Run Harness tests
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize]
6+
7+
env:
8+
ENV0_API_ENDPOINT: ${{ secrets.ENV0_API_ENDPOINT }}
9+
ENV0_API_KEY: ${{ secrets.TF_PROVIDER_INTEGRATION_TEST_API_KEY }} # API Key for organization 'TF-provider-integration-tests' @ dev
10+
ENV0_API_SECRET: ${{ secrets.TF_PROVIDER_INTEGRATION_TEST_API_SECRET }}
11+
GO_VERSION: "1.24"
12+
TERRAFORM_VERSION: 1.11.4
13+
14+
jobs:
15+
unit-tests:
16+
name: Unit Tests
17+
timeout-minutes: 15
18+
runs-on: ubuntu-24.04
19+
steps:
20+
- name: Install Go
21+
uses: actions/setup-go@v5
22+
with:
23+
go-version: ${{ env.GO_VERSION }}
24+
- name: Checkout code
25+
uses: actions/checkout@v4
26+
- name: Generate mocks
27+
run: |
28+
go install go.uber.org/mock/[email protected]
29+
go generate client/api_client.go
30+
- name: Go fmt
31+
run: |
32+
! go fmt ./... | read
33+
- name: Go vet
34+
run: |
35+
! go vet ./... | read
36+
- name: Install Terraform
37+
uses: hashicorp/setup-terraform@v3
38+
with:
39+
terraform_version: ${{ env.TERRAFORM_VERSION }}
40+
- name: Verify Terraform installation
41+
run: |
42+
terraform version
43+
which terraform
44+
echo "TF_PATH=$(which terraform)" >> $GITHUB_ENV
45+
# Make sure terraform is executable
46+
chmod +x $(which terraform)
47+
ls -la $(which terraform)
48+
- name: golangci-lint
49+
uses: golangci/golangci-lint-action@v7
50+
with:
51+
version: v2.0
52+
- name: Go Test
53+
timeout-minutes: 20
54+
run: |
55+
echo "Using Terraform at: $TF_PATH"
56+
echo "Terraform version: $TERRAFORM_VERSION"
57+
# Run tests with detailed logging and increased timeout
58+
TF_LOG=DEBUG go test -timeout 20m -v ./...
59+
env:
60+
TF_ACC: true
61+
TF_ACC_TERRAFORM_PATH: ${{ env.TF_PATH }}
62+
TF_ACC_TERRAFORM_VERSION: ${{ env.TERRAFORM_VERSION }}
63+
64+
# See terraform-provider-env0 README for integration tests prerequisites
65+
integration-tests:
66+
name: Integration Tests
67+
runs-on: ubuntu-24.04
68+
container: golang:1.24-alpine
69+
timeout-minutes: 20
70+
steps:
71+
- name: Install Opentofu
72+
run: apk add opentofu
73+
- name: Checkout code
74+
uses: actions/checkout@v4
75+
- name: Run Harness tests
7576
run: go run tests/harness.go

client/http/client.go

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ package http
33
//go:generate mockgen -destination=client_mock.go -package=http . HttpClientInterface
44

55
import (
6+
"context"
67
"reflect"
78

89
"github.com/go-resty/resty/v2"
10+
"golang.org/x/time/rate"
911
)
1012

1113
type HttpClientInterface interface {
@@ -17,31 +19,56 @@ type HttpClientInterface interface {
1719
}
1820

1921
type HttpClient struct {
20-
ApiKey string
21-
ApiSecret string
22-
Endpoint string
23-
client *resty.Client
22+
ApiKey string
23+
ApiSecret string
24+
Endpoint string
25+
client *resty.Client
26+
rateLimiter *rate.Limiter
2427
}
2528

2629
type HttpClientConfig struct {
27-
ApiKey string
28-
ApiSecret string
29-
ApiEndpoint string
30-
UserAgent string
31-
RestClient *resty.Client
30+
ApiKey string
31+
ApiSecret string
32+
ApiEndpoint string
33+
UserAgent string
34+
RestClient *resty.Client
35+
RateLimitPerMinute int // Optional, defaults to 500 if not specified
36+
RateLimitAccumulateRate int // Optional, defaults to 8 if not specified
3237
}
3338

3439
func NewHttpClient(config HttpClientConfig) (*HttpClient, error) {
40+
rateLimitPerMinute := config.RateLimitPerMinute
41+
42+
if rateLimitPerMinute <= 0 {
43+
rateLimitPerMinute = 500
44+
}
45+
46+
rateLimitAccumulateRate := config.RateLimitAccumulateRate
47+
48+
if rateLimitAccumulateRate <= 0 {
49+
rateLimitAccumulateRate = 8
50+
}
51+
3552
httpClient := &HttpClient{
36-
ApiKey: config.ApiKey,
37-
ApiSecret: config.ApiSecret,
38-
client: config.RestClient.SetBaseURL(config.ApiEndpoint).SetHeader("User-Agent", config.UserAgent),
53+
ApiKey: config.ApiKey,
54+
ApiSecret: config.ApiSecret,
55+
client: config.RestClient.SetBaseURL(config.ApiEndpoint).SetHeader("User-Agent", config.UserAgent),
56+
rateLimiter: rate.NewLimiter(rate.Limit(rateLimitAccumulateRate), rateLimitPerMinute),
3957
}
4058

4159
return httpClient, nil
4260
}
4361

4462
func (client *HttpClient) request() *resty.Request {
63+
if client.rateLimiter != nil {
64+
ctx := context.Background()
65+
66+
err := client.rateLimiter.Wait(ctx)
67+
if err != nil {
68+
return client.client.R().SetError(err)
69+
}
70+
}
71+
4572
return client.client.R().SetBasicAuth(client.ApiKey, client.ApiSecret)
4673
}
4774

client/http/client_test.go

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package http_test
22

33
import (
44
"encoding/json"
5+
"errors"
56
"io"
67
"net/http"
78
"strconv"
@@ -50,10 +51,6 @@ var _ = BeforeSuite(func() {
5051
httpmock.ActivateNonDefault(restClient.GetClient())
5152
})
5253

53-
var _ = BeforeEach(func() {
54-
httpmock.Reset()
55-
})
56-
5754
var _ = AfterSuite(func() {
5855
// unmock HTTP requests
5956
httpmock.DeactivateAndReset()
@@ -66,19 +63,61 @@ func TestHttpClient(t *testing.T) {
6663

6764
var _ = Describe("Http Client", func() {
6865
var httpRequest *http.Request
66+
var httpclient *httpModule.HttpClient
6967

7068
mockRequest := RequestBody{
71-
Message: "I have a request",
69+
Message: "Hello",
7270
}
71+
7372
mockedResponse := ResponseType{
74-
Id: 1,
75-
Name: "I have a response",
73+
Id: 123,
74+
Name: "Test",
7675
}
76+
7777
successURI := "/path/to/success"
7878
failureURI := "/path/to/failure"
7979
successUrl := BaseUrl + successURI
8080
failureUrl := BaseUrl + failureURI
8181

82+
BeforeEach(func() {
83+
// Create a new REST client for each test to avoid rate limiter interference
84+
restClient := resty.New()
85+
httpmock.ActivateNonDefault(restClient.GetClient())
86+
87+
config := httpModule.HttpClientConfig{
88+
ApiKey: ApiKey,
89+
ApiSecret: ApiSecret,
90+
ApiEndpoint: BaseUrl,
91+
UserAgent: UserAgent,
92+
RestClient: restClient,
93+
RateLimitPerMinute: 1000000, // Set to a very high value to effectively disable rate limiting for tests
94+
}
95+
var err error
96+
httpclient, err = httpModule.NewHttpClient(config)
97+
Expect(err).To(BeNil())
98+
99+
httpRequest = nil
100+
httpmock.RegisterNoResponder(func(req *http.Request) (*http.Response, error) {
101+
httpRequest = req
102+
103+
return nil, errors.New("No responder found")
104+
})
105+
106+
// Make calls to /path/to/success return 200, and calls to /path/to/failure return 500
107+
for _, methodType := range []string{"GET", "POST", "PUT", "DELETE"} {
108+
httpmock.RegisterResponder(methodType, successUrl, func(req *http.Request) (*http.Response, error) {
109+
httpRequest = req
110+
111+
return httpmock.NewJsonResponse(200, mockedResponse)
112+
})
113+
httpmock.RegisterResponder(methodType, failureUrl, func(req *http.Request) (*http.Response, error) {
114+
httpRequest = req
115+
116+
return httpmock.NewStringResponse(ErrorStatusCode, ErrorMessage), nil
117+
})
118+
}
119+
})
120+
82121
AssertAuth := func() {
83122
authorization := httpRequest.Header["Authorization"]
84123
Expect(len(authorization)).To(Equal(1), "Should have authorization header")

0 commit comments

Comments
 (0)