plans: optional rate ceilings, never below the platform default; box hook (#358)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+184
−1
internal/host/plans_pass.go+12 −0internal/host/plans_pass_ratelimit_test.go+29 −0internal/plans/catalog.go+42 −1internal/plans/catalog_ratelimit_test.go+101 −0
diff --git a/internal/host/plans_pass.go b/internal/host/plans_pass.go| index e45961e89..4130da030 100644 |
| --- a/internal/host/plans_pass.go |
| +++ b/internal/host/plans_pass.go |
| @@ -19,6 +19,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/plans" |
| + "github.com/carlosframework/platform/internal/ratelimit" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -135,3 +136,14 @@ func planIdleFor(rt registry.Route) time.Duration { |
| } |
| return time.Duration(l.IdleSeconds) * time.Second |
| } |
| + |
| +// planRateCeilingFor is the edge's rate-limit ceiling hook: the most the |
| +// route's account's plan lets an instance owner set, or the zero Ceiling (no |
| +// ceiling) when plans are off or the account has no record. |
| +func planRateCeilingFor(rt registry.Route) ratelimit.Ceiling { |
| + l, ok := planLimitsFor(release.RouteAccount(rt)) |
| + if !ok { |
| + return ratelimit.Ceiling{} |
| + } |
| + return l.RateCeiling() |
| +} |
diff --git a/internal/host/plans_pass_ratelimit_test.go b/internal/host/plans_pass_ratelimit_test.go| new file mode 100644 |
| index 000000000..dc87508a3 |
| --- /dev/null |
| +++ b/internal/host/plans_pass_ratelimit_test.go |
| @@ -0,0 +1,29 @@ |
| +package host |
| + |
| +// Serial: it writes the package-level plans cache. |
| + |
| +import ( |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| + "github.com/carlosframework/platform/internal/ratelimit" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func TestPlanRateCeilingForOffIsZero(t *testing.T) { |
| + t.Cleanup(func() { planLimits.Store(nil) }) |
| + rt := registry.Route{Host: "a.example", Kind: "instance", Account: "bab", App: "web"} |
| + planLimits.Store(nil) |
| + if c := planRateCeilingFor(rt); c != (ratelimit.Ceiling{}) { |
| + t.Fatalf("plans off: %+v, want the zero Ceiling", c) |
| + } |
| + planLimits.Store(&planLimitsSnapshot{byAccount: map[string]plans.Limits{"bab": {RatePerSecond: 400}}}) |
| + if c := planRateCeilingFor(rt); c != (ratelimit.Ceiling{Rate: 400}) { |
| + t.Fatalf("bab: %+v, want Rate 400 only", c) |
| + } |
| + other := rt |
| + other.Account = "bdk" |
| + if c := planRateCeilingFor(other); c != (ratelimit.Ceiling{}) { |
| + t.Fatalf("account with no record: %+v, want the zero Ceiling", c) |
| + } |
| +} |
diff --git a/internal/plans/catalog.go b/internal/plans/catalog.go| index a9a9592cb..e6f2c96f3 100644 |
| --- a/internal/plans/catalog.go |
| +++ b/internal/plans/catalog.go |
| @@ -17,6 +17,8 @@ import ( |
| "math" |
| "os" |
| "slices" |
| + |
| + "github.com/carlosframework/platform/internal/ratelimit" |
| ) |
| // Limits is one set of caps. In a plan it is the plan's caps; in a pack it |
| @@ -38,6 +40,20 @@ type Limits struct { |
| Fleets bool `json:"fleets,omitempty"` |
| MinScheduleSeconds int `json:"min_schedule_seconds,omitempty"` |
| HistoryDays int `json:"history_days,omitempty"` |
| + |
| + // What an instance owner may set for the edge rate limit (spec |
| + // 2026-10-24-edge-rate-limit-design.md §5.3). Zero is no ceiling. The |
| + // per-second rate and the burst may not sit below the platform default: |
| + // a plan that went lower would throttle an instance that never asked for |
| + // a limit. The client share has no such floor. |
| + RateClientShare int `json:"rate_client_share,omitempty"` |
| + RatePerSecond int `json:"rate_per_second,omitempty"` |
| + RateBurst int `json:"rate_burst,omitempty"` |
| +} |
| + |
| +// RateCeiling is these limits' rate fields as the edge's ceiling. |
| +func (l Limits) RateCeiling() ratelimit.Ceiling { |
| + return ratelimit.Ceiling{ClientShare: l.RateClientShare, Rate: l.RatePerSecond, Burst: l.RateBurst} |
| } |
| // The memory tiers a plan may name. The numbers behind them belong to the |
| @@ -135,10 +151,16 @@ func (c *Catalog) validate() error { |
| if err := c.Ceilings.nonNegative(); err != nil { |
| return fmt.Errorf("ceilings: %w", err) |
| } |
| + if err := c.Ceilings.rateAboveDefault(); err != nil { |
| + return fmt.Errorf("ceilings: %w", err) |
| + } |
| for name, p := range c.Plans { |
| if err := p.Limits.nonNegative(); err != nil { |
| return fmt.Errorf("plan %q: %w", name, err) |
| } |
| + if err := p.Limits.rateAboveDefault(); err != nil { |
| + return fmt.Errorf("plan %q: %w", name, err) |
| + } |
| switch p.Limits.MemoryTier { |
| case "", MemorySmall, MemoryDefault: |
| default: |
| @@ -167,12 +189,25 @@ func (c *Catalog) validate() error { |
| func (l Limits) nonNegative() error { |
| if l.RunningThings < 0 || l.SidecarsPerApp < 0 || l.IdleSeconds < 0 || l.AwakeHoursMonth < 0 || |
| l.DiskBytes < 0 || l.StorageBytes < 0 || l.EmailPerDay < 0 || l.BandwidthBytesMonth < 0 || |
| - l.MinScheduleSeconds < 0 || l.HistoryDays < 0 { |
| + l.MinScheduleSeconds < 0 || l.HistoryDays < 0 || |
| + l.RateClientShare < 0 || l.RatePerSecond < 0 || l.RateBurst < 0 { |
| return errors.New("limits may not be negative") |
| } |
| return nil |
| } |
| +// rateAboveDefault refuses a rate or burst set below the platform default. |
| +// Zero is unset and passes. |
| +func (l Limits) rateAboveDefault() error { |
| + if l.RatePerSecond != 0 && l.RatePerSecond < ratelimit.Defaults.Rule.Rate { |
| + return fmt.Errorf("rate_per_second %d is below the platform default %d", l.RatePerSecond, ratelimit.Defaults.Rule.Rate) |
| + } |
| + if l.RateBurst != 0 && l.RateBurst < ratelimit.Defaults.Rule.Burst { |
| + return fmt.Errorf("rate_burst %d is below the platform default %d", l.RateBurst, ratelimit.Defaults.Rule.Burst) |
| + } |
| + return nil |
| +} |
| + |
| // additive is l with only the fields a pack may raise, and a ceiling clamps. |
| func (l Limits) additive() Limits { |
| return Limits{ |
| @@ -229,6 +264,9 @@ func (c *Catalog) Effective(plan string, packs map[string]int, sponsored *Limits |
| l.EmailPerDay = clamp(l.EmailPerDay, ce.EmailPerDay) |
| l.BandwidthBytesMonth = clamp(l.BandwidthBytesMonth, ce.BandwidthBytesMonth) |
| l.IdleSeconds = clamp(l.IdleSeconds, ce.IdleSeconds) |
| + l.RateClientShare = clamp(l.RateClientShare, ce.RateClientShare) |
| + l.RatePerSecond = clamp(l.RatePerSecond, ce.RatePerSecond) |
| + l.RateBurst = clamp(l.RateBurst, ce.RateBurst) |
| if sponsored != nil { |
| l = l.generous(*sponsored) |
| } |
| @@ -254,6 +292,9 @@ func (l Limits) generous(o Limits) Limits { |
| l.EmailPerDay = max(l.EmailPerDay, o.EmailPerDay) |
| l.BandwidthBytesMonth = max(l.BandwidthBytesMonth, o.BandwidthBytesMonth) |
| l.HistoryDays = max(l.HistoryDays, o.HistoryDays) |
| + l.RateClientShare = max(l.RateClientShare, o.RateClientShare) |
| + l.RatePerSecond = max(l.RatePerSecond, o.RatePerSecond) |
| + l.RateBurst = max(l.RateBurst, o.RateBurst) |
| l.StorageSelfServe = l.StorageSelfServe || o.StorageSelfServe |
| l.CustomDomains = l.CustomDomains || o.CustomDomains |
| l.Fleets = l.Fleets || o.Fleets |
diff --git a/internal/plans/catalog_ratelimit_test.go b/internal/plans/catalog_ratelimit_test.go| new file mode 100644 |
| index 000000000..93540c91c |
| --- /dev/null |
| +++ b/internal/plans/catalog_ratelimit_test.go |
| @@ -0,0 +1,101 @@ |
| +package plans |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/ratelimit" |
| +) |
| + |
| +func TestCatalogRefusesRateCeilingBelowDefault(t *testing.T) { |
| + base := exampleBytes(t) |
| + inPlan := `"running_things": 3,` |
| + inCeil := `"idle_seconds": 900 |
| + }` |
| + cases := []struct { |
| + name, body, wantIn, wantField string |
| + }{ |
| + {"plan rate", strings.Replace(base, inPlan, inPlan+` "rate_per_second": 100,`, 1), `plan "free"`, "rate_per_second"}, |
| + {"plan burst", strings.Replace(base, inPlan, inPlan+` "rate_burst": 10,`, 1), `plan "free"`, "rate_burst"}, |
| + {"ceilings rate", strings.Replace(base, inCeil, `"idle_seconds": 900, "rate_per_second": 100 |
| + }`, 1), "ceilings", "rate_per_second"}, |
| + {"ceilings burst", strings.Replace(base, inCeil, `"idle_seconds": 900, "rate_burst": 10 |
| + }`, 1), "ceilings", "rate_burst"}, |
| + {"negative share", strings.Replace(base, inPlan, inPlan+` "rate_client_share": -1,`, 1), `plan "free"`, "negative"}, |
| + {"negative rate", strings.Replace(base, inPlan, inPlan+` "rate_per_second": -300,`, 1), `plan "free"`, "negative"}, |
| + } |
| + for _, tc := range cases { |
| + if tc.body == base { |
| + t.Fatalf("%s: replacement did not apply", tc.name) |
| + } |
| + _, err := ParseCatalog([]byte(tc.body)) |
| + if err == nil { |
| + t.Errorf("%s: ParseCatalog accepted it", tc.name) |
| + continue |
| + } |
| + if !strings.Contains(err.Error(), tc.wantIn) || !strings.Contains(err.Error(), tc.wantField) { |
| + t.Errorf("%s: error %q should name %s and %s", tc.name, err, tc.wantIn, tc.wantField) |
| + } |
| + } |
| + |
| + // The default itself, a share below the default and a value above all pass. |
| + ok := strings.Replace(base, inPlan, inPlan+` "rate_client_share": 4, "rate_per_second": 300, "rate_burst": 5000,`, 1) |
| + c, err := ParseCatalog([]byte(ok)) |
| + if err != nil { |
| + t.Fatalf("rate_client_share 4, rate_per_second 300, rate_burst 5000: %v", err) |
| + } |
| + want := ratelimit.Ceiling{ClientShare: 4, Rate: 300, Burst: 5000} |
| + if got := c.Plans["free"].Limits.RateCeiling(); got != want { |
| + t.Fatalf("RateCeiling = %+v, want %+v", got, want) |
| + } |
| +} |
| + |
| +func TestPackCannotAddRateFields(t *testing.T) { |
| + base := exampleBytes(t) |
| + for _, f := range []string{`"rate_per_second": 500`, `"rate_burst": 2000`, `"rate_client_share": 20`} { |
| + body := strings.Replace(base, `{"running_things": 5}`, `{"running_things": 5, `+f+`}`, 1) |
| + if body == base { |
| + t.Fatalf("%s: replacement did not apply", f) |
| + } |
| + if _, err := ParseCatalog([]byte(body)); err == nil { |
| + t.Errorf("a pack adding %s was accepted", f) |
| + } |
| + } |
| +} |
| + |
| +func TestEffectiveClampsRateFields(t *testing.T) { |
| + c := exampleCatalog(t) |
| + p := c.Plans["personal"] |
| + p.Limits.RateClientShare = 40 |
| + p.Limits.RatePerSecond = 1000 |
| + p.Limits.RateBurst = 4000 |
| + c.Plans["personal"] = p |
| + c.Ceilings.RateClientShare = 30 |
| + c.Ceilings.RatePerSecond = 500 |
| + c.Ceilings.RateBurst = 2000 |
| + |
| + got, err := c.Effective("personal", nil, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if want := (ratelimit.Ceiling{ClientShare: 30, Rate: 500, Burst: 2000}); got.RateCeiling() != want { |
| + t.Fatalf("clamped = %+v, want %+v", got.RateCeiling(), want) |
| + } |
| + |
| + got, err = c.Effective("personal", nil, &Limits{RateClientShare: 60, RatePerSecond: 5000, RateBurst: 9000}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if want := (ratelimit.Ceiling{ClientShare: 60, Rate: 5000, Burst: 9000}); got.RateCeiling() != want { |
| + t.Fatalf("sponsored = %+v, want %+v (sponsored is not clamped)", got.RateCeiling(), want) |
| + } |
| + |
| + // A sponsored zero is unset: the clamped plan value stands. |
| + got, err = c.Effective("personal", nil, &Limits{RatePerSecond: 5000}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if want := (ratelimit.Ceiling{ClientShare: 30, Rate: 5000, Burst: 2000}); got.RateCeiling() != want { |
| + t.Fatalf("partly sponsored = %+v, want %+v", got.RateCeiling(), want) |
| + } |
| +} |