Documentation
¶
Overview ¶
Package http adapts ratelimiting to inbound HTTP.
It is a routing.Middleware that spends a token per request and answers 429 when there is none — the inbound counterpart to httpclient.WithRateLimit, over the same ratelimiting.RateLimiter, so one configured limiter governs both directions.
limiter, err := ratelimitingcfg.NewRateLimiter(ctx, cfg.RateLimiting)
if err != nil {
return err
}
mw, err := ratelimitinghttp.NewMiddleware(limiter,
ratelimitinghttp.FirstNonEmpty(
keyByPrincipal, // yours
ratelimitinghttp.KeyByHeader("X-API-Key"),
ratelimitinghttp.KeyByRemoteAddr(),
),
ratelimitinghttp.WithMetricsProvider(pillars.Metrics),
)
if err != nil {
return err
}
router.Use(mw)
It never reads the request body, so a global Router.Use costs upload routes nothing — unlike the idempotency middleware, which is documented as per-route for exactly that reason. Install it per route with routing.WithMiddleware when one endpoint is far more expensive than the rest and deserves its own budget.
Keying ¶
The key is what "N per second per what" resolves to, and there is no default because the wrong answer is worse than no limiter at all. Keying an authenticated API on addresses pools a whole office behind one bucket; keying a public one on a client-supplied header hands out a fresh bucket per request.
KeyByRemoteAddr is the only extractor that is safe with nothing in front of the server. Behind a proxy, KeyByForwardedFor(n) reads the client address out of X-Forwarded-For, counting n trusted appending hops from the right — a CDN in front of a load balancer is two, not one. KeyByHeader hashes what it reads, because a limiter key becomes a Redis key and an API key that lands in a keyspace has been disclosed.
A KeyFunc that returns "" exempts the request. That is how a route says it is counted somewhere else rather than twice.
Retry-After ¶
A refusal without a hint relocates load rather than shedding it: every refused client picks its own interval, and clients that guess tend to guess alike. So the middleware asks the limiter when to come back, via ratelimiting.RetryHinter — the in-memory and Redis limiters both implement it — and falls back to DefaultRetryAfter for the limiters that cannot answer. WithoutFallbackRetryAfter sends nothing rather than a guess.
When the limiter cannot answer ¶
Redis unreachable, or a key extractor that failed, is a fault in the guard rather than a verdict from it. By default those requests are let through and counted in ratelimiting_http_errors: failing closed would turn one dependency's bad minute into a total outage of the thing being guarded. WithFailClosed inverts that for endpoints where admitting everyone is the worse failure.
The wire shape ¶
Refusals render through routing.DefaultErrorBody: the platform APIError envelope with code E116, exactly what the Router produces for a handler that returned ratelimiting.ErrRateLimited. A service that replaced that envelope passes its own encoder to WithErrorEncoder — the same one it gave the Router — so a 429 arrives in the shape its clients already parse.
Index ¶
- Constants
- Variables
- func NewMiddleware(limiter ratelimiting.RateLimiter, keyFn KeyFunc, opts ...Option) (routing.Middleware, error)
- type KeyFunc
- type Option
- func WithErrorEncoder(encoder routing.ErrorEncoder) Option
- func WithFailClosed() Option
- func WithLogger(logger logging.Logger) Option
- func WithMetricsProvider(metricsProvider metrics.Provider) Option
- func WithRetryAfter(after time.Duration) Option
- func WithTracerProvider(tracerProvider tracing.Provider) Option
- func WithoutFallbackRetryAfter() Option
Examples ¶
Constants ¶
const ( // RetryAfterHeader is the response header carrying the refusal's retry // hint, in whole seconds. RetryAfterHeader = "Retry-After" // DefaultRetryAfter is the hint sent when the limiter cannot compute one. // It is short on purpose: a client that comes back too early is refused // again and told a better number, whereas one parked too long has had // capacity taken from it that was never actually scarce. DefaultRetryAfter = time.Second )
Variables ¶
var ErrNilKeyFunc = platformerrors.New("nil rate limit key function for the HTTP middleware")
ErrNilKeyFunc indicates NewMiddleware was called without a key function.
var ErrNilLimiter = platformerrors.New("nil rate limiter for the HTTP middleware")
ErrNilLimiter indicates NewMiddleware was called without a limiter.
Functions ¶
func NewMiddleware ¶
func NewMiddleware(limiter ratelimiting.RateLimiter, keyFn KeyFunc, opts ...Option) (routing.Middleware, error)
NewMiddleware builds middleware that spends a token per request and answers 429 when there is none.
It is the inbound half of what httpclient.WithRateLimit does outbound, over the same ratelimiting.RateLimiter — so a service can be configured with one limiter provider and have both directions obey it.
keyFn decides what the limit is per: principal, API key, address. There is no default, because the wrong one is worse than none. Keying an authenticated API on addresses pools an entire office behind one bucket; keying a public one on a header the client controls hands out a fresh bucket per request. See KeyByRemoteAddr and its neighbors.
Install it globally with Router.Use to protect the whole surface, or per route with routing.WithMiddleware where one endpoint is dramatically more expensive than the rest. Unlike the idempotency middleware it never reads the request body, so a global install costs upload routes nothing.
Example ¶
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"github.com/primandproper/primitives-go/ratelimiting"
ratelimitinghttp "github.com/primandproper/primitives-go/ratelimiting/http"
)
func main() {
// One request per second, no burst, so the second request in a row is
// refused and the limiter can say when to come back.
limiter, err := ratelimiting.NewInMemoryRateLimiter(1, 1)
if err != nil {
panic(err)
}
defer limiter.Close()
mw, err := ratelimitinghttp.NewMiddleware(limiter,
ratelimitinghttp.FirstNonEmpty(
ratelimitinghttp.KeyByHeader("X-API-Key"),
ratelimitinghttp.KeyByRemoteAddr(),
),
)
if err != nil {
panic(err)
}
guarded := mw(http.HandlerFunc(func(res http.ResponseWriter, _ *http.Request) {
res.WriteHeader(http.StatusOK)
}))
send := func() (int, string) {
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/things", http.NoBody)
req.Header.Set("X-API-Key", "sk_example")
rec := httptest.NewRecorder()
guarded.ServeHTTP(rec, req)
return rec.Code, rec.Header().Get(ratelimitinghttp.RetryAfterHeader)
}
status, _ := send()
fmt.Println(status)
status, retryAfter := send()
fmt.Println(status, retryAfter)
}
Output: 200 429 1
Types ¶
type KeyFunc ¶
KeyFunc extracts the key a request is counted against — the answer to "N per second per what". It is pluggable because the platform has no notion of a caller to read one from: a service's principal lives wherever its own authentication middleware put it.
Returning an empty key with a nil error exempts the request. That is the intended way to say "this one is limited somewhere else": an authenticated request whose principal-keyed limiter runs on an inner route, say, rather than being counted twice.
An error is treated as a failure of the guard, not a verdict from it, and resolves the same way an unreachable limiter does — see WithFailClosed.
func FirstNonEmpty ¶
FirstNonEmpty tries each KeyFunc in order and returns the first non-empty key.
It is how a service expresses a fallback ladder — count an authenticated caller by principal, an API client by its key, and everyone else by address:
FirstNonEmpty(keyByPrincipal, KeyByHeader("X-API-Key"), KeyByRemoteAddr())
An error from any extractor stops the ladder and is returned. An extractor that fails has not established that the caller is unidentified, so falling through to a broader key would quietly downgrade the limit for exactly the requests something already went wrong on.
func KeyByForwardedFor ¶
KeyByForwardedFor keys on the client address recorded in X-Forwarded-For, for a server behind trustedProxies proxies that each append to it.
trustedProxies is a count, not a list, and getting it right is the whole security of this extractor. Each proxy in the chain appends the address it received the request from, so the rightmost trustedProxies entries were written by infrastructure you control and the one before them is the client. A client that sends its own X-Forwarded-For only pushes its forged entries further left, where this never looks.
Count every hop that appends: a CDN in front of a load balancer is two, not one. Counting too high reads an entry the client wrote and hands it the ability to mint a fresh bucket per request; counting too low pools everyone behind one proxy into a single bucket. A header with fewer entries than expected falls back to the connection's own address rather than guessing.
trustedProxies below 1 is a misconfiguration — there is no proxy to trust — and is treated as 1.
func KeyByHeader ¶
KeyByHeader keys on a request header — an API key, a tenant ID, whatever the service issues to identify a caller.
The value is hashed, not used verbatim. A limiter key travels: it becomes a Redis key, and it reaches spans and logs on the way. An API key is a credential, and a credential that ends up in a keyspace someone else can list has been disclosed. The hash keys just as well, since a limiter only needs two requests from the same caller to land on the same string.
A request without the header is exempted rather than pooled under one empty key, which would count every anonymous caller against a single bucket. Compose with FirstNonEmpty to fall through to an address instead.
func KeyByRemoteAddr ¶
func KeyByRemoteAddr() KeyFunc
KeyByRemoteAddr keys on the address the connection actually came from, ignoring every forwarding header.
This is the safe default, and the only one that is safe with nothing in front of the server: X-Forwarded-For is written by the client on a direct connection, so a limiter keyed on it can be defeated by sending a different value each request — which is precisely the traffic a limiter exists to catch. Use KeyByForwardedFor only behind a proxy you control.
type Option ¶
type Option func(*config)
Option configures the middleware.
func WithErrorEncoder ¶
func WithErrorEncoder(encoder routing.ErrorEncoder) Option
WithErrorEncoder renders the refusal the way the service renders every other error. Pass the same routing.ErrorEncoder the Router was built with: a service that replaced the platform envelope did so because its clients parse something else, and a 429 that arrives in a shape they cannot parse is a refusal they cannot act on.
Without it the platform APIError envelope is used, which is what the Router itself produces for a handler that returned ratelimiting.ErrRateLimited. Both paths run through routing.DefaultErrorBody, so the two cannot drift.
func WithFailClosed ¶
func WithFailClosed() Option
WithFailClosed refuses requests the limiter could not rule on, instead of letting them through.
The default is the other way round. A limiter that cannot answer — Redis unreachable, a key extractor that failed — is a fault in a guard, not a verdict from it, and failing closed turns a dependency's bad minute into a total outage of the thing being guarded. The refusals are counted and logged at error either way, so an operator sees the fault rather than inferring it from traffic.
Use this where the limiter is the only thing standing between an endpoint and abuse it cannot absorb — a login route, an expensive unauthenticated endpoint — and where refusing everyone is genuinely better than admitting everyone.
func WithMetricsProvider ¶
WithMetricsProvider attaches a metrics provider, enabling the middleware's allowed, refused, and error counters.
func WithRetryAfter ¶
WithRetryAfter sets the hint sent when the limiter volunteers none.
A limiter that implements ratelimiting.RetryHinter answers for itself and this value is never used for it; the in-memory and Redis limiters both do. It is the fallback for the ones that cannot — and for a limiter that has no estimate for the key in front of it.
A non-positive duration leaves DefaultRetryAfter in place. To suppress the header entirely for unhinted refusals, use WithoutFallbackRetryAfter.
func WithTracerProvider ¶
WithTracerProvider attaches a tracer provider.
func WithoutFallbackRetryAfter ¶
func WithoutFallbackRetryAfter() Option
WithoutFallbackRetryAfter suppresses Retry-After on refusals the limiter could not put a number to, rather than sending the fallback.
Reach for it when clients treat the header as authoritative and a guess would mislead them more than silence would.