Help us shape the future of Go! Take the 2026 Go Developer Survey

ratelimit

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Nov 1, 2026 License: MIT Imports: 18 Imported by: 0

README

Rate Limit

arkoren.dev/core/http/middleware/ratelimit provides sliding-window rate-limit middleware for Arkoren applications. Each request weighs the previous window's count by how much of it still overlaps the sliding window, reducing fixed-window boundary bursts. This is an estimate, not an exact rolling request log. Denied requests are not counted. Counters are stored through arkoren.dev/core/service/cache, so a shared cache driver enforces the same limit across application instances (whose clocks should be roughly in sync).

Given a store cache and an existing r router, allow ten requests per second. This fragment also uses time:

r.Use(ratelimit.Middleware(store, func(limiter *ratelimit.Builder) {
	limiter.Limit(10)
	limiter.Window(time.Second)
}))

Behind a proxy or load balancer, key clients by their forwarded address. The helper trusts X-Forwarded-For only when RemoteAddr is within the given prefixes, and picks the rightmost untrusted entry. Import net/netip for the prefix:

r.Use(ratelimit.Middleware(store, func(limiter *ratelimit.Builder) {
	limiter.KeyFunc(ratelimit.TrustedProxyKey(netip.MustParsePrefix("10.0.0.0/8")))
}))

Counters use Cache.IncrementWithTTL, which increments and sets the expiry atomically. A driver that cannot do this returns cache.ErrUnsupported and cannot back the limiter.

Construction callbacks set a custom limit, window, key function, namespace, and error response. By default, requests are grouped by client IP, limited to 60 requests per minute, and use keys in the arkoren:ratelimit: namespace.

Zero Limit and Window retain those defaults. Positive int limits are accepted; counts and recovery intervals use integer ratios rather than floating point. RateLimit-Remaining rounds down, while recovery and Retry-After round up to whole seconds and never report less than one second. The estimate is still not an exact request log.

A negative limit, negative window, window above time.Duration(math.MaxInt64/2), invalid namespace, or nil cache panics after all callbacks. The window bound keeps two-window counter retention and recovery representable. Cache failures, including a nonpositive native count after increment, fail the request without calling the protected handler. Fix invalid configuration at construction and handle cache availability as a service failure, not as permission to bypass the limiter.

Builder.ErrorResponse defaults to the standard 429 problem only for a completely zero problem.Details{}. A meaningful custom problem is kept even if its stored status is zero. The HTTP renderer completes its missing members (including status 500), without discarding its type, title, detail, headers, or extensions. Set an explicit status when you want a custom 429 response. Every denial still carries Retry-After; its classification and events do not depend on the problem's status.

With Builder.Events(bus), a cache marker normally limits Exceeded publication to the first denial per client key and fixed window. If adding that marker fails, the limiter still publishes; repeated denials can therefore produce duplicates. Listeners must tolerate them. This best-effort event deduplication is not a durable delivery guarantee and does not weaken request-limit enforcement.

Use your actual proxy network, not an untrusted client network, in the prefix list. The second example is an alternative policy, not an additional limiter. An in-process memory cache limits only one process; use a shared driver for a limit across replicas.

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

Constants

This section is empty.

Variables

View Source
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()
View Source
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

func TrustedProxyKey(trusted ...netip.Prefix) func(*http.Request) string

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

func (limiter *Builder) ErrorResponse(response problem.Details) *Builder

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

func (limiter *Builder) Events(bus *event.Bus) *Builder

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

func (limiter *Builder) KeyFunc(keyFunc func(*http.Request) string) *Builder

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

func (limiter *Builder) Limit(limit int) *Builder

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

func (limiter *Builder) Namespace(space cache.Namespace[int64]) *Builder

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

func (limiter *Builder) Window(window time.Duration) *Builder

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)

Directories

Path Synopsis
internal
window
Package window computes exact sliding-counter usage and recovery intervals.
Package window computes exact sliding-counter usage and recovery intervals.

Jump to

Keyboard shortcuts

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