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>
5 files changed,
+798
−0
apimode.go+59 −0docs/site/cli.md+30 −0main.go+2 −0ratelimit_cli.go+417 −0ratelimit_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") |
| + } |
| +} |