Documentation
¶
Overview ¶
Package ratelimit provides middleware that limits requests per key with a sliding window counted in a cache, answering 429 by default with RateLimit headers.
With Builder.Events, denials publish Exceeded ("http.ratelimit.exceeded") with the key and policy. A cache marker normally deduplicates each key/window; marker failures still publish and can produce duplicates. This is best-effort deduplication, not durable or exactly-once delivery.
Zero Limit and Window select 60 requests per minute. Positive int limits are supported without float precision loss. A window must fit twice in time.Duration; negative policy values, an invalid namespace, or a nil cache panic after construction callbacks. Invalid native counter results and cache errors fail requests without invoking the protected handler. Recovery headers round up from integer nanosecond ratios, while the sliding-window estimate still assumes requests were uniformly distributed within each fixed window. ErrorResponse selects the standard denial problem only when completely zero. Nonzero problems, including statusless custom values, reach the HTTP renderer unchanged; it supplies missing standard members such as status 500. Denials still carry Retry-After regardless of that custom rendered status.
Example:
store := cache.New(memorycache.New())
routes := router.New()
routes.Use(ratelimit.Middleware(store, func(limiter *ratelimit.Builder) {
limiter.Limit(10)
limiter.Window(time.Second)
}))
Index ¶
- Variables
- func Middleware(c *cache.Cache, configure ...BuilderFunc) router.Middleware
- func TrustedProxyKey(trusted ...netip.Prefix) func(*http.Request) string
- type Builder
- func (limiter *Builder) ErrorResponse(response problem.Details) *Builder
- func (limiter *Builder) Events(bus *event.Bus) *Builder
- func (limiter *Builder) KeyFunc(keyFunc func(*http.Request) string) *Builder
- func (limiter *Builder) Limit(limit int) *Builder
- func (limiter *Builder) Namespace(space cache.Namespace[int64]) *Builder
- func (limiter *Builder) Window(window time.Duration) *Builder
- type BuilderFunc
- type ExceededEvent
Constants ¶
This section is empty.
Variables ¶
var ErrRateLimited = problem.New(func(details *problem.Builder) { details.Type("tag:arkoren.dev,2026-09:core:http/middleware/ratelimit:rate-limited") details.Title("Too Many Requests") details.Detail("Rate limit exceeded. Please slow down and retry later.") details.Status(http.StatusTooManyRequests) })
ErrRateLimited is returned when a request exceeds its configured limit.
Example:
failure := ratelimit.ErrRateLimited.Derive(func(details *problem.Builder) {
details.Detail("The request cannot be completed.")
})
_ = failure.Status()
var Exceeded = event.NewTopic[ExceededEvent]("http.ratelimit.exceeded")
Exceeded reports denied requests when Builder.Events supplies a bus. A cache marker normally limits publication to the first denial per key and fixed window. Deduplication is best-effort: if adding that marker fails, the limiter still publishes, so later denials can publish duplicates. This topic is not a durable or exactly-once delivery guarantee; listeners must tolerate duplicates. A marker failure does not admit a denied request or replace its rate-limit error.
Example:
bus := event.New()
unsubscribe, err := bus.Listen(ratelimit.Exceeded,
func(ctx context.Context, exceeded ratelimit.ExceededEvent) error {
fmt.Println(exceeded.Limit)
return nil
})
if err != nil {
return err
}
defer unsubscribe()
routes.Use(ratelimit.Middleware(store, func(limiter *ratelimit.Builder) {
limiter.Events(bus)
}))
Functions ¶
func Middleware ¶
func Middleware(c *cache.Cache, configure ...BuilderFunc) router.Middleware
Middleware returns middleware that limits each key to the configured number of requests in any sliding window, counting in c.
The limiter is a sliding-window counter. Time is divided into fixed windows of settings.Window aligned to the Unix epoch, and each key keeps one counter per window index, joined to the configured namespace and client with a TTL of twice the window. A request increments the current window's counter and reads the previous window's counter, then estimates the requests in the last window as
previous * (1 - elapsed/Window) + current
where elapsed is the time since the current window began. The request is denied when the estimate exceeds Limit. Unlike a fixed window this cannot admit twice the limit across a window boundary: the previous window's requests keep counting, with linearly decaying weight. The estimate assumes requests were evenly spread over the previous window, so it is approximate, but it needs only atomic increments. Instances must have reasonably synchronized clocks.
Denied requests do not count. A denied request's increment is undone with a decrement, so a client that keeps retrying while limited does not extend its own block: the counters would otherwise carry into later windows and a client sending many times the limit could stay blocked for many windows. Concurrent requests may briefly be denied because another in-flight request's increment is visible, which errs towards the limit.
After successful counter operations, responses carry RateLimit-Limit, RateLimit-Remaining (the estimated requests still available, rounded down), and RateLimit-Reset. Reset is the whole seconds, rounded up and at least one, until the estimate would decay to zero if the client stopped sending, which is the moment the full Limit is available again. A denial also carries Retry-After, independently of the configured problem's status, equal to its Reset: the whole seconds, rounded up and at least one, until the estimate leaves room for one more request. Both are computed from the same window arithmetic as the decision and never fall early. Counts and recovery intervals use integer ratios with upward rounding, so large limits and nanosecond boundaries do not lose precision through floats.
Counters are kept with cache.Cache.IncrementWithTTL, which increments and sets the expiry atomically, so a counter can never outlive its window. A driver that cannot do this returns cache.ErrUnsupported and the limiter cannot be used with it.
With Builder.Events, the first denied request of a key in each fixed window normally publishes Exceeded. Finding the first costs one cache Add per denied request, only when events are enabled. Deduplication is best-effort: failure to add the marker still publishes, so repeated denials can produce duplicate events. Counter failures instead fail the request; they never bypass the limit.
A nil cache, negative Limit, invalid Namespace, or Window outside (0, time.Duration(math.MaxInt64/2)] panics after all callbacks. Zero Limit and Window retain their defaults of 60 and one minute. Cache errors, including a nonpositive counter returned after increment, fail the request without calling the protected handler.
Example:
routes.Use(ratelimit.Middleware(store, func(limiter *ratelimit.Builder) {
limiter.Namespace(cache.NewNamespace[int64]("app.rate"))
}))
func TrustedProxyKey ¶
TrustedProxyKey returns a key function for use with Builder.KeyFunc that identifies clients behind reverse proxies or load balancers. When the request's RemoteAddr lies within trusted, the X-Forwarded-For header lines are walked from right to left and the first address outside trusted is the client. When RemoteAddr is not trusted the header is client-controlled and ignored. If an entry cannot be parsed, or every address is trusted, the nearest trusted hop that reported it is the key, so attacker-chosen strings never become keys. Addresses are reduced as in the default key function.
Example:
routes.Use(ratelimit.Middleware(store, func(limiter *ratelimit.Builder) {
limiter.KeyFunc(ratelimit.TrustedProxyKey(netip.MustParsePrefix("10.0.0.0/8")))
}))
Types ¶
type Builder ¶
type Builder struct {
// contains filtered or unexported fields
}
Builder configures Middleware before publication. Callbacks run directly once, synchronously in order on one fresh draft; nil entries panic. Retaining the draft cannot change published settings. Methods return the same draft.
Example:
r := router.New()
r.Use(ratelimit.Middleware(store, func(limiter *ratelimit.Builder) {
limiter.Limit(60)
}))
func (*Builder) ErrorResponse ¶
ErrorResponse is returned when a request exceeds Limit. A completely zero problem selects ErrRateLimited. A nonzero problem is preserved even when its Status is zero: the HTTP renderer completes absent members, including status 500, without changing the configured identity, detail, headers, or extensions. Denial and event publication do not depend on that rendered status.
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
failure := (problem.Details{}).Derive(func(details *problem.Builder) {
details.Type("urn:example:budget-exhausted")
details.Title("Budget exhausted")
})
routes.Use(ratelimit.Middleware(store, func(limiter *ratelimit.Builder) {
limiter.ErrorResponse(failure)
}))
func (*Builder) Events ¶
Events receives Exceeded. Nil, the default, publishes nothing.
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
r := router.New()
r.Use(ratelimit.Middleware(store, func(limiter *ratelimit.Builder) {
limiter.Events(event.New())
}))
func (*Builder) KeyFunc ¶
KeyFunc extracts the client-specific portion of the cache key.
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
r := router.New()
r.Use(ratelimit.Middleware(store, func(limiter *ratelimit.Builder) {
limiter.KeyFunc(nil)
}))
func (*Builder) Limit ¶
Limit is the number of requests allowed within Window. Zero selects 60; positive int values are accepted without a float precision limit. A negative count makes Middleware panic after all construction callbacks have run.
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
r := router.New()
r.Use(ratelimit.Middleware(store, func(limiter *ratelimit.Builder) {
limiter.Limit(1024)
}))
func (*Builder) Namespace ¶
Namespace selects the counter namespace and separator. Window indices are joined after the client key using the same separator, which also joins the denied-event marker suffix. Counter and marker retention is always twice Window, independently of the namespace TTL. Middleware panics for a zero or invalid namespace after running every construction callback.
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
r := router.New()
r.Use(ratelimit.Middleware(store, func(limiter *ratelimit.Builder) {
limiter.Namespace(cache.NewNamespace[int64]("app.rate"))
}))
func (*Builder) Window ¶
Window is the length of the sliding window over which Limit applies. Zero selects one minute. Other values must be positive and at most half the largest time.Duration, because counters and recovery intervals span two windows. Middleware panics for an invalid final window after all callbacks have run.
The setting replaces the previous value on this construction draft. Publication keeps independent settings; functions and services stay shared.
Example:
r := router.New()
r.Use(ratelimit.Middleware(store, func(limiter *ratelimit.Builder) {
limiter.Window(time.Second)
}))
type BuilderFunc ¶
type BuilderFunc = func(*Builder)
BuilderFunc edits one rate-limit construction draft. Policies run once, synchronously in order, before final dependency, numeric, and namespace validation. A supplied nil policy panics; an expanded nil slice supplies none. Retained drafts cannot change the published middleware; services and function captures remain shared.
Example:
var perMinute ratelimit.BuilderFunc = func(limiter *ratelimit.Builder) {
limiter.Limit(60)
limiter.Window(time.Minute)
}
routes.Use(ratelimit.Middleware(store, perMinute))
type ExceededEvent ¶
type ExceededEvent struct {
// Key is the client part of the counter key, the client IP unless
// Builder.KeyFunc says otherwise. It excludes the namespace and fixed-window
// suffix. Avoid treating this potentially identifying value as public data.
//
// Example:
//
// exceeded := ratelimit.ExceededEvent{Key: "account:123"}
// fmt.Println(exceeded.Key == "account:123")
Key string
// Method is the denied request's method, copied without normalization.
// It describes the observation rather than the client's identity.
//
// Example:
//
// exceeded := ratelimit.ExceededEvent{Method: http.MethodGet}
// fmt.Println(exceeded.Method)
Method string
// Path is the decoded URL path of the denied request, without its query.
// Paths can still contain sensitive identifiers; subscribers own redaction.
//
// Example:
//
// exceeded := ratelimit.ExceededEvent{Path: "/search"}
// fmt.Println(exceeded.Path)
Path string
// Limit is the positive configured request count after zero/default
// normalization, not the number of denied requests or a raw cache counter.
//
// Example:
//
// exceeded := ratelimit.ExceededEvent{Limit: 60}
// fmt.Println(exceeded.Limit)
Limit int
// Window is the positive sliding-window duration after default normalization.
// Counter retention spans twice this interval; the event reports one window.
//
// Example:
//
// exceeded := ratelimit.ExceededEvent{Window: time.Minute}
// fmt.Println(exceeded.Window.Seconds())
Window time.Duration
}
ExceededEvent describes a denied request and the normalized policy that denied it. It carries request metadata rather than the full internal counter key. Key and Path may identify a client or resource; subscribers own their privacy and retention policy. Exceeded's best-effort deduplication can deliver the same key/window observation more than once.
Example:
exceeded := ratelimit.ExceededEvent{
Key: "account:123", Method: http.MethodGet, Path: "/search",
Limit: 60, Window: time.Minute,
}
fmt.Println(exceeded.Method, exceeded.Path, exceeded.Limit, exceeded.Window)