console: rate_limit on the instance PATCH, plan-ceiling refusal, rate-limit GET (#358)
The instance PATCH takes rate_limit (*ratelimit.Settings): absent leaves
it, {} clears the record back to the defaults (stored as nil), anything
else replaces it whole. checkRateLimit runs before ValidateShape: Validate,
then, under a plan catalog, each value the owner names against the plan's
ceiling, refused in the approved copy (ratelimit_copy.go) in a fixed order
(base rule, then path rules by prefix; rate, burst, share). A failure to
read the plan is a 502, never a 400. "nothing to update" names rate_limit.
GET .../instances/{host}/rate-limit (instance role) answers the settings,
defaults, the plan ceiling (null with no catalog), the resolved values and
the last 24 hours' shed and backstop counts summed over every box's edge
day objects, for this instance's own account and app only.
Route census: claim-routes.txt, svcroutes_test.go, the claim read fixture
and probe, mirroring the /move GET. The record-write census digest
(plan9_b8: handleAPIInstanceUpdate, registerCLIRoutes) is NOT re-pinned
here, by the branch ruling that the controller re-layers it once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 files changed,
+609
−2
internal/console/apiinstances.go+33 −2internal/console/apiinstances_ratelimit.go+227 −0internal/console/apiinstances_ratelimit_test.go+320 −0internal/console/authcli.go+3 −0internal/console/claim_invariant_fixtures_test.go+2 −0internal/console/claim_invariant_probes_read_test.go+2 −0internal/console/ratelimit_copy.go+16 −0internal/console/svcroutes_test.go+5 −0internal/console/testdata/claim-routes.txt+1 −0
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index 110867b64..bfab13f29 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -41,6 +41,7 @@ import ( |
| "github.com/carlosframework/platform/internal/gate" |
| "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/ratelimit" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/status" |
| @@ -1999,13 +2000,18 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request) |
| // here, never stored or echoed (spec §5.2). The plaintext crosses |
| // the wire once, over TLS, the same as a sign-in. |
| Gate *string `json:"gate"` |
| + // RateLimit is the instance's rate-limit settings, replaced whole: |
| + // absent (or null) leaves them alone, {} clears them back to the |
| + // platform defaults, anything else is the new set (spec §5.2). |
| + RateLimit *ratelimit.Settings `json:"rate_limit"` |
| } |
| if !readJSON(w, r, &req) { |
| return |
| } |
| - if req.Addrs == nil && req.Probe == nil && req.Steering == nil && req.Environment == nil && req.Gate == nil { |
| + if req.Addrs == nil && req.Probe == nil && req.Steering == nil && req.Environment == nil && req.Gate == nil && |
| + req.RateLimit == nil { |
| jsonError(w, http.StatusBadRequest, |
| - "nothing to update — send addrs, probe, steering, environment, gate, or a combination") |
| + "nothing to update — send addrs, probe, steering, environment, gate, rate_limit, or a combination") |
| return |
| } |
| var probe *provision.Health |
| @@ -2076,6 +2082,28 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request) |
| rec.Gate = h |
| } |
| } |
| + // Checked here rather than left to ValidateShape so that a value above |
| + // the account's plan ceiling is refused in the plan's terms (approved |
| + // copy), and a failure to read the plan is the console's fault, not a |
| + // 400. The zero Settings is stored as no settings at all, so a record |
| + // cleared to the defaults reads exactly like one never set. |
| + if req.RateLimit != nil { |
| + if err := s.checkRateLimit(acct, *req.RateLimit); err != nil { |
| + if isRateLimitRefusal(err) { |
| + jsonError(w, http.StatusBadRequest, err.Error()) |
| + return |
| + } |
| + log.Printf("console: api instances update %s/%s/%s: rate limit: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not update the instance — try again") |
| + return |
| + } |
| + if req.RateLimit.Canonical() == "" { |
| + rec.RateLimit = nil |
| + } else { |
| + set := *req.RateLimit |
| + rec.RateLimit = &set |
| + } |
| + } |
| // The same door the create path and the box's reconcile pass check |
| // against, so a shape that could not have been created cannot be patched |
| // into existence either — and the caller gets ValidateShape's own |
| @@ -2181,6 +2209,9 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request) |
| if req.Gate != nil { |
| changed = append(changed, "gate") |
| } |
| + if req.RateLimit != nil { |
| + changed = append(changed, "rate_limit") |
| + } |
| s.audit(acct, app, principal, "instance-update", |
| "updated instance "+host+" ("+strings.Join(changed, ", ")+") (cli)") |
| // Strictly after the write landed, and exactly once: the record is what a |
diff --git a/internal/console/apiinstances_ratelimit.go b/internal/console/apiinstances_ratelimit.go| new file mode 100644 |
| index 000000000..3330f7ce0 |
| --- /dev/null |
| +++ b/internal/console/apiinstances_ratelimit.go |
| @@ -0,0 +1,227 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "net/http" |
| + "os" |
| + "sort" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/ratelimit" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/usage" |
| +) |
| + |
| +// The console's doors for an instance's rate limit (spec |
| +// 2026-10-24-edge-rate-limit-design.md §5.2-§5.4, §6). The write rides the |
| +// instance PATCH; this file holds its check and the read. |
| + |
| +// rateLimitRefusal is a rate-limit setting the console will not store: a |
| +// shape Validate refuses, or a value above the plan's ceiling. Its text is |
| +// the whole 400 body. Any other error from checkRateLimit is the console |
| +// failing to read the account's plan, which is not the caller's to fix. |
| +type rateLimitRefusal struct{ msg string } |
| + |
| +func (e *rateLimitRefusal) Error() string { return e.msg } |
| + |
| +func isRateLimitRefusal(err error) bool { |
| + var r *rateLimitRefusal |
| + return errors.As(err, &r) |
| +} |
| + |
| +// checkRateLimit decides whether acct may store set. Clearing to the |
| +// defaults is always allowed and reads nothing; with no plan catalog there |
| +// is no ceiling and no store read either (spec §5.3). Only the values the |
| +// owner names are compared: a field left unset takes the platform default, |
| +// which the edge clamps to the plan like anything else, so refusing it would |
| +// refuse something the owner never asked for. |
| +func (s *Server) checkRateLimit(acct *Account, set ratelimit.Settings) error { |
| + if err := set.Validate(); err != nil { |
| + return &rateLimitRefusal{err.Error()} |
| + } |
| + if set.Canonical() == "" { |
| + return nil |
| + } |
| + l, err := s.EffectiveLimits(acct) |
| + if err != nil { |
| + return fmt.Errorf("plan limits for %s: %w", acct.Sqid, err) |
| + } |
| + if l == nil { |
| + return nil |
| + } |
| + if msg := overCeiling(set, l.RateCeiling()); msg != "" { |
| + return &rateLimitRefusal{msg} |
| + } |
| + return nil |
| +} |
| + |
| +// overCeiling is the refusal for the first value in set above c, or "". The |
| +// base rule comes first, then the path rules in prefix order, and within a |
| +// rule rate, burst, then the share: one fixed order, so the same settings |
| +// always draw the same sentence whatever order their paths were sent in. |
| +func overCeiling(set ratelimit.Settings, c ratelimit.Ceiling) string { |
| + paths := append([]ratelimit.PathRule(nil), set.Paths...) |
| + sort.Slice(paths, func(i, j int) bool { return paths[i].Prefix < paths[j].Prefix }) |
| + rules := []ratelimit.Rule{set.Rule} |
| + for _, p := range paths { |
| + rules = append(rules, p.Rule) |
| + } |
| + for _, r := range rules { |
| + for _, f := range []struct { |
| + id string |
| + v, limit int |
| + }{ |
| + {"rate", r.Rate, c.Rate}, |
| + {"burst", r.Burst, c.Burst}, |
| + {"client_share", r.ClientShare, c.ClientShare}, |
| + } { |
| + if f.limit != 0 && f.v > f.limit { |
| + return strings.NewReplacer("{field}", rateLimitCopy[f.id], "{max}", strconv.Itoa(f.limit)). |
| + Replace(rateLimitCopy["over_ceiling"]) |
| + } |
| + } |
| + } |
| + return "" |
| +} |
| + |
| +// rateLimitView is the GET's answer. Settings is what the owner set ({} |
| +// for the defaults), Effective what the edge applies after the defaults and |
| +// the plan. Ceiling is null with no plan catalog; under one, a field it |
| +// leaves out has no ceiling. It is a Rule, not a ratelimit.Ceiling, for the |
| +// settings' own field names on the wire. |
| +type rateLimitView struct { |
| + Host string `json:"host"` |
| + Settings ratelimit.Settings `json:"settings"` |
| + Defaults ratelimit.Settings `json:"defaults"` |
| + Ceiling *ratelimit.Rule `json:"ceiling"` |
| + Effective ratelimit.Effective `json:"effective"` |
| + Counts rateLimitCountsView `json:"counts"` |
| +} |
| + |
| +// rateLimitCountsView is the last day's refusals for one host, summed over |
| +// every box: Shed by fair share while busy, Ceiling by the backstop (spec |
| +// §6). In observe mode they count what would have been refused. |
| +type rateLimitCountsView struct { |
| + Shed int64 `json:"shed"` |
| + Ceiling int64 `json:"ceiling"` |
| + Since string `json:"since"` // RFC3339, UTC |
| +} |
| + |
| +// handleAPIInstanceRateLimitGet answers one instance's rate limit: its |
| +// settings, the defaults and the plan ceiling they resolve against, the |
| +// result, and the last day's counts. A host with no record is the instance |
| +// GET's own byte-identical 404. |
| +func (s *Server) handleAPIInstanceRateLimitGet(w http.ResponseWriter, r *http.Request) { |
| + _, acct, app, ok := s.instanceGate(w, r) |
| + if !ok { |
| + return |
| + } |
| + host := r.PathValue("host") |
| + // Shape-checked before the key builder, for the instance GET's reason. |
| + if !provision.ValidHostShape(host) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| + if errors.Is(err, os.ErrNotExist) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if err != nil { |
| + log.Printf("console: api instances rate-limit read %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the instance") |
| + return |
| + } |
| + view := rateLimitView{Host: host, Defaults: ratelimit.Defaults} |
| + if rec.RateLimit != nil { |
| + view.Settings = *rec.RateLimit |
| + } |
| + l, err := s.EffectiveLimits(acct) |
| + if err != nil { |
| + log.Printf("console: api instances rate-limit plan %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the account's plan") |
| + return |
| + } |
| + var ceiling ratelimit.Ceiling |
| + if l != nil { |
| + ceiling = l.RateCeiling() |
| + view.Ceiling = &ratelimit.Rule{ClientShare: ceiling.ClientShare, Rate: ceiling.Rate, Burst: ceiling.Burst} |
| + } |
| + view.Effective = ratelimit.Resolve(view.Settings, ceiling) |
| + view.Counts, err = s.rateLimitCounts(ctx, acct, app, host, time.Now()) |
| + if err != nil { |
| + log.Printf("console: api instances rate-limit counts %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the instance's usage") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, view) |
| +} |
| + |
| +// rateLimitCounts sums host's refusal counters over every box's edge day |
| +// objects for the 24 hours before now. The boxes are found the way the |
| +// usage sweep finds them (sweepUsage). Edge rows are hour buckets, so an |
| +// hour counts whole when any of it falls in the window: the counts can |
| +// reach up to an hour further back than Since, never less far. |
| +// |
| +// A row counts only under this instance's own account and app. A host can |
| +// change hands, and the last owner's traffic is not the new owner's to see. |
| +func (s *Server) rateLimitCounts(ctx context.Context, acct *Account, app, host string, now time.Time) (rateLimitCountsView, error) { |
| + now = now.UTC() |
| + since := now.Add(-24 * time.Hour) |
| + out := rateLimitCountsView{Since: since.Format(time.RFC3339)} |
| + boxes, err := s.cfg.Releases.ListDirs(ctx, usage.EdgeBoxesPrefix) |
| + if err != nil { |
| + return out, err |
| + } |
| + days := []string{usage.DayOf(since)} |
| + if d := usage.DayOf(now); d != days[0] { |
| + days = append(days, d) |
| + } |
| + for _, boxPrefix := range boxes { |
| + box := strings.TrimSuffix(strings.TrimPrefix(boxPrefix, usage.EdgeBoxesPrefix), "/") |
| + for _, date := range days { |
| + key := usage.EdgeDayKey(box, date) |
| + raw, _, err := s.cfg.Releases.Get(ctx, key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + continue // the box served nothing that day |
| + } |
| + if err != nil { |
| + return out, err |
| + } |
| + var ed usage.EdgeDay |
| + if err := json.Unmarshal(raw, &ed); err != nil { |
| + log.Printf("console: rate-limit counts: parse %s: %v", key, err) |
| + continue |
| + } |
| + for hour, rows := range ed.Hours { |
| + row, ok := rows[host] |
| + if !ok { |
| + continue |
| + } |
| + rowAcct := row.Account |
| + if rowAcct == "" { |
| + rowAcct = release.OpsAccount |
| + } |
| + if rowAcct != acct.Sqid || row.App != app { |
| + continue |
| + } |
| + start, err := time.Parse("2006-01-02 15", date+" "+hour) |
| + if err != nil || !start.Add(time.Hour).After(since) || start.After(now) { |
| + continue |
| + } |
| + out.Shed += row.RateShed |
| + out.Ceiling += row.RateCeiling |
| + } |
| + } |
| + } |
| + return out, nil |
| +} |
diff --git a/internal/console/apiinstances_ratelimit_test.go b/internal/console/apiinstances_ratelimit_test.go| new file mode 100644 |
| index 000000000..ed1d4b36f |
| --- /dev/null |
| +++ b/internal/console/apiinstances_ratelimit_test.go |
| @@ -0,0 +1,320 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/ratelimit" |
| + "github.com/carlosframework/platform/internal/usage" |
| +) |
| + |
| +const rlHost = "a.shop.example.com" |
| + |
| +// rateLimitFixture is a console with the app "shop" in the ops account, its |
| +// instances enabled on the shared pool, one socket-backed instance at |
| +// rlHost, and a member's CLI token: the gate's own fixture with neutral |
| +// names. |
| +func rateLimitFixture(t *testing.T, code string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "shop", "owner@example.com", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.AddMember(OpsAccountID, "alice@example.com", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "alice@example.com", code) |
| + enableInstances(t, fx, "shop", provision.Manifest{Schema: 1, App: "shop", Domain: "shop.example.com", |
| + Channel: "stable", Regions: []string{"ie"}, Seed: []string{"seed"}}) |
| + if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "shop"), token, |
| + `{"host":"`+rlHost+`","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("create = %d %s", status, out) |
| + } |
| + return fx, token |
| +} |
| + |
| +func rlRecord(t *testing.T, fx *consoleFixture) *provision.Record { |
| + t.Helper() |
| + rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "shop", rlHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return rec |
| +} |
| + |
| +// rlCatalog is a one-plan catalog whose default plan caps the rate at 400, |
| +// the burst at 2000 and the share per client at 8, which is below the |
| +// platform default of 16: a field the owner leaves unset must not be |
| +// refused for the default it inherits. |
| +func rlCatalog(t *testing.T) *plans.Catalog { |
| + t.Helper() |
| + c, err := plans.ParseCatalog([]byte(`{"version":"rl-test-v1","default_plan":"free", |
| + "plans":{"free":{"paid":false,"limits":{"running_things":10, |
| + "rate_per_second":400,"rate_burst":2000,"rate_client_share":8}}}}`)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return c |
| +} |
| + |
| +func TestPatchRateLimitSetsAndClears(t *testing.T) { |
| + fx, token := rateLimitFixture(t, "code-rl-set") |
| + pu := updateURL(fx, "bab", "shop", rlHost) |
| + |
| + status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"rate_limit":{"rate":500}}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("patch rate_limit = %d %s, want 200", status, out) |
| + } |
| + if !strings.Contains(out, `"rate_limit":{"rate":500}`) { |
| + t.Errorf("patch reply lacks the new settings: %s", out) |
| + } |
| + rec := rlRecord(t, fx) |
| + if rec.RateLimit == nil || !reflect.DeepEqual(*rec.RateLimit, ratelimit.Settings{Rule: ratelimit.Rule{Rate: 500}}) { |
| + t.Fatalf("stored rate_limit = %+v, want rate 500 only", rec.RateLimit) |
| + } |
| + if detail := lastEventDetail(t, fx, "shop"); detail != "updated instance "+rlHost+" (rate_limit) (cli)" { |
| + t.Errorf("audit detail = %q", detail) |
| + } |
| + |
| + // A patch that does not name rate_limit leaves it alone. |
| + if status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"steering":""}`); status != http.StatusOK { |
| + t.Fatalf("steering-only patch = %d %s", status, out) |
| + } |
| + if rec := rlRecord(t, fx); rec.RateLimit == nil || rec.RateLimit.Rate != 500 { |
| + t.Fatalf("steering-only patch moved rate_limit: %+v", rec.RateLimit) |
| + } |
| + |
| + // {} clears back to the defaults: the record carries no settings at all. |
| + if status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"rate_limit":{}}`); status != http.StatusOK { |
| + t.Fatalf("patch rate_limit {} = %d %s", status, out) |
| + } |
| + if rec := rlRecord(t, fx); rec.RateLimit != nil { |
| + t.Fatalf("stored rate_limit after clear = %+v, want nil", rec.RateLimit) |
| + } |
| + |
| + // The empty-body rail names rate_limit among the patchable fields. |
| + status, _, out = rawReq(t, http.MethodPatch, pu, token, `{}`) |
| + if status != http.StatusBadRequest || !strings.Contains(out, "rate_limit") { |
| + t.Fatalf("empty patch = %d %s, want 400 naming rate_limit", status, out) |
| + } |
| +} |
| + |
| +func TestPatchRateLimitInvalid400(t *testing.T) { |
| + fx, token := rateLimitFixture(t, "code-rl-invalid") |
| + pu := updateURL(fx, "bab", "shop", rlHost) |
| + if status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"rate_limit":{"rate":500}}`); status != http.StatusOK { |
| + t.Fatalf("patch = %d %s", status, out) |
| + } |
| + before := serial(t, fx) |
| + |
| + var paths []string |
| + for i := 0; i < ratelimit.MaxPaths+1; i++ { |
| + paths = append(paths, fmt.Sprintf(`{"prefix":"/p%d/","rate":400}`, i)) |
| + } |
| + status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"rate_limit":{"paths":[`+strings.Join(paths, ",")+`]}}`) |
| + if status != http.StatusBadRequest || !strings.Contains(out, "rate_limit.paths") { |
| + t.Fatalf("9 paths = %d %s, want 400 naming rate_limit.paths", status, out) |
| + } |
| + if rec := rlRecord(t, fx); rec.RateLimit == nil || !reflect.DeepEqual(*rec.RateLimit, ratelimit.Settings{Rule: ratelimit.Rule{Rate: 500}}) { |
| + t.Fatalf("refused patch changed the record: %+v", rec.RateLimit) |
| + } |
| + if got := serial(t, fx); got != before { |
| + t.Errorf("serial after a refused patch = %d, want %d", got, before) |
| + } |
| +} |
| + |
| +func TestPatchRateLimitOverPlanCeiling(t *testing.T) { |
| + fx, token := rateLimitFixture(t, "code-rl-ceiling") |
| + pu := updateURL(fx, "bab", "shop", rlHost) |
| + // Installed after the create: the catalog's running_things cap is not |
| + // what this test is about. |
| + fx.srv.cfg.PlanCatalog = rlCatalog(t) |
| + |
| + refused := []struct{ body, want string }{ |
| + {`{"rate":500}`, "The backstop rate can be at most 400 on this account's plan."}, |
| + {`{"burst":2001}`, "The backstop burst can be at most 2000 on this account's plan."}, |
| + {`{"client_share":9}`, "The share per client can be at most 8 on this account's plan."}, |
| + // Several over: rate, then burst, then the share. |
| + {`{"rate":500,"burst":3000,"client_share":64}`, "The backstop rate can be at most 400 on this account's plan."}, |
| + {`{"burst":3000,"client_share":64}`, "The backstop burst can be at most 2000 on this account's plan."}, |
| + // The base rule first, then each path rule in prefix order, whatever |
| + // order the caller sent them in. |
| + {`{"client_share":9,"paths":[{"prefix":"/a/","rate":500}]}`, "The share per client can be at most 8 on this account's plan."}, |
| + {`{"paths":[{"prefix":"/z/","rate":500},{"prefix":"/a/","burst":3000}]}`, "The backstop burst can be at most 2000 on this account's plan."}, |
| + } |
| + for _, c := range refused { |
| + status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"rate_limit":`+c.body+`}`) |
| + if status != http.StatusBadRequest { |
| + t.Errorf("%s = %d %s, want 400", c.body, status, out) |
| + continue |
| + } |
| + if got := jsonErrorText(t, out); got != c.want { |
| + t.Errorf("%s refusal = %q, want %q", c.body, got, c.want) |
| + } |
| + } |
| + if rec := rlRecord(t, fx); rec.RateLimit != nil { |
| + t.Fatalf("a refused patch wrote %+v", rec.RateLimit) |
| + } |
| + |
| + // At the ceiling is allowed, and so is a rule that leaves the share unset |
| + // though its default (16) is above this plan's ceiling (8). |
| + for _, body := range []string{`{"rate":400}`, `{"rate":400,"burst":2000,"client_share":8,"paths":[{"prefix":"/api/","rate":400}]}`} { |
| + if status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"rate_limit":`+body+`}`); status != http.StatusOK { |
| + t.Errorf("%s = %d %s, want 200", body, status, out) |
| + } |
| + } |
| + |
| + // With no catalog there is no ceiling. |
| + fx.srv.cfg.PlanCatalog = nil |
| + if status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"rate_limit":{"rate":500000}}`); status != http.StatusOK { |
| + t.Fatalf("no catalog, rate 500000 = %d %s, want 200", status, out) |
| + } |
| + if rec := rlRecord(t, fx); rec.RateLimit == nil || rec.RateLimit.Rate != 500000 { |
| + t.Fatalf("stored = %+v, want rate 500000", rec.RateLimit) |
| + } |
| +} |
| + |
| +// rlRateLimitView is the GET's answer as a consumer reads it. |
| +type rlRateLimitView struct { |
| + Host string `json:"host"` |
| + Settings ratelimit.Settings `json:"settings"` |
| + Defaults ratelimit.Settings `json:"defaults"` |
| + Ceiling *ratelimit.Rule `json:"ceiling"` |
| + Effective ratelimit.Effective `json:"effective"` |
| + Counts struct { |
| + Shed int64 `json:"shed"` |
| + Ceiling int64 `json:"ceiling"` |
| + Since string `json:"since"` |
| + } `json:"counts"` |
| +} |
| + |
| +func rlUpsert(t *testing.T, fx *consoleFixture, box string, at time.Time, host string, row usage.EdgeRow) { |
| + t.Helper() |
| + if err := usage.UpsertEdgeDay(context.Background(), fx.rel, box, at, map[string]usage.EdgeRow{host: row}); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestRateLimitGet(t *testing.T) { |
| + fx, token := rateLimitFixture(t, "code-rl-get") |
| + pu := updateURL(fx, "bab", "shop", rlHost) |
| + gu := pu + "/rate-limit" |
| + if status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"rate_limit":{"busy_at":100,"rate":500}}`); status != http.StatusOK { |
| + t.Fatalf("patch = %d %s", status, out) |
| + } |
| + |
| + // Two boxes' rows within the day, one row 25 hours old, and another |
| + // host's row that must not be counted. |
| + now := time.Now() |
| + ours := usage.EdgeRow{Account: "bab", App: "shop"} |
| + r1, r2, old := ours, ours, ours |
| + r1.RateShed, r1.RateCeiling = 3, 1 |
| + r2.RateShed, r2.RateCeiling = 5, 2 |
| + old.RateShed, old.RateCeiling = 1000, 1000 |
| + rlUpsert(t, fx, "box-a", now, rlHost, r1) |
| + rlUpsert(t, fx, "box-a", now, "b.shop.example.com", usage.EdgeRow{Account: "bab", App: "shop", RateShed: 70}) |
| + rlUpsert(t, fx, "box-b", now.Add(-2*time.Hour), rlHost, r2) |
| + rlUpsert(t, fx, "box-b", now.Add(-25*time.Hour), rlHost, old) |
| + |
| + // No catalog: the ceiling is null and the owner's numbers apply as set. |
| + status, _, out := rawReq(t, http.MethodGet, gu, token, "") |
| + if status != http.StatusOK { |
| + t.Fatalf("GET = %d %s", status, out) |
| + } |
| + if !strings.Contains(out, `"ceiling":null`) { |
| + t.Errorf("no catalog: want \"ceiling\":null in %s", out) |
| + } |
| + var v rlRateLimitView |
| + if err := json.Unmarshal([]byte(out), &v); err != nil { |
| + t.Fatal(err) |
| + } |
| + if v.Effective.Base.Rate != 500 { |
| + t.Errorf("no catalog: effective rate = %d, want 500", v.Effective.Base.Rate) |
| + } |
| + |
| + fx.srv.cfg.PlanCatalog = rlCatalog(t) |
| + status, _, out = rawReq(t, http.MethodGet, gu, token, "") |
| + if status != http.StatusOK { |
| + t.Fatalf("GET = %d %s", status, out) |
| + } |
| + v = rlRateLimitView{} |
| + if err := json.Unmarshal([]byte(out), &v); err != nil { |
| + t.Fatal(err) |
| + } |
| + if v.Host != rlHost { |
| + t.Errorf("host = %q", v.Host) |
| + } |
| + if !reflect.DeepEqual(v.Settings, ratelimit.Settings{BusyAt: 100, Rule: ratelimit.Rule{Rate: 500}}) { |
| + t.Errorf("settings = %+v", v.Settings) |
| + } |
| + if !reflect.DeepEqual(v.Defaults, ratelimit.Defaults) { |
| + t.Errorf("defaults = %+v, want %+v", v.Defaults, ratelimit.Defaults) |
| + } |
| + if v.Ceiling == nil || *v.Ceiling != (ratelimit.Rule{ClientShare: 8, Rate: 400, Burst: 2000}) { |
| + t.Errorf("ceiling = %+v", v.Ceiling) |
| + } |
| + want := ratelimit.Resolve(v.Settings, ratelimit.Ceiling{ClientShare: 8, Rate: 400, Burst: 2000}) |
| + if !reflect.DeepEqual(v.Effective, want) || v.Effective.Base.Rate != 400 || v.Effective.BusyAt != 100 { |
| + t.Errorf("effective = %+v, want %+v", v.Effective, want) |
| + } |
| + if v.Counts.Shed != 8 || v.Counts.Ceiling != 3 { |
| + t.Errorf("counts = %+v, want shed 8 ceiling 3 (two boxes, nothing 25h old, no other host)", v.Counts) |
| + } |
| + since, err := time.Parse(time.RFC3339, v.Counts.Since) |
| + if err != nil { |
| + t.Fatalf("since %q: %v", v.Counts.Since, err) |
| + } |
| + if d := since.Sub(now.Add(-24 * time.Hour)); d < -time.Second || d > time.Minute { |
| + t.Errorf("since = %s, want about %s", since, now.Add(-24*time.Hour).UTC()) |
| + } |
| + if !strings.HasSuffix(v.Counts.Since, "Z") { |
| + t.Errorf("since = %q, want UTC", v.Counts.Since) |
| + } |
| + |
| + // A host that names no record answers the instance GET's own 404. |
| + status, h, out := rawReq(t, http.MethodGet, updateURL(fx, "bab", "shop", "none.shop.example.com")+"/rate-limit", token, "") |
| + assertNotFound(t, "GET rate-limit for a host with no record", canonicalNotFound(t), status, h, out) |
| +} |
| + |
| +// TestRateLimitCountsWindow pins the 24-hour window at a fixed clock: an |
| +// hour bucket counts when any of it falls inside the window, the window |
| +// reaches into yesterday's day object, and a row for the same host under |
| +// another account or app is not this instance's. |
| +func TestRateLimitCountsWindow(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Date(2026, 10, 24, 10, 30, 0, 0, time.UTC) |
| + ours := usage.EdgeRow{Account: "bab", App: "shop"} |
| + at := func(day, hour int) time.Time { return time.Date(2026, 10, day, hour, 5, 0, 0, time.UTC) } |
| + row := func(base usage.EdgeRow, shed, ceil int64) usage.EdgeRow { |
| + base.RateShed, base.RateCeiling = shed, ceil |
| + return base |
| + } |
| + rlUpsert(t, fx, "box-a", at(24, 10), rlHost, row(ours, 1, 0)) // this hour |
| + rlUpsert(t, fx, "box-a", at(23, 10), rlHost, row(ours, 10, 0)) // 10:00-11:00 yesterday overlaps |
| + rlUpsert(t, fx, "box-b", at(23, 9), rlHost, row(ours, 100, 100)) // ends at 10:00 yesterday: out |
| + rlUpsert(t, fx, "box-b", at(23, 23), rlHost, row(ours, 0, 1000)) // in |
| + rlUpsert(t, fx, "box-c", at(24, 1), rlHost, row(usage.EdgeRow{Account: "bac", App: "shop"}, 7, 7)) // another account |
| + rlUpsert(t, fx, "box-c", at(24, 2), "x"+rlHost, row(ours, 9, 9)) // another host |
| + rlUpsert(t, fx, "box-c", at(24, 3), rlHost, row(usage.EdgeRow{Account: "bab", App: "other"}, 11, 11)) // another app |
| + |
| + acct, err := fx.st.AccountBySqid("bab") |
| + if err != nil || acct == nil { |
| + t.Fatalf("ops account: %v", err) |
| + } |
| + got, err := fx.srv.rateLimitCounts(context.Background(), acct, "shop", rlHost, now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Shed != 11 || got.Ceiling != 1000 || got.Since != "2026-10-23T10:30:00Z" { |
| + t.Fatalf("counts = %+v, want shed 11 ceiling 1000 since 2026-10-23T10:30:00Z", got) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 954e47d57..6482c74df 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -706,6 +706,9 @@ func (s *Server) registerCLIRoutes() { |
| // express; the Instance role is the same authority the create and |
| // delete doors already grant this credential). |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveStatus) |
| + // The rate-limit read (#358): the settings the PATCH above writes, with |
| + // the plan ceiling, what the edge applies, and the last day's refusals. |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/rate-limit", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceRateLimitGet) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveRequest) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveCut) |
| s.cliRoute("DELETE /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveAbort) |
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go| index 20b1b8dab..7b2a81a7f 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -826,6 +826,8 @@ var claimFixtures = map[string]claimFixture{ |
| "route GET /api/cli/apps/{account}/{app}/instances": claimRead("lists instance records"), |
| "route GET /api/cli/apps/{account}/{app}/instances/{host}": claimRead("reads one instance record"), |
| "route GET /api/cli/apps/{account}/{app}/instances/{host}/move": claimRead("reads one instance's move state"), |
| + "route GET /api/cli/apps/{account}/{app}/instances/{host}/rate-limit": claimRead( |
| + "reads one instance's rate-limit settings, plan ceiling and usage counts"), |
| "route GET /api/cli/apps/{account}/{app}/ledgers": claimRead("lists ledgers"), |
| "route GET /api/cli/apps/{account}/{app}/ledgers/{ledger}/head": claimRead("reads a ledger head"), |
| "route GET /api/cli/apps/{account}/{app}/logs": claimRead("reads log chunks"), |
diff --git a/internal/console/claim_invariant_probes_read_test.go b/internal/console/claim_invariant_probes_read_test.go| index 5c814388d..0ee9d31bd 100644 |
| --- a/internal/console/claim_invariant_probes_read_test.go |
| +++ b/internal/console/claim_invariant_probes_read_test.go |
| @@ -191,6 +191,8 @@ var claimReadProbes = map[string]claimProbe{ |
| "/api/cli/apps/bab/titogo/instances/acme.go.tito.io", "acme.go.tito.io"), |
| "route GET /api/cli/apps/{account}/{app}/instances/{host}/move": claimAPIRead(claimWorldMove, |
| "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/move", `"state":"none"`), |
| + "route GET /api/cli/apps/{account}/{app}/instances/{host}/rate-limit": claimAPIRead(claimWorldInstance, |
| + "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/rate-limit", `"host":"acme.go.tito.io"`, `"counts":{`), |
| "route GET /api/cli/apps/{account}/{app}/instances/{host}/updates": claimAPIRead(claimWorldInstance, |
| "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/updates", `"host":"acme.go.tito.io"`, `"updates":"auto"`), |
| "route GET /api/cli/apps/{account}/{app}/ledgers": claimAPIRead(claimWorldLedger, "/api/cli/apps/bab/titogo/ledgers", `"carbon"`), |
diff --git a/internal/console/ratelimit_copy.go b/internal/console/ratelimit_copy.go| new file mode 100644 |
| index 000000000..51efe8e4c |
| --- /dev/null |
| +++ b/internal/console/ratelimit_copy.go |
| @@ -0,0 +1,16 @@ |
| +// Approved in the copy review of 2026-10-24 (#358), unedited. |
| +// Apply later edits through copy review, not by hand. |
| + |
| +package console |
| + |
| +// rateLimitCopy holds the console's rate-limit refusal. "over_ceiling" is |
| +// copy id console.ratelimit.overCeiling, kept with its {field} and {max} |
| +// placeholders as reviewed; the others are the {field} values, keyed by the |
| +// settings field they name (ids console.ratelimit.field.rate, .burst and |
| +// .share). |
| +var rateLimitCopy = map[string]string{ |
| + "over_ceiling": "{field} can be at most {max} on this account's plan.", |
| + "rate": "The backstop rate", |
| + "burst": "The backstop burst", |
| + "client_share": "The share per client", |
| +} |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index 92d8e2fdb..96c93f6a5 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -539,6 +539,11 @@ var exemptFromLegacyPin = map[string]bool{ |
| "POST /api/cli/apps/{account}/{app}/instances/{host}/move": true, |
| "POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut": true, |
| "DELETE /api/cli/apps/{account}/{app}/instances/{host}/move": true, |
| + // The rate-limit read (#358) stands on the instance GET's footing: the |
| + // record it reads is one the instance role already reads and PATCHes, |
| + // and the counts are that host's own refusals. It did not exist before |
| + // service principals. |
| + "GET /api/cli/apps/{account}/{app}/instances/{host}/rate-limit": true, |
| // The offline operator move's doors: each is operator-only behind |
| // moveOperatorGate (a service token meets the family's 404), and |
| // refuses with MV-O20 on a console with host claims or Phase 1 on. |
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt| index 92a03ad37..1f17e0834 100644 |
| --- a/internal/console/testdata/claim-routes.txt |
| +++ b/internal/console/testdata/claim-routes.txt |
| @@ -59,6 +59,7 @@ route GET /api/cli/apps/{account}/{app}/instances |
| route GET /api/cli/apps/{account}/{app}/instances/{host} |
| route GET /api/cli/apps/{account}/{app}/instances/{host}/move |
| route GET /api/cli/apps/{account}/{app}/instances/{host}/move/offline |
| +route GET /api/cli/apps/{account}/{app}/instances/{host}/rate-limit |
| route GET /api/cli/apps/{account}/{app}/instances/{host}/runs/{id} |
| route GET /api/cli/apps/{account}/{app}/ledgers |
| route GET /api/cli/apps/{account}/{app}/ledgers/{ledger}/head |