Documentation
¶
Overview ¶
Package ratelimit provides rules-based rate limiting backed by Redis fixed-window counters. The LabKit SDKs use identical Redis key shapes and Lua scripts, so services in any language can share limits against the same Redis, and all emit the same gitlab_labkit_rate_limiter_* Prometheus metrics.
A Limiter is configured once per call site with an ordered list of Rule values and a Store. The redisstore subpackage adapts a go-redis client, so this package itself never exposes go-redis types. Every request calls Limiter.Check to get back a Result describing what the caller should do:
limiter, err := ratelimit.NewWithConfig(&ratelimit.Config{
Name: "rack_request",
Store: redisstore.New(redisClient.Redis()),
Rules: []ratelimit.Rule{
{
Name: "api_user",
Limit: 600,
Period: time.Minute,
Characteristics: []string{"user"},
Match: map[string]ratelimit.Matcher{"endpoint": ratelimit.Re(`^/api/`)},
},
{
Name: "unauthenticated",
Limit: 60,
Period: time.Minute,
Characteristics: []string{"ip"},
Match: map[string]ratelimit.Matcher{"user": ratelimit.Eq("")},
},
},
Registerer: app.Metrics().Registerer(),
Logger: logger,
})
result := limiter.Check(ctx, ratelimit.Identifier{
"user": userID,
"ip": clientIP,
"endpoint": r.URL.Path,
})
if result.Blocked() {
for k, v := range result.ResponseHeaders() {
w.Header().Set(k, v)
}
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
Evaluation ¶
Check walks the rule list in order and evaluates every rule that matches. Each one increments its own counter. Only two things terminate the walk: a matched ActionSkip rule (bypass, nothing counted), and an ActionLimit rule over its limit (the request is rejected, so later rules are neither counted nor evaluated). The Result reports the most constraining evaluation: a blocking one first, then exceeded ones, then the fewest remaining, ties broken by declaration order.
Fail-open ¶
Check and Peek never return an error. Any failure - unreachable Redis, an unresolvable dynamic value - is logged at WARN, counted with error="true" on checks_total or peeks_total, and returned as an allowed Result with Result.Failed true. A request is never blocked on a partial evaluation.
Windows ¶
Counters are fixed windows starting at the first request: the TTL is set on the first write of each window and not extended on later increments. Bans (Rule.BanFor) write a second key that outlives the counter window and suppress counting while they hold, so a banned caller cannot extend its own ban.
See the package README for the full rule, action, and metrics reference.
Index ¶
- Constants
- Variables
- type Action
- type CheckOption
- type Config
- type Evaluation
- type Identifier
- type Info
- type Limiter
- type Matcher
- type Result
- func (r *Result) Allowed() bool
- func (r *Result) Blocked() bool
- func (r *Result) Degraded() bool
- func (r *Result) Evaluations() []Evaluation
- func (r *Result) Exceeded() bool
- func (r *Result) Failed() bool
- func (r *Result) Info() *Info
- func (r *Result) Matched() bool
- func (r *Result) ResponseHeaders() map[string]string
- func (r *Result) Rule() *Rule
- func (r *Result) Skipped() bool
- type Rule
- type RuleContext
- type Store
Constants ¶
const EndpointKey = "endpoint"
EndpointKey is the identifier key treated specially by the limiter: its query string is stripped before matching and key building, so URLs that vary only by query parameter share the same counter.
Variables ¶
Functions ¶
This section is empty.
Types ¶
type Action ¶
type Action string
Action describes what a rule does when it matches. The zero value is treated as ActionLimit.
const ( // ActionLimit enforces the rule: the result blocks when the rule is over // its limit, which also terminates evaluation. ActionLimit Action = "limit" // ActionLog counts and reports only. It never blocks and never // terminates evaluation. Use it for shadow rules during rollout. ActionLog Action = "log" // ActionSkip bypasses: a match permits the request and terminates // evaluation without any Redis operation, so Limit, Period, // Characteristics, and CountDistinct are inert on a skip rule. ActionSkip Action = "skip" )
type CheckOption ¶
type CheckOption func(*checkOptions)
CheckOption configures a single Limiter.Check or Limiter.Peek call.
func WithCost ¶
func WithCost(cost float64) CheckOption
WithCost sets the amount added to the counter, defaulting to 1 (count-mode). Pass a non-1 value for cost-mode counters such as resource-usage limits. Passing 0 charges nothing but still opens the fixed window on a fresh key, so a later charged request inherits the window that started here. Use Limiter.Peek to read without writing. A negative cost would credit quota back, so the check fails open and logs instead. The cost is ignored by CountDistinct rules and by Limiter.Peek.
func WithRuleContext ¶
func WithRuleContext(rctx RuleContext) CheckOption
WithRuleContext passes per-request context to func-resolved rule values (LimitFunc, PeriodFunc, BanForFunc). The concrete type contract is owned by the rule's func, not validated here: keep the rule definition and the call site colocated.
type Config ¶
type Config struct {
// Name identifies this limiter's call site (e.g. "rack_request",
// "graphql_mutations"). Must match ^[a-z0-9_]+$. It is the first
// segment of every Redis counter key for this limiter, so renaming a
// limiter abandons any in-flight counters. Required.
Name string
// Rules is the ordered list of rules. Every rule whose Match
// predicates are satisfied is evaluated and counted, and the Result
// reports the most constraining one. Rule names must be unique within
// a limiter.
Rules []Rule
// Store executes the limiter's Redis scripts and deletes. Wrap the
// labkit redis client's Redis() value with redisstore.New, or supply
// any [Store] implementation. Required.
Store Store
// Registerer publishes the limiter's Prometheus metrics. When nil,
// metrics are not collected. Limiters share metric families and are
// distinguished by the rate_limiter label, so any number of limiters
// can register against the same Registerer.
Registerer prometheus.Registerer
// Logger emits structured warnings (fail-open errors, missing
// count_distinct values). When nil, no log output is produced. The
// limiter name is automatically included on every entry.
Logger *slog.Logger
}
Config configures a Limiter created via NewWithConfig.
type Evaluation ¶
type Evaluation struct {
// Exceeded reports whether the post-increment count exceeded the
// resolved limit. On a rule carrying a ban it tracks the ban instead:
// once the counter window has expired the count can sit below the
// limit while the ban still holds.
Exceeded bool
// Info holds the per-window counters.
Info Info
// contains filtered or unexported fields
}
Evaluation is the outcome of counting one matched rule.
func (*Evaluation) Blocks ¶
func (e *Evaluation) Blocks() bool
Blocks reports whether this evaluation decides the request: an ActionLimit rule over its limit (or with its ban in force).
func (*Evaluation) Rule ¶
func (e *Evaluation) Rule() Rule
Rule returns a copy of the evaluated rule. Mutating it has no effect on the limiter.
type Identifier ¶
Identifier describes the caller of one request as key-value attributes (e.g. user, ip, endpoint). Rules match against these values and fold the keys named in Rule.Characteristics into the Redis counter key.
A missing key reads as the empty string: matchers see "" and counter keys encode it as the "_unknown_" sentinel shared across the SDKs.
type Info ¶
type Info struct {
// Limit is the resolved limit for this check.
Limit int64
// Period is the resolved window length for this check.
Period time.Duration
// Count is the post-increment counter value. Integer-valued for
// default cost=1 callers, fractional for cost-mode callers.
Count float64
// Remaining is the amount left before the limit is hit, floored at 0.
Remaining float64
// ResetAt is the best-effort UTC time when the counter window resets
// (or, while a ban holds, when the ban lifts). Advisory only.
ResetAt time.Time
}
Info holds the per-window counter snapshot for one evaluated rule.
type Limiter ¶
type Limiter struct {
// contains filtered or unexported fields
}
Limiter is the primary rate limiting API. Construct one per call site at application boot with NewWithConfig, then call Limiter.Check on every request. A Limiter is immutable after construction and safe for concurrent use.
func NewWithConfig ¶
NewWithConfig returns a Limiter configured with cfg. It validates the limiter name and every rule (name format and uniqueness, action, period, ban and count-distinct constraints, match pattern compilation), returning an error rather than constructing a limiter that would misbehave at request time.
func (*Limiter) Check ¶
func (l *Limiter) Check(ctx context.Context, id Identifier, opts ...CheckOption) *Result
Check evaluates the identifier against the limiter's rules, incrementing the counter of every matching rule, and returns a Result describing what the caller should do. It never returns an error: any failure (unreachable Redis, unresolvable dynamic configuration) fails open, logged at WARN and visible on the Result via Result.Failed.
func (*Limiter) Clear ¶
func (l *Limiter) Clear(ctx context.Context, id Identifier) int64
Clear discards this limiter's state for one identifier: every rule's counter, and any ban written by a rule carrying a ban duration. It returns the number of Redis keys removed, or on error the number removed before it failed.
Counters otherwise only expire with their window, so this is the only way to end one early. It exists for call sites where a later success should wipe earlier failures, such as an authentication ban cleared by a valid login. It is scoped to this limiter and identifier, not to a single rule: a caller clearing after a success knows who succeeded, not which rules matched.
func (*Limiter) Peek ¶
func (l *Limiter) Peek(ctx context.Context, id Identifier, opts ...CheckOption) *Result
Peek reads the current rate-limit state without incrementing any counter or extending any TTL. It mirrors Limiter.Check otherwise. Useful for "have we already throttled this caller?" checks where another path does the actual increment.
When the underlying Redis key does not exist yet, the result reports count=0 and not exceeded, and the rule still matches. On error the result fails open identically to Check.
A CountDistinct rule needs no CountDistinct key on the identifier here: Peek reads the set's cardinality across all members. So Peek can report Result.Blocked for an identifier on which Check would report Result.Degraded.
type Matcher ¶
type Matcher struct {
// contains filtered or unexported fields
}
Matcher is a single key predicate in a Rule Match map. Build one with Eq, Re, or OneOf. The zero value behaves like Eq(""), which matches a missing or empty identifier value.
Glob and prefix matchers are intentionally out of scope. See gitlab-com/gl-infra/production-engineering#28853.
func Eq ¶
Eq returns a Matcher satisfied when the identifier value equals v. A missing identifier key reads as the empty string, so Eq("") matches callers that do not carry the key at all (e.g. unauthenticated traffic without a user).
func OneOf ¶
OneOf returns a Matcher satisfied when the identifier value is one of values (exact string membership).
func Re ¶
Re returns a Matcher satisfied when the identifier value matches the regular expression pattern. The pattern is compiled by NewWithConfig using Go's RE2 engine, which is linear-time, so no match timeout is needed. An invalid pattern or one longer than 200 characters makes NewWithConfig return an error.
type Result ¶
type Result struct {
// contains filtered or unexported fields
}
Result is the return value of Limiter.Check and Limiter.Peek.
It accumulates one Evaluation per matched-and-counted rule. The reader methods report the single most constraining one. A matched skip rule or a fail-open error replaces the evaluations as the source of the reported outcome.
func (*Result) Allowed ¶
Allowed is the complement of Result.Blocked. It covers everything else, including an exceeded log rule (visible via Result.Exceeded), a matched skip rule, no rule matched, and fail-open errors.
func (*Result) Blocked ¶
Blocked reports whether the caller should reject the request (e.g. with HTTP 429): an ActionLimit rule matched and is over its limit.
func (*Result) Degraded ¶
Degraded reports whether a matched CountDistinct rule was skipped because the identifier was missing its CountDistinct key. Unlike Result.Failed, the check still completed.
func (*Result) Evaluations ¶
func (r *Result) Evaluations() []Evaluation
Evaluations returns every counted evaluation, in rule declaration order.
func (*Result) Exceeded ¶
Exceeded reports whether the reported evaluation's counter exceeded its limit. A log rule over its limit reports true here while the result still allows.
func (*Result) Failed ¶
Failed reports whether the check failed open: Redis or a rule's dynamic configuration was unavailable, so the request is allowed without a verdict.
func (*Result) Info ¶
Info returns the per-window counters for the reported rule. Nil when nothing matched, on fail-open, or when the matched rule is a skip rule (no counter exists).
func (*Result) Matched ¶
Matched reports whether at least one rule's match conditions were satisfied.
func (*Result) ResponseHeaders ¶
ResponseHeaders returns RFC-compliant rate limit response headers, or an empty map when no rule matched or an error occurred, so it is safe to merge unconditionally. Keys: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (Unix timestamp). Remaining is truncated to an integer: the RateLimit header spec requires integer values.
func (*Result) Rule ¶
Rule returns a copy of the reported rule: the skip rule when one matched, otherwise the most constraining evaluated rule. Nil when nothing matched. Other rules may also have matched and been counted. See Result.Evaluations. Mutating the copy has no effect on the limiter.
type Rule ¶
type Rule struct {
// Name is a stable identifier used in Redis keys and metric labels.
// Must match ^[a-z0-9_]+$, max 64 characters. Renaming a rule abandons
// its in-flight counters.
Name string
// Match holds identifier key predicates that must all be satisfied for
// the rule to apply. An empty or nil map matches any identifier.
Match map[string]Matcher
// Limit is the request threshold per Period. Must be at least 1 unless
// LimitFunc is set: an omitted Limit would block every matching
// request. Ignored when LimitFunc is set.
Limit int64
// LimitFunc, when set, resolves the limit on every check, receiving the
// [RuleContext] passed via [WithRuleContext] (nil when none was passed).
// A resolved value of 0 or less blocks every matching request, which
// lets a dynamic setting mean "deny all".
LimitFunc func(rctx RuleContext) int64
// Period is the fixed window length. Must be at least one second. The
// window TTL is written in whole seconds, so any sub-second remainder
// is dropped. Ignored when PeriodFunc is set.
Period time.Duration
// PeriodFunc, when set, resolves the period on every check. A resolved
// period under one second is an error and fails open.
PeriodFunc func(rctx RuleContext) time.Duration
// Action is what the rule does when it matches. Defaults to
// [ActionLimit].
Action Action
// Characteristics names the identifier keys whose values are folded
// into the Redis counter key. Each unique combination of values gets
// its own counter. Missing or empty values are encoded as "_unknown_".
Characteristics []string
// CountDistinct optionally names an identifier key. When set, the rule
// counts the number of distinct values seen for that key within the
// (characteristics-bucketed) window, backed by a Redis SET rather than
// a counter. Must not overlap Characteristics. Cannot be combined with
// a ban.
CountDistinct string
// BanFor is an optional ban duration, minimum 1s, truncated to whole
// seconds like Period. On crossing the limit the rule writes a separate
// ban key that outlives the counter window, and stops counting while the
// ban holds. Action still decides who is blocked: ActionLimit enforces
// the ban, ActionLog records what it would have done. Rejected on
// ActionSkip. Ignored when BanForFunc is set.
BanFor time.Duration
// BanForFunc, when set, resolves the ban duration on every check. A
// resolved duration under one second is an error and fails open, so a
// rule whose ban duration cannot resolve stops blocking entirely.
BanForFunc func(rctx RuleContext) time.Duration
}
Rule describes a single rate limit rule. Rules are value objects: NewWithConfig validates and copies them, so mutating a Rule after constructing a Limiter has no effect.
type RuleContext ¶
type RuleContext = any
RuleContext carries optional per-check data into LimitFunc, PeriodFunc, and BanForFunc, letting rules resolve dynamic configuration (e.g. per-namespace settings) without rebuilding the Rule. The concrete type is a contract between the rule's func and the call site passing WithRuleContext. The limiter never inspects it and passes nil when the caller supplies none.
type Store ¶
type Store interface {
// Eval runs a Lua script against keys with args and returns its reply.
// Implementations should load the script once and reference it by
// digest afterwards (EVALSHA, falling back to EVAL on NOSCRIPT).
//
// Replies follow the Redis-to-Go mapping: integers as int64, bulk
// strings as string, arrays as []any, and a nil reply as nil.
Eval(ctx context.Context, script string, keys []string, args ...any) (any, error)
// Del removes keys and returns how many of them existed.
Del(ctx context.Context, keys ...string) (int64, error)
}
Store is the storage surface a Limiter needs, expressed in plain Go types so the storage client stays an implementation detail of the adapter. The go-redis adapter is redisstore.New. A test double needs no Redis client at all.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package redisstore adapts a go-redis client to ratelimit.Store.
|
Package redisstore adapts a go-redis client to ratelimit.Store. |