Skip to content

Commit 72ac902

Browse files
authored
Add rate limit to Port client (#262)
1 parent fa7eec1 commit 72ac902

File tree

5 files changed

+550
-3
lines changed

5 files changed

+550
-3
lines changed

internal/cli/client.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"github.com/go-resty/resty/v2"
8+
"github.com/port-labs/terraform-provider-port-labs/v2/internal/ratelimit"
9+
"github.com/port-labs/terraform-provider-port-labs/v2/internal/utils"
10+
"log/slog"
11+
"os"
712
"slices"
813
"strings"
9-
10-
"github.com/go-resty/resty/v2"
1114
)
1215

1316
type Option func(*PortClient)
@@ -21,12 +24,26 @@ type PortClient struct {
2124
BlueprintPropertyTypeChangeProtection bool
2225
}
2326

27+
func isTooManyRequests(r *resty.Response, _ error) bool {
28+
return r.StatusCode() == 429
29+
}
30+
2431
func New(baseURL string, opts ...Option) (*PortClient, error) {
32+
ratelimitOpts := &ratelimit.Options{
33+
Enabled: utils.PtrTo(os.Getenv("PORT_RATE_LIMIT_DISABLED") == ""),
34+
}
35+
if isDebug := os.Getenv("PORT_DEBUG_RATE_LIMIT") != ""; isDebug {
36+
ratelimitOpts.Logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
37+
}
38+
rateLimitManager := ratelimit.New(ratelimitOpts)
39+
2540
c := &PortClient{
2641
Client: resty.New().
42+
SetRateLimiter(rateLimitManager).
43+
OnAfterResponse(rateLimitManager.ResponseMiddleware).
2744
SetBaseURL(baseURL).
2845
SetRetryCount(5).
29-
SetRetryWaitTime(300).
46+
AddRetryCondition(isTooManyRequests).
3047
// retry when create permission fails because scopes are created async-ly and sometimes (mainly in tests) the scope doesn't exist yet.
3148
AddRetryCondition(func(r *resty.Response, err error) bool {
3249
if err != nil {
@@ -40,6 +57,7 @@ func New(baseURL string, opts ...Option) (*PortClient, error) {
4057
return err != nil || b["ok"] != true
4158
}),
4259
}
60+
4361
for _, opt := range opts {
4462
opt(c)
4563
}

internal/ratelimit/ratelimit.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package ratelimit
2+
3+
import (
4+
"context"
5+
"github.com/port-labs/terraform-provider-port-labs/v2/internal/utils"
6+
"io"
7+
"log/slog"
8+
"math/rand/v2"
9+
"strconv"
10+
"sync/atomic"
11+
"time"
12+
13+
"github.com/go-resty/resty/v2"
14+
)
15+
16+
// Info holds rate limit information extracted from Port HTTP Headers
17+
type Info struct {
18+
// Limit is extracted from x-ratelimit-limit
19+
Limit int
20+
// Reset is extracted from x-ratelimit-reset (seconds until reset)
21+
Reset int
22+
}
23+
24+
type Options struct {
25+
Logger *slog.Logger
26+
MinRequestInterval *time.Duration
27+
Enabled *bool
28+
Ctx context.Context
29+
}
30+
31+
func DefaultOptions() *Options {
32+
return &Options{
33+
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
34+
MinRequestInterval: utils.PtrTo(50 * time.Millisecond),
35+
Enabled: utils.PtrTo(true),
36+
Ctx: context.Background(),
37+
}
38+
}
39+
40+
type Manager struct {
41+
enabled bool
42+
lastRequestTime atomic.Pointer[time.Time]
43+
minRequestInterval time.Duration
44+
logger *slog.Logger
45+
46+
info atomic.Pointer[Info]
47+
remaining atomic.Int64
48+
49+
ctx context.Context
50+
cancelCtxFunc context.CancelFunc
51+
}
52+
53+
func New(opts *Options) *Manager {
54+
if opts == nil {
55+
opts = &Options{}
56+
}
57+
58+
// Apply defaults for nil fields
59+
defaults := DefaultOptions()
60+
logger := opts.Logger
61+
if logger == nil {
62+
logger = defaults.Logger
63+
}
64+
minRequestInterval := opts.MinRequestInterval
65+
if minRequestInterval == nil {
66+
minRequestInterval = defaults.MinRequestInterval
67+
}
68+
enabled := opts.Enabled
69+
if enabled == nil {
70+
enabled = defaults.Enabled
71+
}
72+
baseCtx := opts.Ctx
73+
if baseCtx == nil {
74+
baseCtx = defaults.Ctx
75+
}
76+
77+
logger = logger.WithGroup("ratelimit").
78+
With("enabled", *enabled, "minRequestInterval", *minRequestInterval)
79+
80+
ctx, cancel := context.WithCancel(baseCtx)
81+
82+
manager := &Manager{
83+
enabled: *enabled,
84+
minRequestInterval: *minRequestInterval,
85+
logger: logger,
86+
ctx: ctx,
87+
cancelCtxFunc: cancel,
88+
}
89+
90+
logger.Debug("ratelimit.Manager initialized")
91+
return manager
92+
}
93+
94+
func (m *Manager) GetInfo() *Info {
95+
info := m.info.Load()
96+
if info == nil {
97+
return nil
98+
}
99+
return &Info{
100+
Limit: info.Limit,
101+
Reset: info.Reset,
102+
}
103+
}
104+
105+
// Close gracefully shuts down the rate limit manager
106+
func (m *Manager) Close() {
107+
m.cancelCtxFunc()
108+
}
109+
110+
func (m *Manager) Allow() bool {
111+
m.logger.Debug("ratelimit.Manager.Allow called")
112+
113+
if !m.enabled {
114+
m.logger.Debug("Rate limiting disabled - returning early")
115+
return true
116+
}
117+
if utils.IsDone(m.ctx) {
118+
m.logger.Debug("Rate limiting context cancelled - returning early")
119+
return true
120+
}
121+
122+
lastRequestTime := m.lastRequestTime.Load()
123+
defer m.lastRequestTime.Store(utils.PtrTo(time.Now()))
124+
125+
remaining := m.remaining.Add(-1)
126+
info := m.GetInfo()
127+
128+
if throttlingDelay := m.calculateDelay(lastRequestTime, remaining, info); throttlingDelay > 0 {
129+
m.logger.Debug("Throttling request", "delay", throttlingDelay, "remaining", remaining)
130+
select {
131+
case <-m.ctx.Done():
132+
m.logger.Debug("Rate limiting context cancelled - stopping delay", "remaining", remaining)
133+
return true
134+
case <-time.After(throttlingDelay):
135+
}
136+
} else {
137+
m.logger.Debug("Not throttling", "remaining", remaining)
138+
}
139+
140+
return true
141+
}
142+
143+
func (m *Manager) ResponseMiddleware(_ *resty.Client, resp *resty.Response) error {
144+
m.logger.Debug("ResponseMiddleware called")
145+
146+
if !m.enabled {
147+
m.logger.Debug("Rate limiting disabled - response middleware returning early")
148+
return nil
149+
}
150+
m.logger.Debug("Extracting rate limit headers")
151+
152+
remainingHeader := resp.Header().Get("x-ratelimit-remaining")
153+
limitHeader := resp.Header().Get("x-ratelimit-limit")
154+
resetHeader := resp.Header().Get("x-ratelimit-reset")
155+
156+
if limitHeader+remainingHeader+resetHeader == "" {
157+
m.logger.Debug("No rate limit headers found or incomplete", slog.Group("headers",
158+
"limit", limitHeader,
159+
"remaining", remainingHeader,
160+
"reset", resetHeader))
161+
return nil
162+
}
163+
m.logger.Debug("Parsing rate limit headers")
164+
165+
remaining, err := strconv.ParseInt(remainingHeader, 10, 64)
166+
if err != nil {
167+
m.logger.Debug("Invalid RateLimit remaining header - ignoring all RateLimit headers", "remaining",
168+
remainingHeader, "error", err)
169+
return nil
170+
}
171+
172+
limit, err := strconv.Atoi(limitHeader)
173+
if err != nil {
174+
m.logger.Debug("Invalid RateLimit limit header - ignoring all RateLimit headers", "limit",
175+
limitHeader, "error", err)
176+
return nil
177+
}
178+
179+
reset, err := strconv.Atoi(resetHeader)
180+
if err != nil {
181+
m.logger.Debug("Invalid RateLimit reset header - ignoring all RateLimit headers", "reset", resetHeader,
182+
"error", err)
183+
return nil
184+
}
185+
186+
rateLimitInfo := &Info{Limit: limit, Reset: reset}
187+
oldRateLimitInfo := m.info.Swap(rateLimitInfo)
188+
m.remaining.Store(remaining)
189+
190+
m.logger.Debug("Parsed rate limit info", "new_rate_limit_info", rateLimitInfo,
191+
"old_rate_limit_info", oldRateLimitInfo, "remaining", remaining)
192+
193+
return nil
194+
}
195+
196+
func (m *Manager) calculateDelay(lastRequestTime *time.Time, remaining int64, info *Info) time.Duration {
197+
if lastRequestTime == nil {
198+
lastRequestTime = utils.PtrTo(time.Time{})
199+
}
200+
201+
// Calculate minimum interval delay
202+
var minIntervalDelay time.Duration
203+
if timeSinceLastRequest := time.Since(*lastRequestTime); timeSinceLastRequest < m.minRequestInterval {
204+
minIntervalDelay = m.minRequestInterval - timeSinceLastRequest
205+
}
206+
207+
if info == nil || info.Limit <= 0 || remaining > 0 {
208+
return minIntervalDelay
209+
}
210+
211+
if info.Reset > 0 {
212+
delay := float64(info.Reset)
213+
jitterMultiplier := 1 + rand.Float64()*0.1
214+
return max(time.Duration(delay*jitterMultiplier*float64(time.Second)), minIntervalDelay)
215+
}
216+
217+
return 0
218+
}

0 commit comments

Comments
 (0)