Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## 0.5.0

FEATURES:

* `quicknode_endpoint_rate_limits` gains `limit_by_ip`, the dashboard's "Limit by IP
address" setting. Left out of the configuration, it keeps the endpoint's current
value. Destroying the resource turns it off.
* `quicknode_endpoint_method_rate_limit` gains `limit_by_ip`. It defaults to `false`,
so a limiter set to count per IP outside Terraform plans a change back to `false`
unless the configuration sets it.

## 0.4.0

FEATURES:
Expand Down
11,447 changes: 7,874 additions & 3,573 deletions api/admin/admin.gen.go

Large diffs are not rendered by default.

3,183 changes: 2,522 additions & 661 deletions api/admin/openapi.json

Large diffs are not rendered by default.

2,119 changes: 1,990 additions & 129 deletions api/admin/openapi.upstream.json

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions docs/resources/endpoint_method_rate_limit.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions docs/resources/endpoint_rate_limits.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ resource "quicknode_endpoint_method_rate_limit" "heavy_reads" {
methods = ["eth_getLogs", "debug_traceTransaction"]
rate = 5
interval = "second"

# Count the rate per client IP, so one busy client cannot use up the budget
# for everyone else.
limit_by_ip = true
}

# Disabling keeps the definition in place, which suits turning a limit off
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ resource "quicknode_endpoint_rate_limits" "api" {

rps = 25
rpd = 1000000

# Each client IP gets its own 25 requests per second and 1,000,000 per day.
limit_by_ip = true
}

output "plan_allows_per_second" {
Expand Down
87 changes: 56 additions & 31 deletions internal/client/ratelimits.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,19 @@ type RateLimit struct {
ID string
}

// RateLimitOverrides names the buckets a write can set. A nil field is left
// alone, so a configuration that manages only one bucket does not disturb the
// others.
type EndpointRateLimits struct {
Limits []RateLimit
LimitByIP bool
}

// RateLimitOverrides names the settings a write can change. A nil field is
// left alone, so a configuration that manages only one bucket does not disturb
// the others.
type RateLimitOverrides struct {
RPS *int
RPM *int
RPD *int
RPS *int
RPM *int
RPD *int
LimitByIP *bool
}

// MethodRateLimit throttles a named set of RPC methods independently of the
Expand All @@ -47,9 +53,11 @@ type MethodRateLimit struct {
Interval string
Status string
Created string

LimitByIP bool
}

func (c *Client) GetRateLimits(ctx context.Context, endpointID string) ([]RateLimit, error) {
func (c *Client) GetRateLimits(ctx context.Context, endpointID string) (*EndpointRateLimits, error) {
const operation = "read endpoint rate limits"

resp, err := c.api.GetV0EndpointsByIdRateLimitsWithResponse(ctx, endpointID)
Expand All @@ -65,8 +73,14 @@ func (c *Client) GetRateLimits(ctx context.Context, endpointID string) ([]RateLi
if err := envelopeError(operation, resp.StatusCode(), resp.JSON200.Error); err != nil {
return nil, err
}
if resp.JSON200.Data == nil || resp.JSON200.Data.RateLimits == nil {
return nil, nil

result := &EndpointRateLimits{}
if resp.JSON200.Data == nil {
return result, nil
}
result.LimitByIP = deref(resp.JSON200.Data.LimitByIp)
if resp.JSON200.Data.RateLimits == nil {
return result, nil
}

limits := make([]RateLimit, 0, len(*resp.JSON200.Data.RateLimits))
Expand All @@ -82,22 +96,29 @@ func (c *Client) GetRateLimits(ctx context.Context, endpointID string) ([]RateLi
}
limits = append(limits, limit)
}
return limits, nil
result.Limits = limits
return result, nil
}

// SetRateLimits writes the buckets the caller manages. Managing none sends no
// request at all.
// SetRateLimits writes the settings the caller manages. Managing none sends no
// request at all. The route rejects an empty rate_limits object, so it is left
// out when no bucket is set.
func (c *Client) SetRateLimits(ctx context.Context, endpointID string, overrides RateLimitOverrides) error {
const operation = "update endpoint rate limits"

if overrides.RPS == nil && overrides.RPM == nil && overrides.RPD == nil {
setsBucket := overrides.RPS != nil || overrides.RPM != nil || overrides.RPD != nil
if !setsBucket && overrides.LimitByIP == nil {
return nil
}

body := admin.PatchV0EndpointsByIdRateLimitsJSONRequestBody{}
body.RateLimits.Rps = overrides.RPS
body.RateLimits.Rpm = overrides.RPM
body.RateLimits.Rpd = overrides.RPD
body := admin.PatchV0EndpointsByIdRateLimitsJSONRequestBody{LimitByIp: overrides.LimitByIP}
if setsBucket {
body.RateLimits = &struct {
Rpd *int `json:"rpd,omitempty"`
Rpm *int `json:"rpm,omitempty"`
Rps *int `json:"rps,omitempty"`
}{Rpd: overrides.RPD, Rpm: overrides.RPM, Rps: overrides.RPS}
}

resp, err := c.api.PatchV0EndpointsByIdRateLimitsWithResponse(ctx, endpointID, body)
if err != nil {
Expand Down Expand Up @@ -156,10 +177,11 @@ func (c *Client) ListMethodRateLimits(ctx context.Context, endpointID string) ([
limiters := make([]MethodRateLimit, 0, len(*resp.JSON200.Data.RateLimiters))
for _, raw := range *resp.JSON200.Data.RateLimiters {
limiter := MethodRateLimit{
ID: deref(raw.Id),
Interval: deref(raw.Interval),
Status: deref(raw.Status),
Created: deref(raw.Created),
ID: deref(raw.Id),
Interval: deref(raw.Interval),
Status: deref(raw.Status),
Created: deref(raw.Created),
LimitByIP: deref(raw.LimitByIp),
}
if raw.Rate != nil {
limiter.Rate = *raw.Rate
Expand All @@ -177,9 +199,10 @@ func (c *Client) AddMethodRateLimit(ctx context.Context, endpointID string, limi

resp, err := c.api.PostV0EndpointsByIdMethodRateLimitsWithResponse(ctx, endpointID,
admin.PostV0EndpointsByIdMethodRateLimitsJSONRequestBody{
Interval: limiter.Interval,
Methods: limiter.Methods,
Rate: limiter.Rate,
Interval: limiter.Interval,
LimitByIp: &limiter.LimitByIP,
Methods: limiter.Methods,
Rate: limiter.Rate,
})
if err != nil {
return nil, err
Expand All @@ -196,10 +219,11 @@ func (c *Client) AddMethodRateLimit(ctx context.Context, endpointID string, limi

data := resp.JSON200.Data
created := &MethodRateLimit{
ID: *data.Id,
Interval: deref(data.Interval),
Status: deref(data.Status),
Created: deref(data.Created),
ID: *data.Id,
Interval: deref(data.Interval),
Status: deref(data.Status),
Created: deref(data.Created),
LimitByIP: deref(data.LimitByIp),
}
if data.Rate != nil {
created.Rate = *data.Rate
Expand All @@ -218,9 +242,10 @@ func (c *Client) UpdateMethodRateLimit(ctx context.Context, endpointID, limiterI

resp, err := c.api.PatchV0EndpointsByIdMethodRateLimitsByMethodRateLimitIdWithResponse(ctx, endpointID, limiterID,
admin.PatchV0EndpointsByIdMethodRateLimitsByMethodRateLimitIdJSONRequestBody{
Methods: limiter.Methods,
Rate: limiter.Rate,
Status: limiter.Status,
LimitByIp: &limiter.LimitByIP,
Methods: &limiter.Methods,
Rate: &limiter.Rate,
Status: &limiter.Status,
})
if err != nil {
return err
Expand Down
47 changes: 42 additions & 5 deletions internal/client/ratelimits_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,18 @@ const rateLimitsBody = `{"data":{"rate_limits":[
{"bucket":"rps","rate_limit":25,"source":"plan_default"},
{"bucket":"rpm","rate_limit":500,"source":"plan_default"},
{"bucket":"rpd","rate_limit":-1,"source":"plan_default"},
{"bucket":"rps","rate_limit":10,"source":"user_override","id":"ovr-1"}]}}`
{"bucket":"rps","rate_limit":10,"source":"user_override","id":"ovr-1"}],
"limit_by_ip":true}}`

func TestGetRateLimitsKeepsSourceAndID(t *testing.T) {
limits, err := newTestClient(t, rateLimitsBody).GetRateLimits(context.Background(), "1")
result, err := newTestClient(t, rateLimitsBody).GetRateLimits(context.Background(), "1")
if err != nil {
t.Fatalf("GetRateLimits: %v", err)
}
if !result.LimitByIP {
t.Error("LimitByIP = false, want true")
}
limits := result.Limits
if len(limits) != 4 {
t.Fatalf("got %d limits, want 4", len(limits))
}
Expand Down Expand Up @@ -85,6 +90,38 @@ func TestSetRateLimitsOmitsUnmanagedBuckets(t *testing.T) {
t.Errorf("%s was sent although the caller left it unset", unmanaged)
}
}
if _, present := captured["limit_by_ip"]; present {
t.Error("limit_by_ip was sent although the caller left it unset")
}
}

func TestSetRateLimitsSendsLimitByIPAlone(t *testing.T) {
var captured map[string]any

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &captured)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"limits":{"rps":-1,"rpm":-1,"rpd":-1},"limit_by_ip":false}}`))
}))
t.Cleanup(server.Close)

quicknode, err := New("test-key", WithBaseURL(server.URL))
if err != nil {
t.Fatalf("New: %v", err)
}

limitByIP := false
if err := quicknode.SetRateLimits(context.Background(), "1", RateLimitOverrides{LimitByIP: &limitByIP}); err != nil {
t.Fatalf("SetRateLimits: %v", err)
}

if captured["limit_by_ip"] != false {
t.Errorf("limit_by_ip = %v, want false", captured["limit_by_ip"])
}
if _, present := captured["rate_limits"]; present {
t.Errorf("rate_limits was sent with no bucket set, which the route rejects: %v", captured)
}
}

func TestDeleteRateLimitOverrideRejectsFalseSuccess(t *testing.T) {
Expand Down Expand Up @@ -117,7 +154,7 @@ func TestRateLimitDeletesTolerateAbsence(t *testing.T) {

func TestListMethodRateLimits(t *testing.T) {
const body = `{"data":{"rate_limiters":[{"id":"lim-1","interval":"second","rate":5,
"status":"enabled","created":"1742400000","methods":["eth_getLogs","eth_call"]}]}}`
"status":"enabled","created":"1742400000","methods":["eth_getLogs","eth_call"],"limit_by_ip":true}]}}`

limiters, err := newTestClient(t, body).ListMethodRateLimits(context.Background(), "1")
if err != nil {
Expand All @@ -127,7 +164,7 @@ func TestListMethodRateLimits(t *testing.T) {
t.Fatalf("got %d limiters, want 1", len(limiters))
}
limiter := limiters[0]
if limiter.ID != "lim-1" || limiter.Rate != 5 || limiter.Interval != "second" || limiter.Status != "enabled" {
if limiter.ID != "lim-1" || limiter.Rate != 5 || limiter.Interval != "second" || limiter.Status != "enabled" || !limiter.LimitByIP {
t.Errorf("limiter = %+v", limiter)
}
if len(limiter.Methods) != 2 || limiter.Methods[0] != "eth_getLogs" {
Expand Down Expand Up @@ -161,7 +198,7 @@ func TestUpdateMethodRateLimitSendsWholeObject(t *testing.T) {
if err != nil {
t.Fatalf("UpdateMethodRateLimit: %v", err)
}
if captured["rate"] != float64(9) || captured["status"] != "disabled" {
if captured["rate"] != float64(9) || captured["status"] != "disabled" || captured["limit_by_ip"] != false {
t.Errorf("body = %v", captured)
}
if _, present := captured["interval"]; present {
Expand Down
22 changes: 17 additions & 5 deletions internal/provider/acceptance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,10 +269,11 @@ resource "quicknode_endpoint_rate_limits" "test" {
CheckDestroy: testAccCheckEndpointsDestroyed,
Steps: []resource.TestStep{
{
Config: withBuckets(" rps = 25\n rpm = 500"),
Config: withBuckets(" rps = 25\n rpm = 500\n limit_by_ip = true"),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("quicknode_endpoint_rate_limits.test", "rps", "25"),
resource.TestCheckResourceAttr("quicknode_endpoint_rate_limits.test", "rpm", "500"),
resource.TestCheckResourceAttr("quicknode_endpoint_rate_limits.test", "limit_by_ip", "true"),
resource.TestCheckResourceAttrSet("quicknode_endpoint_rate_limits.test", "plan_default.rps"),
),
},
Expand All @@ -281,14 +282,22 @@ resource "quicknode_endpoint_rate_limits" "test" {
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("quicknode_endpoint_rate_limits.test", "rps", "25"),
resource.TestCheckNoResourceAttr("quicknode_endpoint_rate_limits.test", "rpm"),
resource.TestCheckResourceAttr("quicknode_endpoint_rate_limits.test", "limit_by_ip", "true"),
),
},
{
Config: withBuckets(" limit_by_ip = false"),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckNoResourceAttr("quicknode_endpoint_rate_limits.test", "rps"),
resource.TestCheckResourceAttr("quicknode_endpoint_rate_limits.test", "limit_by_ip", "false"),
),
},
},
})
}

func TestAccMethodRateLimit_lifecycle(t *testing.T) {
withRate := func(rate int, enabled bool) string {
withRate := func(rate int, enabled, limitByIP bool) string {
return fmt.Sprintf(`
resource "quicknode_endpoint" "test" {
chain = %q
Expand All @@ -304,8 +313,9 @@ resource "quicknode_endpoint_method_rate_limit" "test" {
rate = %d
interval = "second"
enabled = %t
limit_by_ip = %t
}
`, acceptanceChain, acceptanceNetwork, rate, enabled)
`, acceptanceChain, acceptanceNetwork, rate, enabled, limitByIP)
}

resource.Test(t, resource.TestCase{
Expand All @@ -314,17 +324,19 @@ resource "quicknode_endpoint_method_rate_limit" "test" {
CheckDestroy: testAccCheckEndpointsDestroyed,
Steps: []resource.TestStep{
{
Config: withRate(5, true),
Config: withRate(5, true, true),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("quicknode_endpoint_method_rate_limit.test", "rate", "5"),
resource.TestCheckResourceAttr("quicknode_endpoint_method_rate_limit.test", "enabled", "true"),
resource.TestCheckResourceAttr("quicknode_endpoint_method_rate_limit.test", "limit_by_ip", "true"),
),
},
{
Config: withRate(9, false),
Config: withRate(9, false, false),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("quicknode_endpoint_method_rate_limit.test", "rate", "9"),
resource.TestCheckResourceAttr("quicknode_endpoint_method_rate_limit.test", "enabled", "false"),
resource.TestCheckResourceAttr("quicknode_endpoint_method_rate_limit.test", "limit_by_ip", "false"),
),
},
},
Expand Down
Loading
Loading