carlos / platform Public

carlos ratelimit: show, set and clear per-instance rate limits (#358)

show prints each instance's limits with defaulted values marked and any
plan-ceiling cap noted, plus the last day's refusals. set reads the current
settings, overlays only the flags named, and PATCHes the whole object. clear
sends an empty rate_limit. Without -host the verbs fan out over every
instance and exit 1 if any fail. A malformed -path exits 2 before any
request.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev d9b13590e72a9209b2254cacaee57bb41c7fd090 parent f8545e2
5 files changed, +798 −0
  • apimode.go +59 −0
  • docs/site/cli.md +30 −0
  • main.go +2 −0
  • ratelimit_cli.go +417 −0
  • ratelimit_cli_test.go +290 −0
diff --git a/apimode.go b/apimode.go
index 42621bc70..ec151e670 100644
--- a/apimode.go
+++ b/apimode.go
@@ -40,6 +40,7 @@ import (
"github.com/carlosframework/platform/internal/dbdoor"
"github.com/carlosframework/platform/internal/ledger"
"github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/ratelimit"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/schedule"
"github.com/carlosframework/platform/internal/updatewords"
@@ -3622,6 +3623,64 @@ func (s *apiSession) instanceSetGate(ctx context.Context, a apiAcct, app, host,
return out, nil
}
+// apiRateLimit is the console's answer for one instance's rate limit
+// (internal/console/apiinstances_ratelimit.go): what the owner set, the
+// defaults and plan ceiling those resolve against, the result the edge
+// enforces, and the last day's refusals. Ceiling is nil when the plan
+// has none.
+type apiRateLimit 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"`
+}
+
+// instanceRateLimit reads one instance's rate limit through the console.
+func (s *apiSession) instanceRateLimit(ctx context.Context, a apiAcct, app, host string) (apiRateLimit, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)+"/rate-limit"))
+ if err != nil {
+ return apiRateLimit{}, err
+ }
+ if status != http.StatusOK {
+ return apiRateLimit{}, apiErrorFromBody(status, body)
+ }
+ var out apiRateLimit
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiRateLimit{}, fmt.Errorf("rate limit: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+// instanceSetRateLimit replaces one instance's rate-limit settings whole;
+// the zero Settings clears them. A console older than the field answers 400
+// "nothing to update", which is reported as the approved older-console text.
+func (s *apiSession) instanceSetRateLimit(ctx context.Context, a apiAcct, app, host string, set ratelimit.Settings) (apiInstanceRecord, error) {
+ in := struct {
+ RateLimit ratelimit.Settings `json:"rate_limit"`
+ }{set}
+ status, body, err := s.patchJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)), in)
+ if err != nil {
+ return apiInstanceRecord{}, err
+ }
+ if status != http.StatusOK {
+ if status == http.StatusBadRequest && strings.Contains(string(body), "nothing to update") {
+ return apiInstanceRecord{}, errors.New(ratelimitConsoleOld)
+ }
+ return apiInstanceRecord{}, apiErrorFromBody(status, body)
+ }
+ var out apiInstanceRecord
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiInstanceRecord{}, fmt.Errorf("rate limit: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
// instanceList reads the app's instance records through the console, honest
// about the server's 500-record cap. match and health are the server-side
// filters Task 9 added (q and health query params) — empty strings send
diff --git a/docs/site/cli.md b/docs/site/cli.md
index 0a8e384f2..da7115d7c 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -1191,6 +1191,36 @@ attached to the app are gated along with the platform hostname. This is a
curtain in front of the whole hostname, not accounts for the app's own
users; an app with real sign-in keeps doing that itself.
+### carlos ratelimit
+
+```
+usage: carlos ratelimit <show|set|clear> [flags]
+
+ show -app <a> [-host <h>]
+ the limits on the app's instances (or one host), the
+ plan's ceiling if there is one, and the last day's refusals
+ set -app <a> [-host <h>] [-busy-at N] [-share N] [-rate N] [-burst N]
+ [-path /prefix:share=N,rate=N,burst=N]... [-remove-path /prefix]...
+ change only the values you name; the rest stay as they are
+ clear -app <a> [-host <h>]
+ go back to the platform's defaults
+
+ -busy-at N the app counts as busy at N requests in flight (default 64)
+ -share N while busy, each address may have N in flight (default 16)
+ -rate N backstop: N requests a second from one address (default 300)
+ -burst N backstop: bursts of up to N on top of the rate (default 1000)
+ -path different numbers for one path prefix, such as /api/
+
+ -account <a> account (sqid or name; default "ops")
+ -account-id <id> account sqid, matched as a sqid ONLY
+ -console <origin> console to act through (default: the only logged-in one)
+
+Signed-in only (carlos auth login). A quiet app is never limited except by
+the backstop, which only a flood from one address reaches. When the app is
+busy, the addresses with the most requests in flight are turned away first.
+The box picks a change up within seconds of the next converge tick.
+```
+
### carlos logs
The app's own stdout and stderr, merged with the platform's events about it:
diff --git a/main.go b/main.go
index 3f13a396f..1c70aa96e 100644
--- a/main.go
+++ b/main.go
@@ -77,6 +77,8 @@ func main() {
runFeatures(args)
case "gate":
runGate(args)
+ case "ratelimit":
+ runRatelimit(args)
case "ship":
runShip(args)
case "promote":
diff --git a/ratelimit_cli.go b/ratelimit_cli.go
new file mode 100644
index 000000000..19cca7030
--- /dev/null
+++ b/ratelimit_cli.go
@@ -0,0 +1,417 @@
+// The `carlos ratelimit` verb family: the per-instance rate limits an owner
+// sets on their own app (spec docs/superpowers/specs/2026-10-24-edge-rate-limit-design.md
+// §5.4). API mode only, like `carlos gate`: a limit is a console-authorized
+// write with an audit row.
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/ratelimit"
+)
+
+// Every string this verb prints, each named by its approved copy id. The
+// {name} words are filled in by ratelimitCopy.
+const (
+ // cli.ratelimit.usage
+ ratelimitUsage = `usage: carlos ratelimit <show|set|clear> [flags]
+
+ show -app <a> [-host <h>]
+ the limits on the app's instances (or one host), the
+ plan's ceiling if there is one, and the last day's refusals
+ set -app <a> [-host <h>] [-busy-at N] [-share N] [-rate N] [-burst N]
+ [-path /prefix:share=N,rate=N,burst=N]... [-remove-path /prefix]...
+ change only the values you name; the rest stay as they are
+ clear -app <a> [-host <h>]
+ go back to the platform's defaults
+
+ -busy-at N the app counts as busy at N requests in flight (default 64)
+ -share N while busy, each address may have N in flight (default 16)
+ -rate N backstop: N requests a second from one address (default 300)
+ -burst N backstop: bursts of up to N on top of the rate (default 1000)
+ -path different numbers for one path prefix, such as /api/
+
+ -account <a> account (sqid or name; default "ops")
+ -account-id <id> account sqid, matched as a sqid ONLY
+ -console <origin> console to act through (default: the only logged-in one)
+
+Signed-in only (carlos auth login). A quiet app is never limited except by
+the backstop, which only a flood from one address reaches. When the app is
+busy, the addresses with the most requests in flight are turned away first.
+The box picks a change up within seconds of the next converge tick.
+`
+ ratelimitShowBusyAt = "busy at {n} requests in flight" // cli.ratelimit.show.busyAt
+ ratelimitShowShare = "per address, busy {n} requests in flight" // cli.ratelimit.show.share
+ ratelimitShowBackstp = "backstop {rate} a second, bursts of {burst}" // cli.ratelimit.show.backstop
+ ratelimitShowPath = "{prefix} {share} in flight per address when busy, backstop {rate} a second, bursts of {burst}" // cli.ratelimit.show.path
+ ratelimitShowDefault = "(default)" // cli.ratelimit.show.default
+ ratelimitShowCapped = "(the plan caps this; you set {set})" // cli.ratelimit.show.capped
+ ratelimitShowCounts = "last 24 hours {shed} turned away while busy, {ceiling} over the backstop" // cli.ratelimit.show.counts
+ ratelimitSetOK = "rate limit set on" // cli.ratelimit.set.ok
+ ratelimitSetBad = "rate limit not set on" // cli.ratelimit.set.bad
+ ratelimitClearOK = "rate limit back to defaults on" // cli.ratelimit.clear.ok
+ ratelimitClearBad = "rate limit not cleared on" // cli.ratelimit.clear.bad
+ ratelimitPickup = "the box picks it up within seconds of the next converge tick" // cli.ratelimit.pickup
+ ratelimitBadPath = "-path {value}: want /prefix:share=N,rate=N,burst=N, naming any of the three" // cli.ratelimit.badPath
+ ratelimitConsoleOld = "this console does not know about rate limits yet. It needs a newer carlos." // cli.ratelimit.consoleTooOld
+)
+
+// ratelimitCopy fills the {name} words of an approved string.
+func ratelimitCopy(text string, pairs ...string) string {
+ for i := 0; i+1 < len(pairs); i += 2 {
+ text = strings.ReplaceAll(text, "{"+pairs[i]+"}", pairs[i+1])
+ }
+ return text
+}
+
+func runRatelimit(args []string) {
+ if len(args) > 0 {
+ switch args[0] {
+ case "show":
+ runRatelimitShow(args[1:])
+ return
+ case "set":
+ runRatelimitSet(args[1:])
+ return
+ case "clear":
+ runRatelimitClear(args[1:])
+ return
+ }
+ }
+ log.Fatal(ratelimitUsage)
+}
+
+func requireRatelimitAPISession(consoleFlag string) *apiSession {
+ sess, err := resolveMode(consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess == nil {
+ log.Fatal("carlos ratelimit works through a console, and this shell is in direct bucket mode\n" +
+ "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). A rate limit is a console write with an\n" +
+ "audit row. Unset the store env and run: carlos auth login -console <url>.")
+ }
+ return sess
+}
+
+func ratelimitFlags(name string) (*flag.FlagSet, *string, *string, *string, *string, *string) {
+ fs := flag.NewFlagSet("ratelimit "+name, flag.ExitOnError)
+ app := appFlag(fs, "app whose instances to limit")
+ host := fs.String("host", "", "one instance host (default: every instance of the app)")
+ consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ return fs, app, host, consoleFlag, acctRaw, acctIDRaw
+}
+
+// ratelimitHosts is the hosts a verb acts on: -host alone, else every
+// instance record of the app, from the same lister the gate verb uses.
+func ratelimitHosts(sess *apiSession, acct apiAcct, app, host string) []string {
+ if host != "" {
+ return []string{host}
+ }
+ recs, _, _, err := sess.instanceList(context.Background(), acct, app, "", "")
+ if err != nil {
+ log.Fatalf("ratelimit: list instances: %v", err)
+ }
+ if len(recs) == 0 {
+ log.Fatalf("ratelimit: %s has no instances", app)
+ }
+ hosts := make([]string, 0, len(recs))
+ for _, r := range recs {
+ hosts = append(hosts, r.Host)
+ }
+ return hosts
+}
+
+func runRatelimitShow(args []string) {
+ fs, app, host, consoleFlag, acctRaw, acctIDRaw := ratelimitFlags("show")
+ fs.Parse(args)
+ sess := requireRatelimitAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, ratelimitUsage)
+ if failed := ratelimitShow(sess, acct, app2, ratelimitHosts(sess, acct, app2, strings.TrimSpace(*host)), stdout); failed > 0 {
+ os.Exit(1)
+ }
+}
+
+// ratelimitShow prints each host's limits and returns how many it could not
+// read. A host that fails does not stop the rest.
+func ratelimitShow(sess *apiSession, acct apiAcct, app string, hosts []string, w io.Writer) (failed int) {
+ for _, h := range hosts {
+ rl, err := sess.instanceRateLimit(context.Background(), acct, app, h)
+ if err != nil {
+ failed++
+ fmt.Fprintf(w, "%s: %v\n", h, err)
+ continue
+ }
+ printRateLimit(w, rl)
+ }
+ return failed
+}
+
+// printRateLimit is one host: its name on its own line, then the busy
+// point, the per-address share, the backstop, one line per path rule and the
+// counts, each indented two spaces. A value the owner left unset is marked
+// as the default; one the plan ceiling lowered carries what the owner set.
+func printRateLimit(w io.Writer, rl apiRateLimit) {
+ n := func(v int) string { return strconv.Itoa(v) }
+ set, eff := rl.Settings, rl.Effective
+ lines := []string{}
+
+ busy := ratelimitCopy(ratelimitShowBusyAt, "n", n(eff.BusyAt))
+ if set.BusyAt == 0 {
+ busy += " " + ratelimitShowDefault
+ }
+ lines = append(lines, busy)
+
+ share := ratelimitCopy(ratelimitShowShare, "n", n(eff.Base.ClientShare))
+ switch {
+ case set.ClientShare == 0:
+ share += " " + ratelimitShowDefault
+ case eff.Base.ClientShare < set.ClientShare:
+ share += " " + ratelimitCopy(ratelimitShowCapped, "set", n(set.ClientShare))
+ }
+ lines = append(lines, share)
+
+ backstop := ratelimitCopy(ratelimitShowBackstp, "rate", n(eff.Base.Rate), "burst", n(eff.Base.Burst))
+ switch {
+ case set.Rate == 0 && set.Burst == 0:
+ backstop += " " + ratelimitShowDefault
+ case (set.Rate != 0 && eff.Base.Rate < set.Rate) || (set.Burst != 0 && eff.Base.Burst < set.Burst):
+ // What the owner asked for: their own numbers, the default where
+ // they named only one of the two.
+ asked := ratelimit.Defaults.Rule
+ if set.Rate != 0 {
+ asked.Rate = set.Rate
+ }
+ if set.Burst != 0 {
+ asked.Burst = set.Burst
+ }
+ // The numbers in the backstop line's own words, without its label.
+ words := ratelimitShowBackstp[strings.Index(ratelimitShowBackstp, "{rate}"):]
+ backstop += " " + ratelimitCopy(ratelimitShowCapped, "set",
+ ratelimitCopy(words, "rate", n(asked.Rate), "burst", n(asked.Burst)))
+ }
+ lines = append(lines, backstop)
+
+ for _, p := range eff.Paths {
+ lines = append(lines, ratelimitCopy(ratelimitShowPath, "prefix", p.Prefix,
+ "share", n(p.ClientShare), "rate", n(p.Rate), "burst", n(p.Burst)))
+ }
+ lines = append(lines, ratelimitCopy(ratelimitShowCounts,
+ "shed", strconv.FormatInt(rl.Counts.Shed, 10), "ceiling", strconv.FormatInt(rl.Counts.Ceiling, 10)))
+
+ fmt.Fprintln(w, rl.Host)
+ for _, l := range lines {
+ fmt.Fprintln(w, " "+l)
+ }
+}
+
+// pathFlags collects the repeatable -path and -remove-path values.
+type pathFlags []string
+
+func (p *pathFlags) String() string { return strings.Join(*p, " ") }
+func (p *pathFlags) Set(v string) error { *p = append(*p, v); return nil }
+
+// parseRatelimitPath reads one -path value, /prefix:share=N,rate=N,burst=N,
+// naming any non-empty subset of the three.
+func parseRatelimitPath(v string) (ratelimit.PathRule, bool) {
+ var p ratelimit.PathRule
+ i := strings.LastIndex(v, ":")
+ if i < 0 || !strings.HasPrefix(v, "/") {
+ return p, false
+ }
+ p.Prefix = v[:i]
+ seen := map[string]bool{}
+ for _, kv := range strings.Split(v[i+1:], ",") {
+ k, val, ok := strings.Cut(kv, "=")
+ if !ok || seen[k] {
+ return p, false
+ }
+ seen[k] = true
+ num, err := strconv.Atoi(val)
+ if err != nil || num < 0 {
+ return p, false
+ }
+ switch k {
+ case "share":
+ p.ClientShare = num
+ case "rate":
+ p.Rate = num
+ case "burst":
+ p.Burst = num
+ default:
+ return p, false
+ }
+ }
+ return p, true
+}
+
+// overlayRatelimit is cur with only the values the owner named changed. A
+// -path naming a prefix that already has a rule changes only the numbers it
+// names; the last -path for one prefix wins over an earlier one. Removals
+// go first, so a prefix named in both is added fresh. Paths come back in
+// prefix order so the PATCH body does not depend on flag order.
+func overlayRatelimit(cur ratelimit.Settings, busyAt, share, rate, burst *int, add []ratelimit.PathRule, remove []string) ratelimit.Settings {
+ out := cur
+ if busyAt != nil {
+ out.BusyAt = *busyAt
+ }
+ if share != nil {
+ out.ClientShare = *share
+ }
+ if rate != nil {
+ out.Rate = *rate
+ }
+ if burst != nil {
+ out.Burst = *burst
+ }
+ byPrefix := map[string]ratelimit.PathRule{}
+ for _, p := range cur.Paths {
+ byPrefix[p.Prefix] = p
+ }
+ for _, pre := range remove {
+ delete(byPrefix, pre)
+ }
+ last := map[string]ratelimit.PathRule{}
+ for _, p := range add {
+ last[p.Prefix] = p
+ }
+ for pre, p := range last {
+ if old, ok := byPrefix[pre]; ok {
+ if p.ClientShare == 0 {
+ p.ClientShare = old.ClientShare
+ }
+ if p.Rate == 0 {
+ p.Rate = old.Rate
+ }
+ if p.Burst == 0 {
+ p.Burst = old.Burst
+ }
+ }
+ byPrefix[pre] = p
+ }
+ out.Paths = nil
+ for _, p := range byPrefix {
+ out.Paths = append(out.Paths, p)
+ }
+ sort.Slice(out.Paths, func(i, j int) bool { return out.Paths[i].Prefix < out.Paths[j].Prefix })
+ return out
+}
+
+func runRatelimitSet(args []string) {
+ fs, app, host, consoleFlag, acctRaw, acctIDRaw := ratelimitFlags("set")
+ busyAt := fs.Int("busy-at", 0, "in-flight count at which the app counts as busy")
+ share := fs.Int("share", 0, "in-flight requests each address may have while busy")
+ rate := fs.Int("rate", 0, "backstop: requests a second from one address")
+ burst := fs.Int("burst", 0, "backstop: burst size on top of the rate")
+ var paths, removes pathFlags
+ fs.Var(&paths, "path", "/prefix:share=N,rate=N,burst=N for one path prefix (repeatable)")
+ fs.Var(&removes, "remove-path", "/prefix to drop the rule for (repeatable)")
+ fs.Parse(args)
+
+ given := map[string]*int{}
+ fs.Visit(func(f *flag.Flag) {
+ switch f.Name {
+ case "busy-at":
+ given[f.Name] = busyAt
+ case "share":
+ given[f.Name] = share
+ case "rate":
+ given[f.Name] = rate
+ case "burst":
+ given[f.Name] = burst
+ }
+ })
+ // A malformed -path is refused before anything is read or sent.
+ var add []ratelimit.PathRule
+ for _, v := range paths {
+ p, ok := parseRatelimitPath(v)
+ if !ok {
+ fmt.Fprintln(os.Stderr, ratelimitCopy(ratelimitBadPath, "value", v))
+ os.Exit(2)
+ }
+ add = append(add, p)
+ }
+ if len(given) == 0 && len(add) == 0 && len(removes) == 0 {
+ log.Fatal(ratelimitUsage)
+ }
+
+ sess := requireRatelimitAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, ratelimitUsage)
+ hosts := ratelimitHosts(sess, acct, app2, strings.TrimSpace(*host))
+ failed := ratelimitApply(sess, acct, app2, hosts, func(cur ratelimit.Settings) ratelimit.Settings {
+ return overlayRatelimit(cur, given["busy-at"], given["share"], given["rate"], given["burst"], add, removes)
+ }, stdout)
+ ratelimitPickupLine(stdout, len(hosts), failed)
+ if failed > 0 {
+ os.Exit(1)
+ }
+}
+
+func runRatelimitClear(args []string) {
+ fs, app, host, consoleFlag, acctRaw, acctIDRaw := ratelimitFlags("clear")
+ fs.Parse(args)
+ sess := requireRatelimitAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, ratelimitUsage)
+ hosts := ratelimitHosts(sess, acct, app2, strings.TrimSpace(*host))
+ failed := ratelimitApply(sess, acct, app2, hosts, nil, stdout)
+ ratelimitPickupLine(stdout, len(hosts), failed)
+ if failed > 0 {
+ os.Exit(1)
+ }
+}
+
+// ratelimitPickupLine says when the change lands, once, and only if it
+// landed anywhere.
+func ratelimitPickupLine(w io.Writer, hosts, failed int) {
+ if failed < hosts {
+ mutedf(w, "%s", ratelimitPickup)
+ }
+}
+
+// ratelimitApply is the set/clear loop: one line per host, and the number
+// that failed, which the callers turn into the exit code. A failure on one
+// host does not stop the others. change == nil is the clear; otherwise each
+// host's current settings are read and change returns what to store.
+func ratelimitApply(sess *apiSession, acct apiAcct, app string, hosts []string, change func(ratelimit.Settings) ratelimit.Settings, w io.Writer) (failed int) {
+ bad, ok := ratelimitSetBad, ratelimitSetOK
+ if change == nil {
+ bad, ok = ratelimitClearBad, ratelimitClearOK
+ }
+ ctx := context.Background()
+ for _, h := range hosts {
+ var next ratelimit.Settings
+ var err error
+ if change != nil {
+ var cur apiRateLimit
+ if cur, err = sess.instanceRateLimit(ctx, acct, app, h); err == nil {
+ next = change(cur.Settings)
+ }
+ }
+ if err == nil {
+ _, err = sess.instanceSetRateLimit(ctx, acct, app, h, next)
+ }
+ if err != nil {
+ failed++
+ fmt.Fprintf(w, "%s %s: %v\n", colorFor(w).bad(bad), h, err)
+ continue
+ }
+ fmt.Fprintf(w, "%s %s\n", colorFor(w).ok(ok), h)
+ }
+ return failed
+}
diff --git a/ratelimit_cli_test.go b/ratelimit_cli_test.go
new file mode 100644
index 000000000..52a1b16bc
--- /dev/null
+++ b/ratelimit_cli_test.go
@@ -0,0 +1,290 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/ratelimit"
+)
+
+// rlConsole is a fake console for the ratelimit client: the instance list,
+// the per-host rate-limit GET and the instance PATCH, each driven by the
+// test, with every request recorded so a test can prove a door was not
+// knocked on.
+type rlConsole struct {
+ ts *httptest.Server
+ seen []string
+ list string // body for GET .../instances
+ current string // body for GET .../rate-limit
+ patch func(host string) (int, string) // status and body for PATCH .../instances/<host>
+ bodies map[string]string // PATCH body by host
+}
+
+func newRLConsole(t *testing.T) *rlConsole {
+ t.Helper()
+ fc := &rlConsole{bodies: map[string]string{}, current: `{"host":"h","settings":{}}`}
+ fc.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fc.seen = append(fc.seen, r.Method+" "+r.URL.Path)
+ switch {
+ case r.Method == http.MethodPatch:
+ host := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
+ b, _ := io.ReadAll(r.Body)
+ fc.bodies[host] = string(b)
+ status, body := 200, `{"host":"`+host+`"}`
+ if fc.patch != nil {
+ status, body = fc.patch(host)
+ }
+ w.WriteHeader(status)
+ w.Write([]byte(body))
+ case strings.HasSuffix(r.URL.Path, "/rate-limit"):
+ w.Write([]byte(fc.current))
+ default:
+ w.Write([]byte(fc.list))
+ }
+ }))
+ t.Cleanup(fc.ts.Close)
+ return fc
+}
+
+// patchedSettings is the rate_limit object of a recorded PATCH body.
+func patchedSettings(t *testing.T, body string) ratelimit.Settings {
+ t.Helper()
+ var in struct {
+ RateLimit *ratelimit.Settings `json:"rate_limit"`
+ }
+ if err := json.Unmarshal([]byte(body), &in); err != nil || in.RateLimit == nil {
+ t.Fatalf("PATCH body %q: want a rate_limit object (%v)", body, err)
+ }
+ return *in.RateLimit
+}
+
+func ratelimitSet(fc *rlConsole, hosts []string, change func(ratelimit.Settings) ratelimit.Settings) (int, string) {
+ var out bytes.Buffer
+ failed := ratelimitApply(newTestSession(fc.ts.URL), apiAcct{Seg: "bah"}, "app", hosts, change, &out)
+ return failed, out.String()
+}
+
+func TestRatelimitSetOverlaysFlags(t *testing.T) {
+ fc := newRLConsole(t)
+ fc.current = `{"host":"a.example.com","settings":{"rate":500}}`
+ burst := 2000
+ failed, out := ratelimitSet(fc, []string{"a.example.com"}, func(cur ratelimit.Settings) ratelimit.Settings {
+ return overlayRatelimit(cur, nil, nil, nil, &burst, nil, nil)
+ })
+ if failed != 0 {
+ t.Fatalf("failed = %d: %s", failed, out)
+ }
+ got := fc.bodies["a.example.com"]
+ if got != `{"rate_limit":{"rate":500,"burst":2000}}` {
+ t.Errorf("PATCH body = %s, want the current rate kept and the burst added", got)
+ }
+ if !strings.Contains(out, ratelimitSetOK+" a.example.com") {
+ t.Errorf("output = %q", out)
+ }
+}
+
+func TestRatelimitPathFlagParse(t *testing.T) {
+ p, ok := parseRatelimitPath("/api/:share=32,rate=1000")
+ if !ok || p.Prefix != "/api/" || p.ClientShare != 32 || p.Rate != 1000 || p.Burst != 0 {
+ t.Fatalf("parse = %+v, %v", p, ok)
+ }
+ for _, bad := range []string{"", "/api/", "/api/:", "api/:rate=1", "/api/:rate", "/api/:rate=x",
+ "/api/:rate=-1", "/api/:speed=1", "/api/:rate=1,rate=2", "/api/:rate=1,"} {
+ if _, ok := parseRatelimitPath(bad); ok {
+ t.Errorf("parse accepted %q", bad)
+ }
+ }
+
+ var cur ratelimit.Settings
+ add := []ratelimit.PathRule{
+ {Prefix: "/api/", Rule: ratelimit.Rule{ClientShare: 8}},
+ {Prefix: "/api/", Rule: ratelimit.Rule{ClientShare: 32, Rate: 1000}},
+ }
+ cur = overlayRatelimit(cur, nil, nil, nil, nil, add, nil)
+ if len(cur.Paths) != 1 || cur.Paths[0].ClientShare != 32 || cur.Paths[0].Rate != 1000 {
+ t.Fatalf("the last -path for a prefix should win: %+v", cur.Paths)
+ }
+ // A later -path for the same prefix changes only the numbers it names.
+ cur = overlayRatelimit(cur, nil, nil, nil, nil, []ratelimit.PathRule{{Prefix: "/api/", Rule: ratelimit.Rule{Burst: 50}}}, nil)
+ if r := cur.Paths[0].Rule; r.ClientShare != 32 || r.Rate != 1000 || r.Burst != 50 {
+ t.Fatalf("named values should overlay the old rule: %+v", cur.Paths)
+ }
+ cur = overlayRatelimit(cur, nil, nil, nil, nil, nil, []string{"/nope/", "/api/"})
+ if len(cur.Paths) != 0 {
+ t.Fatalf("-remove-path left %+v", cur.Paths)
+ }
+
+ // Through the wire: the rule is in the PATCH body.
+ fc := newRLConsole(t)
+ failed, out := ratelimitSet(fc, []string{"a.example.com"}, func(c ratelimit.Settings) ratelimit.Settings {
+ return overlayRatelimit(c, nil, nil, nil, nil, add[1:], nil)
+ })
+ if failed != 0 {
+ t.Fatalf("failed = %d: %s", failed, out)
+ }
+ if got := patchedSettings(t, fc.bodies["a.example.com"]); len(got.Paths) != 1 || got.Paths[0].Prefix != "/api/" {
+ t.Errorf("PATCHed %+v", got)
+ }
+}
+
+// A malformed -path is refused before any request, and before the CLI so
+// much as looks for a console, with exit status 2.
+func TestRatelimitMalformedPathExits2(t *testing.T) {
+ out, err := runCarlosCLI(t, "ratelimit", "set", "-app", "site", "-path", "/api/:rate=lots")
+ if err == nil || !strings.Contains(out, ratelimitCopy(ratelimitBadPath, "value", "/api/:rate=lots")) {
+ t.Fatalf("err=%v out=%s", err, out)
+ }
+ if ee, ok := err.(interface{ ExitCode() int }); !ok || ee.ExitCode() != 2 {
+ t.Fatalf("exit = %v, want 2", err)
+ }
+}
+
+func TestRatelimitClearSendsEmptyObject(t *testing.T) {
+ fc := newRLConsole(t)
+ failed, out := ratelimitSet(fc, []string{"a.example.com"}, nil)
+ if failed != 0 {
+ t.Fatalf("failed = %d: %s", failed, out)
+ }
+ if got := fc.bodies["a.example.com"]; got != `{"rate_limit":{}}` {
+ t.Errorf("PATCH body = %s, want an empty rate_limit object", got)
+ }
+ if !strings.Contains(out, ratelimitClearOK+" a.example.com") {
+ t.Errorf("output = %q", out)
+ }
+ for _, s := range fc.seen {
+ if strings.HasPrefix(s, "GET") {
+ t.Errorf("clear read first: %v", fc.seen)
+ }
+ }
+}
+
+func TestRatelimitFanOutReportsEach(t *testing.T) {
+ fc := newRLConsole(t)
+ fc.patch = func(host string) (int, string) {
+ if host == "b.example.com" {
+ return http.StatusConflict, `{"error":"the record moved under you"}`
+ }
+ return 200, `{"host":"` + host + `"}`
+ }
+ rate := 100
+ failed, out := ratelimitSet(fc, []string{"a.example.com", "b.example.com", "c.example.com"},
+ func(cur ratelimit.Settings) ratelimit.Settings {
+ return overlayRatelimit(cur, nil, nil, &rate, nil, nil, nil)
+ })
+ if failed != 1 {
+ t.Errorf("failed = %d, want 1", failed)
+ }
+ for _, want := range []string{
+ ratelimitSetOK + " a.example.com\n",
+ ratelimitSetBad + " b.example.com: the record moved under you\n",
+ ratelimitSetOK + " c.example.com\n",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("output missing %q:\n%s", want, out)
+ }
+ }
+ if n := len(fc.bodies); n != 3 {
+ t.Errorf("%d PATCHes, want 3", n)
+ }
+
+ // And the lister: no -host is every instance, -host is that one alone.
+ fc.list = `{"instances":[{"host":"a.example.com"},{"host":"b.example.com"}],"total":2}`
+ sess := newTestSession(fc.ts.URL)
+ if got := ratelimitHosts(sess, apiAcct{Seg: "bah"}, "app", ""); len(got) != 2 {
+ t.Errorf("hosts = %v", got)
+ }
+ fc.seen = nil
+ if got := ratelimitHosts(sess, apiAcct{Seg: "bah"}, "app", "x.example.com"); len(got) != 1 || len(fc.seen) != 0 {
+ t.Errorf("hosts = %v, requests %v", got, fc.seen)
+ }
+}
+
+const rlShowFull = `{"host":"a.example.com",
+ "settings":{"busy_at":32,"client_share":40,"rate":5000,"paths":[{"prefix":"/api/","client_share":8}]},
+ "defaults":{"busy_at":64,"client_share":16,"rate":300,"burst":1000},
+ "ceiling":{"client_share":20,"rate":1000,"burst":2000},
+ "effective":{"busy_at":32,"base":{"client_share":20,"rate":1000,"burst":1000},
+ "paths":[{"prefix":"/api/","client_share":8,"rate":1000,"burst":1000}]},
+ "counts":{"shed":12,"ceiling":3,"since":"2026-10-23T10:00:00Z"}}`
+
+const rlShowPlain = `{"host":"b.example.com","settings":{},
+ "defaults":{"busy_at":64,"client_share":16,"rate":300,"burst":1000},
+ "ceiling":null,
+ "effective":{"busy_at":64,"base":{"client_share":16,"rate":300,"burst":1000}},
+ "counts":{"shed":0,"ceiling":0,"since":"2026-10-23T10:00:00Z"}}`
+
+func TestRatelimitShowMarksDefaultsAndCeiling(t *testing.T) {
+ fc := newRLConsole(t)
+ fc.current = rlShowPlain
+ var out bytes.Buffer
+ if failed := ratelimitShow(newTestSession(fc.ts.URL), apiAcct{Seg: "bah"}, "app", []string{"b.example.com"}, &out); failed != 0 {
+ t.Fatalf("failed = %d", failed)
+ }
+ want := "b.example.com\n" +
+ " busy at 64 requests in flight (default)\n" +
+ " per address, busy 16 requests in flight (default)\n" +
+ " backstop 300 a second, bursts of 1000 (default)\n" +
+ " last 24 hours 0 turned away while busy, 0 over the backstop\n"
+ if out.String() != want {
+ t.Errorf("plain show:\n%s\nwant:\n%s", out.String(), want)
+ }
+ if strings.Contains(out.String(), "plan caps") {
+ t.Errorf("a null ceiling printed a cap: %s", out.String())
+ }
+
+ fc.current = rlShowFull
+ out.Reset()
+ ratelimitShow(newTestSession(fc.ts.URL), apiAcct{Seg: "bah"}, "app", []string{"a.example.com"}, &out)
+ want = "a.example.com\n" +
+ " busy at 32 requests in flight\n" +
+ " per address, busy 20 requests in flight (the plan caps this; you set 40)\n" +
+ " backstop 1000 a second, bursts of 1000 (the plan caps this; you set 5000 a second, bursts of 1000)\n" +
+ " /api/ 8 in flight per address when busy, backstop 1000 a second, bursts of 1000\n" +
+ " last 24 hours 12 turned away while busy, 3 over the backstop\n"
+ if out.String() != want {
+ t.Errorf("capped show:\n%s\nwant:\n%s", out.String(), want)
+ }
+ if strings.Contains(out.String(), "counting what it would") {
+ t.Errorf("the observe line must not be printed: %s", out.String())
+ }
+}
+
+func TestRatelimitOldConsole(t *testing.T) {
+ fc := newRLConsole(t)
+ fc.patch = func(string) (int, string) {
+ return http.StatusBadRequest, `{"error":"nothing to update — send addrs, probe, steering, environment, gate, or a combination"}`
+ }
+ _, err := newTestSession(fc.ts.URL).instanceSetRateLimit(t.Context(), apiAcct{Seg: "bah"}, "app", "h", ratelimit.Settings{})
+ if err == nil || err.Error() != ratelimitConsoleOld {
+ t.Fatalf("err = %v, want exactly %q", err, ratelimitConsoleOld)
+ }
+}
+
+func TestRatelimitUsageNamesTheVerbs(t *testing.T) {
+ out, err := runCarlosCLI(t, "ratelimit")
+ if err == nil {
+ t.Fatalf("bare `ratelimit` exited 0: %s", out)
+ }
+ for _, w := range []string{"show", "set", "clear"} {
+ if !strings.Contains(out, w) {
+ t.Fatalf("usage should name %s: %s", w, out)
+ }
+ }
+}
+
+// The docs carry the usage text word for word.
+func TestRatelimitDocsCarryTheUsage(t *testing.T) {
+ raw, err := os.ReadFile("docs/site/cli.md")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(raw), "### carlos ratelimit\n\n```\n"+ratelimitUsage+"```\n") {
+ t.Error("docs/site/cli.md does not carry the ratelimit usage text verbatim")
+ }
+}