rate

package module
v0.2.1 Latest Latest
Warning

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

Go to latest
Published: Aug 14, 2025 License: MIT Imports: 5 Imported by: 0

README

Tests Go Reference

A new, composable rate limiter for Go, with an emphasis on clean API and low overhead.

Rate limiters are typically an expression of several layers of policy. You might limit by user, or by resource, or both. You might allow short spikes; you might apply dynamic limits; you may want to stack several limits on top of one another.

This library intends to make the above use cases expressible, readable and easy to reason about.

Early days! I want your feedback, on GitHub or on 𝕏.

Quick start

go get github.com/clipperhouse/rate
// Define a “KeyFunc”, which defines the bucket. It’s generic, doesn't have to be HTTP.
func byIP(req *http.Request) string {
    // You can put arbitrary logic in here. In this case, we’ll just use IP address.
    return req.RemoteAddr
}

limit := rate.NewLimit(10, time.Second)
limiter := rate.NewLimiter(byIP, limit)

// In your HTTP handler:
if limiter.Allow(r) {
    w.WriteHeader(http.StatusOK)
} else {
    w.WriteHeader(http.StatusTooManyRequests)
}

Composability

I intend this package to offer a set of basics for rate limiting, that you can compose into arbitrary logic, while being easy to reason about. In my experience, rate limiting gets complicated in production -- layered policies, dynamic policies, etc.

So, let’s make easy things easy and hard things possible.

One or many limits

You might wish to allow short spikes while preventing sustained load. So a Limiter can accept any number of Limit’s:

func byIP(req *http.Request) string {
    // You can put arbitrary logic in here. In this case, we’ll just use IP address.
    return req.RemoteAddr
}

perSecond := rate.NewLimit(10, time.Second)
perMinute := rate.NewLimit(100, time.Minute)

limiter := rate.NewLimiter(byIP, perSecond, perMinute)

The limiter.Allow() call checks both limits; all must allow or the request is denied. If denied, it will deduct no tokens from any limit.

One or many limiters
func byUser(req *http.Request) string {
    return getTheUserID(req)
}

userLimit := rate.NewLimit(100, time.Minute)
userLimiter := rate.NewLimiter(byUser, userLimit)

func byResource(req *http.Request) string {
    return req.Path
}

resourceLimit := rate.NewLimit(5, time.Second)
resourceLimiter := rate.NewLimiter(byResource, resourceLimit)

combined := rate.Combine(userLimiter, resourceLimiter)

// in your app, a single transactional allow call:

if combined.Allow(r)...

Dynamic limits

Dynamic == funcs.

// Dynamic based on customer

func byCustomerID(customerID int) int {
    return customerID
}

func getCustomerLimit(customerID int) Limit {
    plan := lookupCustomerPlan(customerID)
    return plan.Limit
}

limiter := rate.NewLimiterFunc(byCustomerID, getCustomerLimit)

// somewhere in the app:

customerID := getTheCustomerID()

if limiter.Allow(customerID) {
    ...do the thing
}
// Dynamic based on expense

// reads are cheap
readLimit := rate.NewLimit(50, time.Second)
// writes are expensive
writeLimit := rate.NewLimit(10, time.Second)

limitFunc := func(r *http.Request) Limit {
    if r.Method == "GET" {
        return readLimit
    }
    return writeLimit
}
limiter := rate.NewLimiterFunc(keyFunc, limitFunc)
Dynamic costs

// think of 100 as "a dollar"
limit := rate.NewLimit(100, time.Second)
limiter := rate.NewLimiter(keyFunc, limit)

// decide how many "cents" a given request costs
tokens := decideThePriceOfThisRequest()

if limiter.AllowN(customerID, tokens) {
    ...do the thing
}

Implementation details

We define “do the right thing” as “minimize surprise”. Whether we’ve achieved that is what I would like to hear from you.

Concurrent

Of course we need to handle concurrency. After all, a rate limiter is only important in contended circumstances. We’ve worked to make this correct and performant.

Transactional

For a soft definition of “transactional”. Tokens are only deducted when all limits pass, otherwise no tokens are deducted. I think this is the right semantics, but perhaps more importantly, it mitigates noisy-neighbor DOS attempts.

There is only one call to time.Now(), and all subsequent logic uses that time. Inspired by databases, where a transaction has a consistent snapshot view that applies throughout.

Efficient

You should usually see zero allocations. An Allow() call takes around 60ns on my machine. Here are some benchmarks of other Go rate limiters.

At scale, one might create millions of buckets, so we’ve minimized the data size of that struct.

I had the insight that the state of a bucket is completely expressed by a time field (in combination with a Limit). There is no token type or field. Calculating the available tokens is just arithmetic on time.

Inspectable

You’ll find Peek, and *WithDetails and *WithDebug methods, which give you the information you’ll need to return “retry after” or “remaining tokens” headers, or do detailed logging.

Generic

The Limiter type is generic. You'll define the type via the KeyFunc that you pass to NewLimiter. HTTP is the common case, but you can use whatever your app needs.

Roadmap

First and foremost, I want some feedback. Try it, open an issue, or ping me.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Debug added in v0.2.0

type Debug[TInput any, TKey comparable] struct {
	// contains filtered or unexported fields
}

Debug is a struct that contains the details of a rate limit check for a single bucket. Used by debug APIs that return per-bucket information.

func (Debug[TInput, TKey]) Allowed added in v0.2.0

func (d Debug[TInput, TKey]) Allowed() bool

Allowed returns true if the request was allowed.

func (Debug[TInput, TKey]) ExecutionTime added in v0.2.0

func (d Debug[TInput, TKey]) ExecutionTime() time.Time

ExecutionTime returns the time the request was executed.

func (Debug[TInput, TKey]) Input added in v0.2.0

func (d Debug[TInput, TKey]) Input() TInput

Input returns the input that was used to create the bucket.

func (Debug[TInput, TKey]) Key added in v0.2.0

func (d Debug[TInput, TKey]) Key() TKey

Key returns the key of the bucket that was used for the request.

func (Debug[TInput, TKey]) Limit added in v0.2.0

func (d Debug[TInput, TKey]) Limit() Limit

Limit returns the limit that was used for the request.

func (Debug[TInput, TKey]) RetryAfter added in v0.2.0

func (d Debug[TInput, TKey]) RetryAfter() time.Duration

RetryAfter returns the duration after which the bucket may have refilled.

func (Debug[TInput, TKey]) TokensConsumed added in v0.2.0

func (d Debug[TInput, TKey]) TokensConsumed() int64

TokensConsumed returns the number of tokens that were consumed for the request.

func (Debug[TInput, TKey]) TokensRemaining added in v0.2.0

func (d Debug[TInput, TKey]) TokensRemaining() int64

TokensRemaining returns the number of remaining tokens in the bucket

func (Debug[TInput, TKey]) TokensRequested added in v0.2.0

func (d Debug[TInput, TKey]) TokensRequested() int64

TokensRequested returns the number of tokens that were requested for the request.

type Details

type Details[TInput any, TKey comparable] struct {
	// contains filtered or unexported fields
}

Details contains aggregated rate limit details across multiple buckets/limits, optimized for the common use case of setting response headers without expensive allocations.

func (Details[TInput, TKey]) Allowed

func (d Details[TInput, TKey]) Allowed() bool

Allowed returns true if the request was allowed.

func (Details[TInput, TKey]) ExecutionTime

func (d Details[TInput, TKey]) ExecutionTime() time.Time

ExecutionTime returns the time the request was executed.

func (Details[TInput, TKey]) RetryAfter

func (d Details[TInput, TKey]) RetryAfter() time.Duration

RetryAfter returns the duration after which all relevant buckets may have refilled.

func (Details[TInput, TKey]) TokensConsumed

func (d Details[TInput, TKey]) TokensConsumed() int64

TokensConsumed returns the number of tokens that were consumed for the request.

func (Details[TInput, TKey]) TokensRemaining

func (d Details[TInput, TKey]) TokensRemaining() int64

TokensRemaining returns the minimum number of remaining tokens across all buckets. This represents "after using this many tokens, you will be denied".

func (Details[TInput, TKey]) TokensRequested

func (d Details[TInput, TKey]) TokensRequested() int64

TokensRequested returns the number of tokens that were requested for the request.

type KeyFunc added in v0.2.0

type KeyFunc[TInput any, TKey comparable] func(input TInput) TKey

KeyFunc is a function that takes an input and returns a bucket key.

type Limit

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

func NewLimit

func NewLimit(count int64, period time.Duration) Limit

NewLimit creates a new rate with the given count and period. For example, to create a rate of 10 requests per second, use:

limit := rate.NewLimit(10, time.Second)

func (Limit) Count

func (l Limit) Count() int64

func (Limit) DurationPerToken

func (l Limit) DurationPerToken() time.Duration

func (Limit) Period

func (l Limit) Period() time.Duration

func (Limit) String

func (l Limit) String() string

type LimitFunc

type LimitFunc[TInput any] func(input TInput) Limit

type Limiter

type Limiter[TInput any, TKey comparable] struct {
	// contains filtered or unexported fields
}

Limiter is a rate limiter that can be used to limit the rate of requests to a given key.

func NewLimiter

func NewLimiter[TInput any, TKey comparable](keyFunc KeyFunc[TInput, TKey], limits ...Limit) *Limiter[TInput, TKey]

NewLimiter creates a new rate limiter

func NewLimiterFunc

func NewLimiterFunc[TInput any, TKey comparable](keyFunc KeyFunc[TInput, TKey], limitFuncs ...LimitFunc[TInput]) *Limiter[TInput, TKey]

NewLimiterFunc creates a new rate limiter with a dynamic limit function. Use this if you wish to apply a different limit based on the input, for example by URL path. The LimitFunc takes the same input type as the Keyer function.

func (*Limiter[TInput, TKey]) Allow

func (r *Limiter[TInput, TKey]) Allow(input TInput) bool

Allow returns true if one or more tokens are available for the given key. If true, it will consume a token from the key's bucket. If false, no token will be consumed.

If the Limiter has multiple limits, Allow will return true only if all limits allow the request, and one token will be consumed against each limit. If any limit would be exceeded, no token will be consumed against any limit.

func (*Limiter[TInput, TKey]) AllowN

func (r *Limiter[TInput, TKey]) AllowN(input TInput, n int64) bool

AllowN returns true if at least `n` tokens are available for the given key. If true, it will consume `n` tokens. If false, no token will be consumed.

If the Limiter has multiple limits, AllowN will return true only if all limits allow the request, and `n` tokens will be consumed against each limit. If any limit would be exceeded, no token will be consumed against any limit.

func (*Limiter[TInput, TKey]) AllowNWithDebug

func (r *Limiter[TInput, TKey]) AllowNWithDebug(input TInput, n int64) (bool, []Debug[TInput, TKey])

AllowNWithDebug returns true if at least `n` tokens are available for the given key, along with detailed debugging information about all bucket(s), remaining tokens, etc. You might use these details for logging, debugging, etc.

Note: This method allocates and may be expensive for performance-critical paths. For setting response headers, consider using AllowNWithDetails instead.

If true, it will consume `n` tokens. If false, no token will be consumed.

If the Limiter has multiple limits, AllowNWithDebug will return true only if all limits allow the request, and `n` tokens will be consumed against each limit. If any limit would be exceeded, no token will be consumed against any limit.

func (*Limiter[TInput, TKey]) AllowNWithDetails

func (r *Limiter[TInput, TKey]) AllowNWithDetails(input TInput, n int64) (bool, Details[TInput, TKey])

AllowNWithDetails returns true if at least `n` tokens are available for the given key, along with details for setting response headers.

If true, it will consume `n` tokens. If false, no token will be consumed.

If the Limiter has multiple limits, AllowNWithDetails will return true only if all limits allow the request, and `n` tokens will be consumed against each limit. If any limit would be exceeded, no token will be consumed against any limit.

func (*Limiter[TInput, TKey]) AllowWithDebug

func (r *Limiter[TInput, TKey]) AllowWithDebug(input TInput) (bool, []Debug[TInput, TKey])

AllowWithDebug returns true if a token is available for the given key, along with detailed debugging information about all bucket(s) and tokens. You might use these details for logging, debugging, etc.

Note: This method allocates and may be expensive for performance-critical paths. For setting response headers, consider using AllowWithDetails instead.

If true, it will consume one token. If false, no token will be consumed.

If the Limiter has multiple limits, AllowWithDebug will return true only if all limits allow the request, and one token will be consumed against each limit. If any limit would be exceeded, no token will be consumed against any limit.

func (*Limiter[TInput, TKey]) AllowWithDetails

func (r *Limiter[TInput, TKey]) AllowWithDetails(input TInput) (bool, Details[TInput, TKey])

AllowWithDetails returns true if a token is available for the given key, along with details for setting response headers.

If true, it will consume one token. If false, no token will be consumed.

If the Limiter has multiple limits, AllowWithDetails will return true only if all limits allow the request, and one token will be consumed against each limit. If any limit would be exceeded, no token will be consumed against any limit.

func (*Limiter[TInput, TKey]) Peek

func (r *Limiter[TInput, TKey]) Peek(input TInput) bool

Peek returns true if tokens are available for the given key, but without consuming any tokens.

func (*Limiter[TInput, TKey]) PeekN

func (r *Limiter[TInput, TKey]) PeekN(input TInput, n int64) bool

PeekN returns true if tokens are available for the given key, but without consuming any tokens.

func (*Limiter[TInput, TKey]) PeekNWithDebug

func (r *Limiter[TInput, TKey]) PeekNWithDebug(input TInput, n int64) (bool, []Debug[TInput, TKey])

PeekNWithDebug returns true if `n` tokens are available for the given key, along with detailed debugging information about all bucket(s) and remaining tokens. You might use these details for logging, debugging, etc.

Note: This method allocates and may be expensive for performance-critical paths. For setting response headers, consider using PeekNWithDetails instead.

No tokens are consumed.

func (*Limiter[TInput, TKey]) PeekNWithDetails

func (r *Limiter[TInput, TKey]) PeekNWithDetails(input TInput, n int64) (bool, Details[TInput, TKey])

PeekNWithDetails returns true if `n` tokens are available for the given key, along with aggregated details optimized for setting response headers. This method avoids allocations and is suitable for performance-critical paths.

No tokens are consumed.

func (*Limiter[TInput, TKey]) PeekWithDebug

func (r *Limiter[TInput, TKey]) PeekWithDebug(input TInput) (bool, []Debug[TInput, TKey])

PeekWithDebug returns true if tokens are available for the given key, and detailed debugging information about all bucket(s) and the execution time. You might use these details for logging, debugging, etc.

Note: This method allocates and may be expensive for performance-critical paths. For setting response headers, consider using PeekWithDetails instead.

No tokens are consumed.

func (*Limiter[TInput, TKey]) PeekWithDetails

func (r *Limiter[TInput, TKey]) PeekWithDetails(input TInput) (bool, Details[TInput, TKey])

PeekWithDetails returns true if tokens are available for the given key, along with aggregated details optimized for setting response headers. This method avoids allocations and is suitable for performance-critical paths.

No tokens are consumed.

func (*Limiter[TInput, TKey]) Wait

func (r *Limiter[TInput, TKey]) Wait(ctx context.Context, input TInput) bool

Wait will poll [Allow] for a period of time, until it is cancelled by the passed context. It has the effect of adding latency to requests instead of refusing them immediately. Consider it graceful degradation.

Wait will return true if a token becomes available prior to the context cancellation, and will consume a token. It will return false if not, and therefore not consume a token.

Take care to create an appropriate context. You almost certainly want context.WithTimeout or context.WithDeadline.

You should be conservative, as Wait will introduce backpressure on your upstream systems -- connections may be held open longer, requests may queue in memory.

A good starting place will be to timeout after waiting for one token. For example:

ctx := context.WithTimeout(ctx, limit.DurationPerToken())

Wait offers best-effort FIFO ordering of requests. Under sustained contention (when multiple requests wait longer than ~1ms), the Go runtime's mutex starvation mode ensures strict FIFO ordering. Under light load, ordering may be less strict but performance is optimized.

func (*Limiter[TInput, TKey]) WaitN

func (r *Limiter[TInput, TKey]) WaitN(ctx context.Context, input TInput, n int64) bool

WaitN will poll Limiter.AllowN for a period of time, until it is cancelled by the passed context. It has the effect of adding latency to requests instead of refusing them immediately. Consider it graceful degradation.

WaitN will return true if `n` tokens become available prior to the context cancellation, and will consume `n` tokens. If not, it will return false, and therefore consume no tokens.

Take care to create an appropriate context. You almost certainly want context.WithTimeout or context.WithDeadline.

You should be conservative, as Wait will introduce backpressure on your upstream systems -- connections may be held open longer, requests may queue in memory.

A good starting place will be to timeout after waiting for one token. For example:

ctx := context.WithTimeout(ctx, limit.DurationPerToken())

WaitN offers best-effort FIFO ordering of requests. Under sustained contention (when multiple requests wait longer than ~1ms), the Go runtime's mutex starvation mode ensures strict FIFO ordering. Under light load, ordering may be less strict but performance is optimized.

type Limiters added in v0.2.0

type Limiters[TInput any, TKey comparable] struct {
	// contains filtered or unexported fields
}

Limiters is a collection of Limiter.

func Combine added in v0.2.0

func Combine[TInput any, TKey comparable](limiters ...*Limiter[TInput, TKey]) *Limiters[TInput, TKey]

Combine combines multiple Limiter into a single Limiters, which can be treated like a single with Allow, etc.

func (*Limiters[TInput, TKey]) Allow added in v0.2.0

func (rs *Limiters[TInput, TKey]) Allow(input TInput) bool

func (*Limiters[TInput, TKey]) AllowN added in v0.2.0

func (rs *Limiters[TInput, TKey]) AllowN(input TInput, n int64) bool

func (*Limiters[TInput, TKey]) AllowNWithDebug added in v0.2.0

func (rs *Limiters[TInput, TKey]) AllowNWithDebug(input TInput, n int64) (bool, []Debug[TInput, TKey])

func (*Limiters[TInput, TKey]) AllowNWithDetails added in v0.2.0

func (rs *Limiters[TInput, TKey]) AllowNWithDetails(input TInput, n int64) (bool, Details[TInput, TKey])

func (*Limiters[TInput, TKey]) AllowWithDebug added in v0.2.0

func (rs *Limiters[TInput, TKey]) AllowWithDebug(input TInput) (bool, []Debug[TInput, TKey])

func (*Limiters[TInput, TKey]) AllowWithDetails added in v0.2.0

func (rs *Limiters[TInput, TKey]) AllowWithDetails(input TInput) (bool, Details[TInput, TKey])

func (*Limiters[TInput, TKey]) Peek added in v0.2.0

func (rs *Limiters[TInput, TKey]) Peek(input TInput) bool

Peek returns true if tokens are available for the given input across all limiters, but without consuming any tokens.

func (*Limiters[TInput, TKey]) PeekN added in v0.2.0

func (rs *Limiters[TInput, TKey]) PeekN(input TInput, n int64) bool

PeekN returns true if `n` tokens are available for the given input across all limiters, but without consuming any tokens.

func (*Limiters[TInput, TKey]) PeekNWithDebug added in v0.2.0

func (rs *Limiters[TInput, TKey]) PeekNWithDebug(input TInput, n int64) (bool, []Debug[TInput, TKey])

PeekNWithDebug returns true if `n` tokens are available for the given input across all limiters, along with detailed debugging information about all bucket(s) and remaining tokens. You might use these details for logging, debugging, etc.

Note: This method allocates and may be expensive for performance-critical paths. For setting response headers, consider using PeekNWithDetails instead.

No tokens are consumed.

func (*Limiters[TInput, TKey]) PeekNWithDetails added in v0.2.0

func (rs *Limiters[TInput, TKey]) PeekNWithDetails(input TInput, n int64) (bool, Details[TInput, TKey])

PeekNWithDetails returns true if `n` tokens are available for the given input across all limiters, along with aggregated details optimized for setting response headers. This method avoids allocations and is suitable for performance-critical paths.

No tokens are consumed.

func (*Limiters[TInput, TKey]) PeekWithDebug added in v0.2.0

func (rs *Limiters[TInput, TKey]) PeekWithDebug(input TInput) (bool, []Debug[TInput, TKey])

PeekWithDebug returns true if tokens are available for the given input across all limiters, along with detailed debugging information about all bucket(s) and the execution time. You might use these details for logging, debugging, etc.

Note: This method allocates and may be expensive for performance-critical paths. For setting response headers, consider using PeekWithDetails instead.

No tokens are consumed.

func (*Limiters[TInput, TKey]) PeekWithDetails added in v0.2.0

func (rs *Limiters[TInput, TKey]) PeekWithDetails(input TInput) (bool, Details[TInput, TKey])

PeekWithDetails returns true if tokens are available for the given input across all limiters, along with aggregated details optimized for setting response headers. This method avoids allocations and is suitable for performance-critical paths.

No tokens are consumed.

Jump to

Keyboard shortcuts

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