cf_http_ratelimiter

package module
v0.0.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 14, 2026 License: Apache-2.0 Imports: 22 Imported by: 0

README

caerus-framework-http-ratelimiter

CI codecov License

Caerus Framework HTTP Rate Limiter Component. Fixed-window rate limiting for HTTP services and outbound clients, backed by a Valkey peer (default) with an optional in-process sticky-note map (use_memory_fallback / force_memory / StorageMemoryFallback). It owns the lifecycle, configuration (file + env + flags), live reload of tunables, framework logging, and health/metrics — the app supplies the per-call limit, window, key, and store-error policy.

Choosing this module means configuring storage-error policy per call site. There is no silent fail-open: Middleware errors if OnStoreError is unset, and AllowWithPolicy always takes an explicit policy argument (see Storage-error policy).

What it is (and is not — yet)

Is Is not
Fixed-window counting with a Valkey INCR + PEXPIRE (TTL only set on the first increment) Sliding window, token bucket, or leaky bucket (see below)
A stdlib func(http.Handler) http.Handler middleware plus Allow/Reset/Peek/Wait primitives A router, a WAF, or a per-endpoint policy engine
A way to pace your own outbound calls (Wait: peek + interruptible sleep + jitter + allow once) A way to slow down HTTP responses for attackers — answer 429 + Retry-After instead
A rule about "how often" something may happen A lock about "who owns it right now" — that is a different concern (see Recipe B)
Fixed window vs the others (same story, four machines)

Imagine the rule: “at most 30 requests every 60 seconds.” Four common machines can enforce a sentence like that. They are not the same machine. This module ships only the first.

Machine Picture for a 14-year-old What “30 per 60s” really means Nice part Ugly part This module?
Fixed window A kitchen timer. When the first request arrives, you start a 60s countdown and put a tally mark for every request. When the timer hits zero, you throw the paper away and start over on the next request. At most 30 tallies inside one 60s timer run. The timer is tied to that key’s TTL (here: set on the first INCR). Dead simple. One counter + one expiry. Cheap in Valkey (INCR + PEXPIRE). Easy to reason about and to implement with Lua. Boundary burst: use 30 at 0:59, timer ends, use 30 again at 1:00 → ~60 in two seconds that cross the boundary. The calendar does not care about your feeling of “one minute.” Yes — this is what we ship.
Sliding window A moving picture frame on a timeline. You always look at “the last 60 seconds from now,” not “the box that started when I first clicked.” At most 30 requests whose timestamps fall inside the trailing 60s. Matches how humans say “per minute.” Softens the fixed-window boundary spike. Needs more memory or clever math (store timestamps, or approximate with weighted previous+current windows). Heavier than one counter. Not yet
Token bucket A jar of coins. Coins drip into the jar at a steady rate (refill). Each request spends one coin. If the jar is empty, you wait or get denied. The jar has a max size (burst). Average rate ≈ refill speed; short bursts up to the jar size are allowed. Great for “usually 30/min, but a small burst is OK.” Natural fit for pacing (Wait until a coin exists). You must pick rate + burst carefully. Two numbers, not one “limit per window.” Harder to explain to product people. Not yet
Leaky bucket A funnel. Requests pour into the top; they leave the bottom at a constant drip. If you pour too fast, the funnel overflows (deny) or the queue grows. Outflow is smooth; spikes get queued or dropped so the exit rate stays flat. Excellent when the thing behind you hates bursts (legacy API, fragile DB). Often adds queueing delay. Can feel unfair (“I arrived first but wait”). Easy to confuse with token bucket (related math, different story). Not yet

What this module means today by “fixed window”: for a key, the counter resets when its TTL expires. Limit 30 and window 60s means “at most 30 while this timer is alive,” not “at most 30 in every rolling wall-clock minute.” If you need true rolling minutes or smooth drip rates, that is a later algorithm — not a config toggle on this one.

Why start here: auth-style login lockouts and IP caps usually want a simple shared counter, not a traffic-shaping lab. Fixed window + Valkey is the smallest honest story that works across replicas. Sliding / token / leaky can wait until a product actually needs their shape.

Behaviour schemes

Canonical teaching diagrams for this module. Inbound never sleeps; outbound Wait may.

Layers — who owns what
flowchart TB
  M["MODULE settings<br/>http-ratelimiter.json<br/>prefix, map size, jitter, …"]
  A["APP settings<br/>myservice.json / myapirequestor.json<br/>loginLimit, ipLimit, …"]
  C["CALL SITE code<br/>OnStoreError, KeyFunc,<br/>this route's limit/window"]
  M --> A --> C
Inbound HTTP (middleware) — never sleeps
flowchart TD
  R[request] --> K["KeyFunc(r) → logical key<br/>TRUSTED identity only"]
  K --> L{limit ≤ 0?}
  L -->|yes| D["ALLOW — no store<br/>disabled_total++"]
  L -->|no| V{validate key}
  V -->|fail| RJ["error / reject<br/>key_rejected_total"]
  V -->|ok| S["primary store<br/>Valkey Lua INCR+PEXPIRE"]
  S -->|OK| C{Count ≤ limit?}
  S -->|store ERROR| P["OnStoreError REQUIRED"]
  C -->|yes| N[next handler]
  C -->|no| DENY["429 + Retry-After<br/>no sleep"]
  P --> FO[FailOpen → ALLOW warn]
  P --> FC[FailClosed → DENY/503]
  P --> MF["MemoryFallback → sticky-note map<br/>same Allow math"]
Outbound Wait — may sleep
flowchart TD
  W["Wait(key, limit, window)"] --> Peek[Peek]
  Peek --> Q{"Count < limit?"}
  Q -->|yes| Allow[Allow once]
  Allow -->|OK| Done[return OK]
  Allow -->|denied| Reset[use ResetIn]
  Reset --> Sleep
  Q -->|no| Sleep["sleep ResetIn + jitter<br/>honour ctx"]
  Sleep --> Peek
Where counters live
flowchart TD
  K[logical key] --> V["Valkey<br/>shared across replicas<br/>normal prod"]
  K --> M["In-process map per pod<br/>use_memory_fallback / force_memory / MemoryFallback<br/>multi-replica = split counters"]
Two switches + DegradedMode (sticky notes)
Switch Meaning
use_memory_fallback Sticky-note engine allowed. Off → never count in-process; StorageMemoryFallback is illegal.
force_memory Break-glass: start/run on sticky notes when Valkey is wired but dead at Init (pair with valkey DegradedMode). Prefer memory at runtime while set.
lame_memory_mode metric Both switches on and Valkey healthy — shame gauge (you chose sticky notes for no good reason).
Situation Result
Valkey not in Components + WithoutValkeyPeer + use_memory_fallback=true Sticky-only (S0)
Valkey wired, Client() nil, force_memory=false Init FAIL
Valkey wired, Client() nil, force_memory=true Sticky + scream
Valkey OK, runtime error, use_memory_fallback + MemoryFallback Sticky + scream
Valkey OK, both switches on Sticky + lame_memory_mode

/readyz still aggregates every HealthProvider. DegradedMode on valkey lets Initialize finish; it does not make Valkey healthy. For “take traffic on sticky notes,” use a deliberate valkey health_when_degraded: ready on a dedicated limiter valkey instance — not as a quiet default.

One-line model
Path Behaviour
Inbound count → deny fast (429) → never sleep
Outbound count → sleep until slot → Allow once
Valkey down + MemoryFallback (use_memory_fallback on) local map; watch policy_fallbacks_total
FailOpen / FailClosed let through / deny-or-503

Wiring

Two wiring shapes are supported. Prefer the app-owned shape (golden path): main declares the limiter as chassis next to valkey, and the app class resolves it as a peer at Init. Use the simple main-level shape for one-off binaries (and see Recipe C for memory-only local runs).

Golden path (app-owned consumer)

main declares valkey + the limiter + the app class; it never touches the limiter directly:

fw := cf.New(&cf.FrameworkOptions{
	Logs:          &cf.LogsSettings{Format: "json", Level: "info", ConfigSource: "logs"},
	Observability: &cf.ObservabilitySettings{Address: ":9090", ConfigSource: "observability"},
	Components: []cf.CaerusComponent{
		cf_valkey.New(cf_valkey.WithConfigSource("valkey", "config/valkey.json"),
			cf_valkey.WithKeyPrefix("myservice:")),
		cf_http_ratelimiter.New(
			cf_http_ratelimiter.WithConfigSource("http-ratelimiter", "config/http-ratelimiter.json"),
		),
		app.New(),
	},
})
if err := fw.RunWithSignals(context.Background()); err != nil {
	log.Fatal(err)
}

The app resolves the limiter component pointer once at Init, declares it in GetDependencies, and calls Allow/Reset/Wait (or builds Middleware) per use. Never copy the limiter or its client — always keep the pointer and call its methods, because the valkey peer swaps its client on reload:

type App struct {
	rl *cf_http_ratelimiter.RateLimiter
}

func (a *App) GetDependencies() []string {
	return []string{
		cf_http_ratelimiter.ComponentName, // "http-ratelimiter" — the component name, NOT a shortened nickname
		// + logs, valkey, …
	}
}

func (a *App) Init(ctx context.Context, fw *cf.CaerusFramework) error {
	rl, ok := cf.Get[*cf_http_ratelimiter.RateLimiter](fw)
	if !ok {
		return errors.New("app: http-ratelimiter missing")
	}
	a.rl = rl
	return nil
}

The limiter needs a Valkey peer. It resolves it at Init via cf.Get (or cf.GetByName when you set WithValkeyName), and it calls vk.Client()/vk.Key() per use — it never snapshots the valkey client, so the peer's reconnect/reload keeps working underneath.

Simple main-level wiring

For a one-off binary (MyAPIRequestor's local dry-run CLI, tests, local tools), register the components directly and use cf.MustGet:

fw := cf.New()
fw.AddComponent(cf_logs.New(cf_logs.WithWriter(os.Stdout)))
fw.AddComponent(cf_valkey.New(cf_valkey.WithAddress("127.0.0.1:6379")))
rl := cf_http_ratelimiter.New(
	cf_http_ratelimiter.WithConfigSource("http-ratelimiter", "config/http-ratelimiter.json"),
)
fw.AddComponent(rl)
// …
rl, _ = cf.MustGet[*cf_http_ratelimiter.RateLimiter](fw)

The component is ConfigSourceRegistrar-self-sufficient: WithConfigSource registers the Source[http_ratelimiter.Config] with the configuration component during argv absorption, so main never touches os.Getenv/ParseFlags. The --http-ratelimiter path flag and the per-field flags come from that declaration.

Component name vs configuration source name

These are two different strings that happen to match by default. Keep them apart in your head:

Concept Value Who uses it
Component name "http-ratelimiter" (ComponentName) Framework registry, GetDependencies, Get/GetByName, logs level settings
Config source name "http-ratelimiter" (from WithConfigSource) File path flag --http-ratelimiter, env prefix HTTP_RATELIMITER_, OnConfigReload's source string

If you name a limiter instance with WithName("sessions"), depend on "sessions" in GetDependencies — but keep the source name whatever WithConfigSource says.

API

Result
type Result struct {
	Allowed bool          // Count <= limit (only meaningful from Allow/AllowWithPolicy)
	Count   int64         // counter value after this call
	ResetIn time.Duration // time until the window resets (for Retry-After)
}
Allow / Reset / Peek
  • Allow(ctx, key, limit, window) — increments the counter for key and reports whether Count <= limit. Use it for inbound checks you do inside a handler (login lockout, register limit) where the middleware's fixed shape does not fit. Storage errors are returned to the caller; policy belongs to the call site (use AllowWithPolicy or the middleware).
  • Reset(ctx, key) — deletes the counter (successful login / admin unlock). Missing keys are a no-op (idempotent).
  • Peek(ctx, key) — reads count + remaining TTL without incrementing. Allowed is always false (there is no limit to compare against). Useful for dashboards and pre-flight checks.
  • Wait(ctx, key, limit, window) — blocks until a single Allow would succeed, then performs that one Allow. For outbound self-pacing (see Outbound / Wait). Honors ctx cancellation.

limit <= 0 short-circuits every one of these: rate limiting is off for that call — always allowed, no storage access, counted only in http_ratelimiter_disabled_total. It is neither an allow nor a deny; it is "this call asked for no limit." Alert on it rising in prod: it usually means a config value (e.g. loginLimit: 0) silently disabled the limiter.

Keys

Keys are logical strings: a prefix plus an opaque identity, e.g. "login:" + hex, "ip:" + ip, "externalapi:rest:42". The component places the valkey peer's prefix and the module's key_prefix underneath, so apps never hardcode full Redis key names.

Max logical key length is enforced (default 256 bytes): empty or oversized keys are rejected with an error, never truncated, and counted in http_ratelimiter_key_rejected_total{reason="empty"|"too_long"} before any storage access. 256 bytes comfortably fits login: + 64 hex chars (HMAC-SHA256) or ip: + an IPv6 address, and rejects accidental dumps. Override with WithMaxKeyLength(n) or max_key_length (reloadable).

Outbound / Wait

Wait is for when we are the client (Recipe B — MyAPIRequestor calling ExternalAPIWeCall). It does not spin-increment: it peeks at the counter, sleeps until the window resets (plus a small jitter to avoid thundering-herd, ~10% capped at 1s by default), then performs exactly one Allow. If the context is done it returns the context error without granting.

key := fmt.Sprintf("externalapi:rest:%d", installationID)
if err := rl.Wait(ctx, key, cfg.ExternalAPI.RESTLimit, time.Minute); err != nil {
	return err // ctx canceled or wait failed
}
// now make the HTTP call to ExternalAPIWeCall; still honor ExternalAPIWeCall's own
// X-RateLimit-* / Retry-After headers in your HTTP client

The sleep is interruptible (it observes ctx), and the pause decision is fully configurable:

  • WithWaitJitterMax(d) / wait_jitter_max_sec: 0 disables jitter (deterministic tests);
  • WithWaitDelayFunc(fn) lets the app decide the sleep — cap it, disable it, or abort with an error instead of waiting.
What sleep is not
Not this Reality
A way to delay HTTP responses to slow attackers Return 429; optional WAF/Ingress
Holding a login request open until unlock Return 429/403; the client retries later
Replacing a vendor's own rate-limit sleep Still read their Retry-After/X-RateLimit-* after calls; that may sleep in the outbound client, separate from this module

For inbound HTTP, never sleep in the handler: answer 429 with a Retry-After header (the middleware does this automatically) so server goroutines are not held open.

Storage-error policy

The limiter's clipboard is Valkey. When the clipboard is missing (Valkey down, network blip) the call site needs a rule:

Rule Plain English
StorageFailOpen "Clipboard broken → let them in anyway." Site stays up; attackers also get in with no limits.
StorageFailClosed "Clipboard broken → nobody gets in." Safer; real users may see 429/503 until Valkey is back.
StorageMemoryFallback "Clipboard broken → use a sticky note on the desk." Still some limits, only on this one server.

Why "unset" is dangerous: in Go, StorageFailOpen is the zero value of the enum, so "forgot the field" and "I chose FailOpen" look identical. A junior can copy middleware, omit OnStoreError, and silently ship an unlocked door. So:

  • Middleware errors if OnStoreError is nil.
  • AllowWithPolicy(ctx, key, limit, window, policy) always takes an explicit policy argument — there is no overload that defaults it.

This is different from map-full unset→allow below. Map-full is a narrow edge case inside an already-chosen MemoryFallback. OnStoreError is the main safety switch for "Valkey is dead — what now?" The former may default to permissive; the latter never does.

The default OnDenied response is 429 with Retry-After set from Result.ResetIn (rounded up, so the client never retries early). A FailClosed store error answers 503 with Retry-After: 1 — the limiter itself is down, not the caller. Provide OnDenied for custom bodies/status/logging.

Config and options

Config (config/http-ratelimiter.json)

Module settings — file/env/flags drive these; per-call limits and windows stay caller-supplied (the app owns its own policy on its own config source).

Field Env Default Meaning
key_prefix HTTP_RATELIMITER_KEY_PREFIX "rl" extra logical prefix under the valkey peer's Key()
metrics_enabled HTTP_RATELIMITER_METRICS_ENABLED true Metrics() returns nil when false (pointer so "omitted" ≠ "off")
use_memory_fallback HTTP_RATELIMITER_USE_MEMORY_FALLBACK false sticky-note engine enabled (required for MemoryFallback / no-valkey chassis)
force_memory HTTP_RATELIMITER_FORCE_MEMORY false break-glass sticky primary (wired-but-dead Init; prefer memory at runtime)
memory_max_entries HTTP_RATELIMITER_MEMORY_MAX_ENTRIES 10000 hard cap on distinct keys in the in-process map
memory_fallback_limit HTTP_RATELIMITER_MEMORY_FALLBACK_LIMIT call's limit coarser fallback limit; 0 → use the call's limit
memory_fallback_window_sec HTTP_RATELIMITER_MEMORY_FALLBACK_WINDOW_SEC call's window coarser fallback window; 0 → use the call's window
memory_map_full_policy HTTP_RATELIMITER_MEMORY_MAP_FULL_POLICY required when use_memory_fallback or force_memory "allow" or "deny" — Init/reload fails if missing/invalid while memory may be used
wait_missing_ttl_policy HTTP_RATELIMITER_WAIT_MISSING_TTL_POLICY required "proceed" or "error" after scrubbing a count>0 / PTTL≤0 key
hash_ip_keys HTTP_RATELIMITER_HASH_IP_KEYS false app-facing toggle: apps should HMAC IPs in KeyFunc when true (see Key material)
max_key_length HTTP_RATELIMITER_MAX_KEY_LENGTH 256 max logical key bytes; oversize is rejected, never truncated
wait_jitter_max_sec HTTP_RATELIMITER_WAIT_JITTER_MAX_SEC ~10% of reset, capped 1s max extra Wait sleep; 0 disables jitter

Map-full is explicit. When use_memory_fallback or force_memory is true you must set memory_map_full_policy to "allow" or "deny" — there is no silent default. Expired map entries are swept before the cap check. There is no LRU eviction.

Tunables reload live (OnConfigReload, http_ratelimiter_config_reloads_total). There is no connection rebuild — the valkey peer owns client rotation, the limiter just re-reads prefix/metrics/sizing.

Options
Option Description
WithConfig(Config) static config snapshot; non-zero fields override option-set defaults
WithConfigSource(name, path, …) bind a configuration source (registers it via ConfigSourceRegistrar; declares a configuration dep). WithSourceEnvPrefix, WithSourceFormat available
WithName(name) custom component name for multiple instances (default "http-ratelimiter")
WithValkeyName(name) bind to a valkey component with the given name (default "valkey")
WithLogger(*slog.Logger) explicit logger override; defaults to the framework logs component logger (re-delivered on logs Reconfigure), falling back to slog.Default()
WithKeyPrefix(prefix) logical key prefix (default "rl"); trailing : trimmed
WithMaxKeyLength(n) maximum logical key bytes (default 256)
WithUseMemoryFallback(bool) enable/disable sticky-note capability (use_memory_fallback)
WithForceMemory(bool) enable/disable break-glass sticky primary (force_memory)
WithoutValkeyPeer() omit valkey from GetDependencies (memory-only chassis; requires use_memory_fallback=true)
WithMemoryMapFullPolicy("allow"|"deny") seed required map-full policy
WithWaitMissingTTLPolicy("proceed"|"error") seed required missing-TTL policy
WithMemoryMaxEntries(n) cap on distinct map keys (default 10000)
WithMetricsEnabled(bool) toggle the metrics catalogue (default on)
WithWaitJitterMax(d) cap Wait jitter (default 1s); 0 disables
WithWaitDelayFunc(fn) app-supplied pause/abort decision for Wait

Default EnvPrefix is HTTP_RATELIMITER_ (from the source name). main declares the limiter with WithConfigSource and runs RunWithSignals — no Getenv, no ParseFlags.

Key material

Keys must not carry raw PII when avoidable. HMAC-SHA256 your identity before building the key — this module ships the helper so apps do not reinvent it:

h, err := cf_http_ratelimiter.NewKeyHasher(secret) // secret is app-owned (pepper / rate_limit_key_secret)
if err != nil {
	return err
}
loginKey := "login:" + h.Hash(normalizedEmail) // emails are ALWAYS hashed by convention
Identity Default Configurable
Emails / usernames (login lockout) Always hash (login: + 64 hex) secret from app config
IPs (middleware KeyFunc) Plain IP for ops debugging (ip:203.0.113.4) hash_ip_keys: true → hash the same way
Installation IDs / non-PII (MyAPIRequestor → ExternalAPIWeCall) Plain OK (externalapi:rest:42) hash optional

Never log secrets or pre-hash identities. After hashing, keys stay short and fit the default 256-byte MaxKeyLength.

Starter recipes

These are the two shapes, in plain language:

Recipe character What it is Direction
MyService A made-up service that receives HTTP requests from browsers/clients (login, register, …) — an app that accepts requests (you are the server) Inbound — middleware + handler Allow/Reset
MyAPIRequestor A made-up worker we run that calls out to some external HTTP API — an app we call from / our process calling someone else (you are the client for the paced calls). The external API here is a dummy named ExternalAPIWeCall — not a real vendor brand Outbound Wait/Allow when we call others; optional inbound webhook middleware

MyAPIRequestor is not ExternalAPIWeCall. MyAPIRequestor is the Caerus app. ExternalAPIWeCall is whoever we call.

Numbers below are sensible first values, not sacred — tune from metrics.

Recipe A — MyService (receives requests; K8s, Valkey + Echo)

MyService is a login/account HTTP API. Clients hit /api/session/*. We blunt IP floods, lock accounts after bad passwords, and stay up if Valkey blips.

Call site Policy to start with Why
IP middleware on public /api/session/* StorageFailOpen prefer taking logins over 503ing everyone when Valkey is down
Register StorageFailOpen same availability bias
Login lockout StorageMemoryFallback (needs use_memory_fallback: true) key = login: + HMAC hash of email (never raw email in Valkey)
Map full (fallback) explicit allow or deny no silent default
On success Reset the same hashed key clear the counter so a successful user is not half-locked
IP keys plain or hashed start plain for ops; set hash_ip_keys: true when Valkey is shared / privacy-sensitive

config/http-ratelimiter.json (module settings — start here):

{
  "key_prefix": "rl",
  "metrics_enabled": true,
  "use_memory_fallback": true,
  "force_memory": false,
  "memory_max_entries": 10000,
  "memory_fallback_limit": 20,
  "memory_fallback_window_sec": 900,
  "memory_map_full_policy": "allow",
  "wait_missing_ttl_policy": "proceed",
  "hash_ip_keys": false,
  "max_key_length": 256
}

MyService's own product policy on its own config source (not this component config. Developer invents own for his own app component) (illustrative — config/myservice.json):

{
  "rateLimit": {
    "loginLimit": 5,
    "loginWindowMinutes": 15,
    "registerLimit": 5,
    "registerWindowMinutes": 60,
    "ipLimit": 30,
    "ipWindowSeconds": 60
  },
  "rateLimitPolicies": {
    "ipOnStoreError": "fail_open",
    "loginOnStoreError": "memory_fallback",
    "hashIpKeys": false
  },
  "rateLimitKeySecret": "(from secret mount — HMAC key for login/IP hashing)"
}

Wiring sketch (Echo) — IP group:

pol := cf_http_ratelimiter.StorageFailOpen
stdMW, err := cf_http_ratelimiter.Middleware(cf_http_ratelimiter.MiddlewareConfig{
	Limiter: a.rl,
	Limit:   cfg.RateLimit.IPLimit, // e.g. 30
	Window:  time.Duration(cfg.RateLimit.IPWindowSeconds) * time.Second,
	KeyFunc: func(r *http.Request) string {
		ip := realIP(r) // trusted IP only
		if cfg.RateLimitPolicies.HashIPKeys {
			return "ip:" + a.keyHasher.Hash(ip)
		}
		return "ip:" + ip
	},
	OnStoreError: &pol, // required — Middleware errors if nil
})
if err != nil {
	return err
}
public := e.Group("/api/session", echo.WrapMiddleware(stdMW))

Login lockout inside the handler — always hash email:

loginKey := "login:" + a.keyHasher.Hash(normalizedEmail)
res, err := a.rl.AllowWithPolicy(ctx, loginKey,
	int64(cfg.RateLimit.LoginLimit),
	time.Duration(cfg.RateLimit.LoginWindowMinutes)*time.Minute,
	cf_http_ratelimiter.StorageMemoryFallback,
)
if err != nil {
	// log it; the policy already decided what to do
}
if !res.Allowed {
	return echo.NewHTTPError(http.StatusTooManyRequests, "temporarily locked")
}
// … verify password …
if success {
	_ = a.rl.Reset(ctx, loginKey)
}

Give the valkey peer WithKeyPrefix("myservice:") so keys look like myservice:rl:login:<hex> — no raw email in the keyspace.

Recipe B — MyAPIRequestor (we call out; K8s webhook + outbound client)

MyAPIRequestor is our background/HTTP service. It exposes POST /hooks/events for an upstream to notify us, and then our code calls an external HTTP API (dummy name: ExternalAPIWeCall) to do something over external service via their API / sync state. Rate limiting is mostly: (1) protect the webhook, (2) pace our outbound calls with Wait so we do not stampede ExternalAPIWeCall.

Call site Policy to start with Why
POST /hooks/events middleware StorageFailClosed (429 or 503 + Retry-After) not end-user UX; upstream can retry; do not fail-open into the worker
Outbound ExternalAPIWeCall REST self-pace Wait/Allow we are the client — sleep/jitter until our ceiling allows another call
Local dry-run CLI Recipe C (no valkey peer) WithoutValkeyPeer + use_memory_fallback
Map full explicit allow or deny raise memory_max_entries if needed

config/http-ratelimiter.json:

{
  "key_prefix": "rl",
  "metrics_enabled": true,
  "use_memory_fallback": true,
  "force_memory": false,
  "memory_max_entries": 5000,
  "memory_map_full_policy": "allow",
  "wait_missing_ttl_policy": "proceed"
}

config/myapirequestor.json (product — illustrative):

{
  "webhook": {
    "ipLimit": 120,
    "ipWindowSeconds": 60,
    "onStoreError": "fail_closed"
  },
  "externalApi": {
    "restLimit": 80,
    "restWindowSeconds": 60
  }
}

Webhook middleware (stdlib ServeMux / cf_http handler chain):

closed := cf_http_ratelimiter.StorageFailClosed
rateMW, err := cf_http_ratelimiter.Middleware(cf_http_ratelimiter.MiddlewareConfig{
	Limiter: rl,
	Limit:   cfg.Webhook.IPLimit, // e.g. 120/min per trusted IP
	Window:  time.Minute,
	KeyFunc: func(r *http.Request) string { return "hook:ip:" + remoteIP(r) },
	OnStoreError: &closed, // required — errors if nil
})
if err != nil {
	return err
}
h := rateMW(webhookHandler)
// Order: MaxBytes → rate limit → HMAC verify → enqueue work

Outbound self-pace inside our ExternalAPIWeCall client (calls we make):

// installationID = which ExternalAPIWeCall install we talk to — still our key material
key := fmt.Sprintf("externalapi:rest:%d", installationID)
if err := rl.Wait(ctx, key, cfg.ExternalAPI.RESTLimit, time.Minute); err != nil {
	return err // ctx canceled or wait failed
}
// then the HTTP call to ExternalAPIWeCall; still honor ExternalAPIWeCall's own rate-limit headers on the response

One wave at a time is not this module. Rate limits are "how often"; locks are "who owns the wave." If you need a single wave to run at a time, use a lock/lease, not the limiter.

Recipe C — Local / CI (memory-only, without Valkey)

When the process has no valkey component, omit it from GetDependencies and enable sticky notes explicitly:

rl := cf_http_ratelimiter.New(
	cf_http_ratelimiter.WithoutValkeyPeer(), // GetDependencies: logs only
	cf_http_ratelimiter.WithUseMemoryFallback(true),
	cf_http_ratelimiter.WithMemoryMapFullPolicy("allow"),
	cf_http_ratelimiter.WithWaitMissingTTLPolicy("proceed"),
	cf_http_ratelimiter.WithMemoryMaxEntries(1000),
)

Wrong: omit valkey from Components but leave the default valkey dependency — Validate fails. Right: WithoutValkeyPeer + use_memory_fallback=true + explicit map-full and missing-TTL policies. Do not use memory-only as the sole backend for multi-replica MyService in production.

Break-glass with Valkey wired but dead — not automatic.
By default Valkey Init is hard: unreachable store → that component’s Init fails → the whole process does not finish Initialize. Nothing “auto-enables” DegradedMode or force_memory. You must turn both on on purpose.

  1. Valkey — allow Init without a live ping (degraded_mode).
  2. Limiter — allow sticky-note primary when Client() is nil (force_memory), and usually enable the sticky engine (use_memory_fallback).

config/valkey.json (or a dedicated limiter instance file, e.g. config/valkey-rl.json):

{
  "addr": "valkey:6379",
  "degraded_mode": true,
  "health_when_degraded": "not_ready"
}
Setting Meaning
degraded_mode: true Valkey Init may succeed even if ping/create fails (logs/metrics scream). Off by default.
health_when_degraded: "not_ready" Default — /readyz still fails while disconnected (pod up, no LB traffic).
health_when_degraded: "ready" Break-glass — Valkey Health returns OK while down so LB may send traffic. Use only on a dedicated limiter Valkey if the same process also has a hard session/DB store.

config/http-ratelimiter.json (same process):

{
  "key_prefix": "rl",
  "use_memory_fallback": true,
  "force_memory": true,
  "memory_map_full_policy": "deny",
  "wait_missing_ttl_policy": "proceed"
}

Watch metrics: force_memory, lame_memory_mode (both limiter switches on and Valkey later healthy), plus Valkey degraded_mode / degraded_unreachable / degraded_mode_uses_total.

Hot reload can save the day sometimes. Both components reload from their config sources (file change / Reload / SIGHUP — env alone does not wake a running process). Ops can flip limiter switches (force_memory, use_memory_fallback, map-full / missing-TTL policies) and Valkey reconnect settings live when the mounted file updates. That is often enough to ride out a Valkey blip or walk back break-glass without a new image. It does not replace a correct first deploy: if Valkey was hard-Init and never came up, there was no process left to reload — you needed degraded_mode (or Recipe C) already on for Initialize to finish. After you are up, reload is the quiet lever; watch metrics so “temporary” does not become permanent.

Middleware

func Middleware(cfg MiddlewareConfig) (func(http.Handler) http.Handler, error)

MiddlewareConfig requires Limiter, Window > 0, a KeyFunc, and OnStoreError (see Storage-error policy). Limit <= 0 disables rate limiting for this middleware. The returned middleware never sleeps: it answers immediately on denial.

KeyFunc must use a trusted client identity. Do not invent a clever X-Forwarded-For parser here — use identity your ingress/mesh already normalized (e.g. Echo RealIP() behind a correct proxy contract, or a mesh header you trust). RemoteAddrKey strips the port from r.RemoteAddr and is a footnote helper for local demos only, not production truth behind a load balancer.

For GitHub-style webhooks, if you intend to throttle abusers, key on the remote IP behind the ingress — not X-GitHub-Delivery (that header is for idempotency/dedupe of a single delivery, a different concern).

Metrics / health

Health reports unhealthy before Init and after Shutdown; after Init on the sticky-note primary path (force_memory / memory-only) it is healthy; with a Valkey primary it delegates to the peer's health. Metrics returns nil when not initialized (lazy pattern) or when metrics_enabled: false.

Common labels on all series: component (= Name()). Low-cardinality labels only — never raw keys, IPs, or emails.

Name Type Extra labels Meaning
http_ratelimiter_info gauge (0/1) backend=valkey|memory 1 while initialized; describes the active primary backend
http_ratelimiter_use_memory_fallback gauge (0/1) sticky-note engine enabled
http_ratelimiter_force_memory gauge (0/1) break-glass sticky primary enabled
http_ratelimiter_lame_memory_mode gauge (0/1) both switches on while Valkey was healthy (shame)
http_ratelimiter_allows_total counter Allow / successful Wait grant (Allowed=true)
http_ratelimiter_denies_total counter Allowed=false (over limit)
http_ratelimiter_resets_total counter Reset calls
http_ratelimiter_peeks_total counter Peek calls
http_ratelimiter_waits_total counter result=ok|canceled|error finished Wait calls
http_ratelimiter_wait_duration_seconds_sum counter (sum) total seconds spent sleeping inside Wait
http_ratelimiter_wait_duration_seconds_count counter number of sleep intervals (mean latency = sum/count)
http_ratelimiter_storage_errors_total counter primary store errors (Valkey transport, etc.)
http_ratelimiter_memory_path_total counter times Allow used the sticky-note path
http_ratelimiter_missing_ttl_total counter scrubbed count>0 / PTTL≤0 keys
http_ratelimiter_policy_fallbacks_total counter policy=fail_open|fail_closed|memory_fallback times a storage-error policy was applied
http_ratelimiter_map_full_total counter action=allow|deny fallback/memory map hit max entries
http_ratelimiter_memory_entries gauge current distinct keys in the in-process map (0 if unused)
http_ratelimiter_config_reloads_total counter successful tunable reloads
http_ratelimiter_disabled_total counter requiredAllow/Wait/AllowWithPolicy short-circuited because limit <= 0 (limiter off for that call)
http_ratelimiter_key_rejected_total counter reason=empty|too_long logical key failed validation before storage

Alert on http_ratelimiter_disabled_total rising: it means something calls Allow with limit <= 0 (see the API section).

Tests

Unit tests cover the component contract, sticky-note semantics (counting, window reset, map-full allow/deny, sweep, concurrency), key validation, Wait behavior, middleware validation/policy paths, and the metrics catalogue — no external service. Integration tests are gated on VALKEY_ADDR:

docker run -d --rm -p 6379:6379 --name v valkey/valkey:8
VALKEY_ADDR=127.0.0.1:6379 go test -race ./...

License

Apache License 2.0 — see LICENSE.

Documentation

Overview

Package cf_http_ratelimiter is the Caerus Framework HTTP rate limiter component. It is an HTTP-plane helper: apps use it from middleware and handlers to count attempts ("this client / email / action has tried too many times") in a shared store and answer allow/deny plus "try again in N seconds". It is not a second HTTP server and it is not a router (no Echo/Gin/chi dependency).

The same counter API is useful outside middleware too — e.g. a background worker calling Allow / Wait before bursts of outbound REST calls (see Wait).

Index

Constants

View Source
const (
	// ComponentName is the framework component name for the http-ratelimiter
	// component. It is the identifier other components use in GetDependencies
	// to require it, and it also matches the default configuration source name
	// so files, env prefixes and flags line up ("http-ratelimiter",
	// config/http-ratelimiter.json, HTTP_RATELIMITER_, --http-ratelimiter).
	ComponentName = "http-ratelimiter"

	// ComponentStage is the stage data-layer components initialize in.
	ComponentStage = cf.Stage("data")
)

Variables

View Source
var ErrMemoryFallbackDisabled = errors.New("cf_http_ratelimiter: StorageMemoryFallback requires use_memory_fallback=true")

ErrMemoryFallbackDisabled is returned when a call site asks for StorageMemoryFallback but the component's use_memory_fallback capability is off.

View Source
var ErrMissingTTL = errors.New("cf_http_ratelimiter: counter missing TTL")

ErrMissingTTL is returned when a Valkey counter exists with count > 0 but no usable TTL (PTTL <= 0), after the key was scrubbed, and wait_missing_ttl_policy (or a WaitOptions override) is "error".

Functions

func HashKey

func HashKey(secret, part string) (string, error)

HashKey is the package-level convenience form of KeyHasher.Hash.

func Middleware

func Middleware(cfg MiddlewareConfig) (func(http.Handler) http.Handler, error)

Middleware builds a stdlib middleware (func(http.Handler) http.Handler) from cfg. It errors when Limiter, KeyFunc, or OnStoreError are nil, or when Window <= 0. OnStoreError is required: choosing this module means tuning store-error policy — there is no silent FailOpen. StorageMemoryFallback requires the limiter's use_memory_fallback capability to be on. MiddlewareConfig.Memory is wired into AllowWithPolicyOpts for per-route sizing overrides.

The middleware never sleeps. On denial it answers immediately: 429 with a Retry-After header from Result.ResetIn (or 503 for a FailClosed store error, with Retry-After: 1). The client is responsible for waiting.

func RemoteAddrKey

func RemoteAddrKey(r *http.Request) string

RemoteAddrKey returns the host (IP) from r.RemoteAddr with the port stripped. It is a footnote helper for local demos only — behind a load balancer use identity your ingress/mesh already normalized (e.g. Echo RealIP() under a correct proxy contract), not a client-supplied X-Forwarded-For.

Types

type Config

type Config struct {
	// KeyPrefix is an extra logical prefix under the valkey peer's Key().
	// Example: peer prefix "auth:" + module prefix "rl" → auth:rl:<key>.
	// Default "rl".
	KeyPrefix string `json:"key_prefix,omitempty" yaml:"key_prefix,omitempty" env:"KEY_PREFIX"`
	// MetricsEnabled — pointer so "omitted" (default on) and an explicit false
	// are distinct. When false, Metrics() returns nil.
	MetricsEnabled *bool `json:"metrics_enabled,omitempty" yaml:"metrics_enabled,omitempty" env:"METRICS_ENABLED"`
	// UseMemoryFallback enables the sticky-note (in-process map) engine. Off → never
	// count in-process (MemoryFallback is illegal). Required true when the
	// process omits a valkey peer (WithoutValkeyPeer).
	UseMemoryFallback *bool `json:"use_memory_fallback,omitempty" yaml:"use_memory_fallback,omitempty" env:"USE_MEMORY_FALLBACK"`
	// ForceMemory is break-glass: use sticky notes even when Valkey is missing
	// a live client at Init, and prefer the memory path at runtime while set.
	// Pair with DegradedMode on the valkey component when Valkey is wired but
	// may be down at start. When both ForceMemory and UseMemoryFallback are on and
	// Valkey is healthy, lame_memory_mode screams.
	ForceMemory *bool `json:"force_memory,omitempty" yaml:"force_memory,omitempty" env:"FORCE_MEMORY"`
	// MemoryMaxEntries caps distinct keys in the in-process map. Default 10000.
	MemoryMaxEntries int `json:"memory_max_entries,omitempty" yaml:"memory_max_entries,omitempty" env:"MEMORY_MAX_ENTRIES"`
	// MemoryFallbackLimit is the optional coarser limit used when a call site
	// chooses StorageMemoryFallback. 0 → the call's own limit.
	MemoryFallbackLimit int64 `json:"memory_fallback_limit,omitempty" yaml:"memory_fallback_limit,omitempty" env:"MEMORY_FALLBACK_LIMIT"`
	// MemoryFallbackWindowSec is the optional coarser window (seconds) used
	// when a call site chooses StorageMemoryFallback. 0 → the call's own window.
	MemoryFallbackWindowSec float64 `json:"memory_fallback_window_sec,omitempty" yaml:"memory_fallback_window_sec,omitempty" env:"MEMORY_FALLBACK_WINDOW_SEC"`
	// MemoryMapFullPolicy must be "allow" or "deny" whenever use_memory_fallback or
	// force_memory is true (Init/reload fails otherwise). When both are false
	// the field may be empty (map unused).
	MemoryMapFullPolicy string `json:"memory_map_full_policy,omitempty" yaml:"memory_map_full_policy,omitempty" env:"MEMORY_MAP_FULL_POLICY"`
	// WaitMissingTTLPolicy must be "proceed" or "error" at Init. After scrubbing
	// a counter with count > 0 and no TTL: proceed continues (Allow once /
	// empty Peek); error returns ErrMissingTTL.
	WaitMissingTTLPolicy string `json:"wait_missing_ttl_policy,omitempty" yaml:"wait_missing_ttl_policy,omitempty" env:"WAIT_MISSING_TTL_POLICY"`
	// HashIPKeys is a documented toggle for apps: when true, apps should HMAC
	// IPs in their KeyFunc (the KeyHasher helper is provided). The component
	// exposes it via HashIPKeys(); it does not rewrite keys itself, because the
	// HMAC secret is app-owned. Emails/account ids are always hashed by
	// convention in the recipes.
	HashIPKeys *bool `json:"hash_ip_keys,omitempty" yaml:"hash_ip_keys,omitempty" env:"HASH_IP_KEYS"`
	// MaxKeyLength is the maximum byte length of the logical key (default 256).
	// 0 after load means "use the default", not unlimited — use the option or
	// config explicitly for a higher cap.
	MaxKeyLength int `json:"max_key_length,omitempty" yaml:"max_key_length,omitempty" env:"MAX_KEY_LENGTH"`
	// WaitJitterMaxSec caps the random extra sleep Wait adds to ResetIn.
	// 0 disables jitter. Omitted → the built-in default (~10% of ResetIn capped
	// at 1s).
	WaitJitterMaxSec *float64 `json:"wait_jitter_max_sec,omitempty" yaml:"wait_jitter_max_sec,omitempty" env:"WAIT_JITTER_MAX_SEC"`
}

Config is the file/env-drivable module configuration. It holds module settings (key prefix, metrics, memory sizing, max key length, wait jitter) — not the per-call limits and windows, which stay caller-supplied (auth / gh-app keep their own policy on their own config sources).

type KeyHasher

type KeyHasher struct {
	// contains filtered or unexported fields
}

KeyHasher hashes PII-ish key parts (emails, optionally IPs) before they become rate-limit keys. Emails/account ids are always hashed in the recipes; IPs are hashed when the app enables hash_ip_keys. The secret is app-owned (a pepper or a dedicated rate_limit_key_secret) and must never be logged or stored next to pre-hash identities.

func NewKeyHasher

func NewKeyHasher(secret string) (*KeyHasher, error)

NewKeyHasher creates a KeyHasher with the given secret. An empty secret is an error: hashing with no secret is effectively a plain SHA-256 and defeats the purpose.

func (*KeyHasher) Hash

func (h *KeyHasher) Hash(part string) string

Hash returns the lowercase hex HMAC-SHA256 of part. The output is stable for the same secret+part and is 64 hex characters, so a key like "login:"+hash fits the default 256-byte MaxKeyLength.

type MapFullPolicy

type MapFullPolicy int

MapFullPolicy is the "fallback/memory map hit its max distinct keys" policy. On MemoryFallbackConfig, the zero value (MapFullAllow) means "inherit the component's required memory_map_full_policy". After resolve, MapFullAllow allows the call without counting and MapFullDeny rejects it.

const (
	// MapFullAllow allows a new key without counting when the map is full.
	// On MemoryFallbackConfig, the zero value also means "inherit component".
	MapFullAllow MapFullPolicy = iota
	// MapFullDeny rejects Allow for a new key when the cap is reached.
	MapFullDeny
)

type MemoryFallbackConfig

type MemoryFallbackConfig struct {
	// MaxEntries is a hard cap on distinct keys. 0 uses the component's
	// configured memory_max_entries.
	MaxEntries int
	// WhenMapFull is the policy when a new key would exceed MaxEntries.
	// MapFullAllow (zero value) uses the component's configured
	// memory_map_full_policy; MapFullDeny always denies.
	WhenMapFull MapFullPolicy
	// Limit is an optional coarser ceiling when running on fallback only.
	// 0 uses the call's limit (or the component's memory_fallback_limit).
	Limit int64
	// Window is an optional coarser window when running on fallback only.
	// 0 uses the call's window (or the component's memory_fallback_window_sec).
	Window time.Duration
}

MemoryFallbackConfig sizes the in-process memory limiter used by the StorageMemoryFallback policy and by force_memory / memory-only mode. Zero fields fall back to the component's configured values; when those are also zero, the call's own limit/window are used.

type MiddlewareConfig

type MiddlewareConfig struct {
	// Limiter is the component instance. Required.
	Limiter *RateLimiter
	// Limit is the number of calls allowed per Window for each key. <= 0
	// disables rate limiting for this middleware (every call allowed).
	Limit int64
	// Window is the fixed window duration. Must be > 0.
	Window time.Duration
	// KeyFunc extracts the logical key from the request (e.g. a trusted IP,
	// "ip:"+ip). Required — do not invent a clever X-Forwarded-For parser
	// here; use identity your ingress/mesh already normalized.
	KeyFunc func(*http.Request) string
	// OnStoreError is the storage-error policy. Required.
	OnStoreError *StorageErrorPolicy
	// Memory sizes the memory fallback when OnStoreError == StorageMemoryFallback.
	// Wired into AllowWithPolicyOpts so per-route overrides apply.
	Memory MemoryFallbackConfig
	// OnDenied, when set, is called instead of the default 429/503 response.
	// status is http.StatusTooManyRequests for an over-limit denial, or
	// http.StatusServiceUnavailable for a FailClosed store error. Use it for
	// custom bodies, status codes, or logging.
	OnDenied func(w http.ResponseWriter, r *http.Request, res Result, status int)
}

MiddlewareConfig configures the stdlib Middleware. OnStoreError is required (use a pointer so zero does not silently mean FailOpen); Middleware errors when it is nil — choosing this module means tuning store-error policy.

type MissingTTLPolicy

type MissingTTLPolicy int

MissingTTLPolicy selects what happens after scrubbing a counter that has count > 0 but no usable TTL. It must be set explicitly on the component (wait_missing_ttl_policy); WaitOptions may override per call.

const (
	// MissingTTLProceed continues after delete: Allow re-runs once; Wait does
	// not sleep and proceeds to Allow once; Peek returns an empty Result.
	MissingTTLProceed MissingTTLPolicy = iota + 1
	// MissingTTLError returns ErrMissingTTL after delete.
	MissingTTLError
)

type Option

type Option func(*options)

Option configures the rate limiter at construction time.

func WithConfig

func WithConfig(cfg Config) Option

WithConfig sets a static configuration snapshot. Non-zero fields of cfg override the values set by the convenience options. Prefer WithConfigSource when using caerus-framework-configuration with hot-reload.

func WithConfigSource

func WithConfigSource(name, path string, opts ...SourceOption) Option

WithConfigSource binds this component to a named configuration source and registers that source with the configuration component (via the framework's ConfigSourceRegistrar pass during argv absorption). Declares a dependency on "configuration".

func WithForceMemory

func WithForceMemory(enabled bool) Option

WithForceMemory enables or disables break-glass sticky-note primary (force_memory). When Valkey is wired but Client() is nil at Init, this must be true or Init fails. At runtime, force_memory prefers the memory path.

func WithKeyPrefix

func WithKeyPrefix(prefix string) Option

WithKeyPrefix sets the logical key prefix placed between the valkey peer's own key prefix and the caller's key (default "rl"). A trailing ":" is trimmed.

func WithLogger

func WithLogger(logger *slog.Logger) Option

WithLogger overrides the logger used for component diagnostics. By default the component logs through the framework logs component (declared in GetDependencies); WithLogger is an explicit override for tests and embedded use and wins over the framework logger. slog.Default() remains the fallback only when neither is available.

func WithMaxKeyLength

func WithMaxKeyLength(n int) Option

WithMaxKeyLength sets the maximum byte length of the logical key (default 256). Oversized keys are rejected with an error, never truncated.

func WithMemoryMapFullPolicy

func WithMemoryMapFullPolicy(policy string) Option

WithMemoryMapFullPolicy seeds memory_map_full_policy ("allow" or "deny"). Required at Init when use_memory_fallback or force_memory is true.

func WithMemoryMaxEntries

func WithMemoryMaxEntries(n int) Option

WithMemoryMaxEntries sets the hard cap on distinct keys in the in-process map (default 10000).

func WithMetricsEnabled

func WithMetricsEnabled(enabled bool) Option

WithMetricsEnabled toggles the metrics catalogue. When disabled, Metrics() returns nil. Default: enabled.

func WithName

func WithName(name string) Option

WithName sets a custom component name, allowing multiple rate-limiter instances in the same process. The default name is "http-ratelimiter" (ComponentName). Retrieve named instances with GetByName[*cf_http_ratelimiter.RateLimiter](fw, "sessions").

func WithUseMemoryFallback

func WithUseMemoryFallback(enabled bool) Option

WithUseMemoryFallback enables or disables the sticky-note engine (use_memory_fallback). This is the capability flag: MemoryFallback and memory-only chassis need it on. It does not remove the valkey dependency — use WithoutValkeyPeer when the process omits valkey from the framework graph.

func WithValkeyName

func WithValkeyName(name string) Option

WithValkeyName binds the limiter to a valkey component with the given name (WithName on the valkey side). The default is the valkey ComponentName ("valkey").

func WithWaitDelayFunc

func WithWaitDelayFunc(fn WaitDelayFunc) Option

WithWaitDelayFunc installs an app-supplied callback that decides how long Wait sleeps before retrying Allow (or aborts it). A nil callback uses the built-in jittered sleep. See WaitDelayFunc.

func WithWaitJitterMax

func WithWaitJitterMax(d time.Duration) Option

WithWaitJitterMax caps the random extra sleep Wait adds to ResetIn (default 1s, ~10% of ResetIn). 0 disables jitter for deterministic tests.

func WithWaitMissingTTLPolicy

func WithWaitMissingTTLPolicy(policy string) Option

WithWaitMissingTTLPolicy seeds wait_missing_ttl_policy ("proceed" or "error"). Required at Init.

func WithoutValkeyPeer

func WithoutValkeyPeer() Option

WithoutValkeyPeer omits valkey from GetDependencies. Use when the process has no valkey component (laptop / single-replica sticky-note chassis). Init then requires use_memory_fallback=true. Wrong: omit valkey from Components but leave the default dependency — Validate fails. Right: WithoutValkeyPeer + WithUseMemoryFallback(true) + explicit memory_map_full_policy + wait_missing_ttl_policy.

type RateLimiter

type RateLimiter struct {
	// contains filtered or unexported fields
}

RateLimiter is the caerus-framework-http-ratelimiter component. It counts attempts for logical keys in a shared store (a cf_valkey.CFValkey peer by default; an in-process map when force_memory / memory-only / MemoryFallback) and reports allow/deny plus time-until-reset. It holds the valkey peer component (never a client snapshot) and builds every command through the peer's live Client() and prefix-aware Key(), so reconnects and key prefixes stay consistent.

func New

func New(opts ...Option) *RateLimiter

New creates a rate-limiter component. The valkey peer (or memory path) is resolved at Init, not here.

func (*RateLimiter) Allow

func (c *RateLimiter) Allow(ctx context.Context, key string, limit int64, window time.Duration) (Result, error)

Allow increments the counter for key and reports whether Count <= limit inside window. An empty key or a key longer than MaxKeyLength is an error (no truncation). A limit <= 0 treats rate limiting as off for this call: always allowed, no storage access, counted only in disabled_total. Transport errors are returned to the caller — fail-open/fail-closed policy belongs to the app (see AllowWithPolicy and Middleware).

func (*RateLimiter) AllowWithPolicy

func (c *RateLimiter) AllowWithPolicy(ctx context.Context, key string, limit int64, window time.Duration, policy StorageErrorPolicy) (Result, error)

AllowWithPolicy is Allow with an explicit storage-error policy argument, for call sites that cannot use the HTTP middleware. On a primary-store error it applies the policy: StorageFailOpen returns Allowed=true, StorageFailClosed returns the error, StorageMemoryFallback delegates to the in-process memory limiter (requires use_memory_fallback=true) using the component's configured fallback sizing. limit <= 0 still short-circuits (disabled_total only) before any policy logic.

func (*RateLimiter) AllowWithPolicyOpts

func (c *RateLimiter) AllowWithPolicyOpts(ctx context.Context, key string, limit int64, window time.Duration, policy StorageErrorPolicy, memory MemoryFallbackConfig) (Result, error)

AllowWithPolicyOpts is AllowWithPolicy with per-call MemoryFallbackConfig overrides (wired from MiddlewareConfig.Memory).

func (*RateLimiter) ForceMemory

func (c *RateLimiter) ForceMemory() bool

ForceMemory reports whether break-glass sticky-note primary is enabled.

func (*RateLimiter) GetDependencies

func (c *RateLimiter) GetDependencies() []string

GetDependencies implements cf.Dependencies. Always depends on logs, and on configuration when WithConfigSource is set. Depends on the valkey peer unless WithoutValkeyPeer was used (memory-only chassis). Peer names are fixed at construction, so the graph is stable before Init.

func (*RateLimiter) GetInitOrderStage

func (c *RateLimiter) GetInitOrderStage() cf.Stage

GetInitOrderStage implements cf.CaerusComponent.

func (*RateLimiter) HashIPKeys

func (c *RateLimiter) HashIPKeys() bool

HashIPKeys reports the configured hash_ip_keys toggle. The component does not hash keys itself (the HMAC secret is app-owned); apps read this flag in their KeyFunc and use KeyHasher when true.

func (*RateLimiter) Health

func (c *RateLimiter) Health(ctx context.Context) error

Health implements cf.HealthProvider. It reports unhealthy before Init or after Shutdown. After Init in memory-only / force_memory sticky mode it reports healthy (the process-local map is ready). With a Valkey primary it delegates to the peer's health (a real PING).

func (*RateLimiter) Init

func (c *RateLimiter) Init(ctx context.Context, fw *cf.CaerusFramework) error

Init implements cf.CaerusComponent. It subscribes to the framework logs component, applies the bound configuration source, validates required policies, and resolves the valkey peer when required.

func (*RateLimiter) Key

func (c *RateLimiter) Key(logical string) string

Key builds the storage key for a logical key: the valkey peer's prefix-aware Key() plus this component's key prefix (e.g. portal:rl:login:<hex>). Before Init (memory mode or key-helper use) it falls back to a plain ":"-join.

func (*RateLimiter) Metrics

func (c *RateLimiter) Metrics() []cf_observability.Metric

Metrics implements cf_observability.MetricsProvider. It returns nil before Init / after Shutdown, and also when metrics_enabled is false (a reload can flip enablement live). Low-cardinality labels only — never raw keys, IPs, or emails. Counters appear at zero until first fired so the series are always present while initialized.

func (*RateLimiter) Name

func (c *RateLimiter) Name() string

Name implements cf.CaerusComponent. Returns the custom name set via WithName, or the default ComponentName ("http-ratelimiter") if no custom name was set.

func (*RateLimiter) OnConfigReload

func (c *RateLimiter) OnConfigReload(source string, cfg any)

OnConfigReload implements cf.ConfigReloader. It re-applies the module tunables from the bound configuration source. Invalid required policies keep last-good settings. No connection is rebuilt: the valkey peer owns client rotation.

func (*RateLimiter) Peek

func (c *RateLimiter) Peek(ctx context.Context, key string) (Result, error)

Peek reads the current counter and TTL for key without incrementing it. A missing or expired key reports Count 0 and ResetIn 0. Allowed is always false (Peek has no limit to compare against). Used by Wait and useful for dashboards and pre-flight checks.

func (*RateLimiter) RegisterConfigSources

func (c *RateLimiter) RegisterConfigSources(conf any) error

RegisterConfigSources implements cf.ConfigSourceRegistrar. The framework calls it during argv absorption; it registers this component's configuration source (name, path, env prefix, format, Owner) with the configuration component. No-op when no source is bound.

func (*RateLimiter) Reset

func (c *RateLimiter) Reset(ctx context.Context, key string) error

Reset deletes the counter for key (successful login / admin unlock). A missing key is a success (idempotent). The error string never includes the logical key (avoid leaking hashed identities into logs).

func (*RateLimiter) Shutdown

func (c *RateLimiter) Shutdown(ctx context.Context) error

Shutdown implements cf.CaerusComponent. It unsubscribes the logs subscription and drops the valkey peer. Further use returns an error.

func (*RateLimiter) UseMemoryFallback

func (c *RateLimiter) UseMemoryFallback() bool

UseMemoryFallback reports whether the sticky-note engine is enabled.

func (*RateLimiter) Wait

func (c *RateLimiter) Wait(ctx context.Context, key string, limit int64, window time.Duration) error

Wait blocks until a single Allow would succeed, then performs that Allow once, or returns when ctx is done. It must not keep calling Allow while denied (each Allow increments the counter): it peeks, sleeps on ResetIn plus a small jitter, then tries Allow once. Sleep is interruptible by ctx. A limit <= 0 returns immediately (limiter off for this call).

func (*RateLimiter) WaitOpts

func (c *RateLimiter) WaitOpts(ctx context.Context, key string, limit int64, window time.Duration, opts WaitOptions) error

WaitOpts is Wait with per-call overrides (missing-TTL policy, delay func).

type Result

type Result struct {
	// Allowed is true when Count does not exceed the limit for the window.
	// From Peek it is always false: Peek has no limit to compare against.
	Allowed bool
	// Count is the (incremented, for Allow) request count within the window.
	Count int64
	// ResetIn is the time remaining until the window resets (for Retry-After).
	ResetIn time.Duration
}

Result reports the outcome of an Allow or Peek call.

type SourceOption

type SourceOption func(*sourceOptions)

SourceOption configures the self-registered configuration source created by WithConfigSource.

func WithSourceEnvPrefix

func WithSourceEnvPrefix(prefix string) SourceOption

WithSourceEnvPrefix sets the environment overlay prefix for the source (default: the uppercase source name with "-" replaced by "_", plus "_"). An empty prefix disables env overlay.

func WithSourceFormat

func WithSourceFormat(f cf_configuration.Format) SourceOption

WithSourceFormat forces the file format instead of inferring it from the path extension (".yaml"/".yml" → YAML; anything else JSON).

type StorageErrorPolicy

type StorageErrorPolicy int

StorageErrorPolicy selects what happens when the primary store (Valkey) errors or is unavailable. It is the main safety switch for "Valkey is dead — what now?" and must be set explicitly per call site (AllowWithPolicy always takes one; Middleware errors if OnStoreError is unset). There is no silent FailOpen.

const (
	// StorageFailOpen treats a store error as allowed. Site stays up;
	// attackers also get in with no limits. Typical for IP middleware on login
	// routes where availability wins.
	StorageFailOpen StorageErrorPolicy = iota
	// StorageFailClosed treats a store error as denied / returns an error.
	// Safer; real users may see 429/503 until the store is back. Typical for
	// webhook intake where failing open would feed an attacker.
	StorageFailClosed
	// StorageMemoryFallback uses the process-local memory limiter for that
	// call. Still some limits, only on this one server. Requires use_memory_fallback=true
	// on the component. Typical for login lockout where a coarse in-process
	// ceiling is better than none.
	StorageMemoryFallback
)

type WaitDelayFunc

type WaitDelayFunc func(ctx context.Context, key string, base, jittered time.Duration) (sleep time.Duration, err error)

WaitDelayFunc is called when Wait must pause before retrying Allow. base is the reset-in from the store (Peek/Allow); jittered is base plus the built-in jitter (if enabled). Return the duration to sleep (may be 0), or an error to abort Wait. A nil function sleeps jittered (or base when jitter is disabled) with ctx.

type WaitOptions

type WaitOptions struct {
	// MissingTTLPolicy overrides wait_missing_ttl_policy when non-nil.
	MissingTTLPolicy *MissingTTLPolicy
	// DelayFunc overrides the component WaitDelayFunc when non-nil. A non-nil
	// function that is itself nil-valued is not expressible; omit to inherit.
	DelayFunc WaitDelayFunc
	// contains filtered or unexported fields
}

WaitOptions overrides Wait behaviour for a single call. Nil pointer fields inherit the component defaults.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL