309 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Go
		
	
	
	
			
		
		
	
	
			309 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Go
		
	
	
	
| package ratelimits
 | |
| 
 | |
| import (
 | |
| 	"context"
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"math"
 | |
| 	"slices"
 | |
| 	"time"
 | |
| 
 | |
| 	"github.com/jmhodges/clock"
 | |
| 	"github.com/prometheus/client_golang/prometheus"
 | |
| )
 | |
| 
 | |
| const (
 | |
| 	// Allowed is used for rate limit metrics, it's the value of the 'decision'
 | |
| 	// label when a request was allowed.
 | |
| 	Allowed = "allowed"
 | |
| 
 | |
| 	// Denied is used for rate limit metrics, it's the value of the 'decision'
 | |
| 	// label when a request was denied.
 | |
| 	Denied = "denied"
 | |
| )
 | |
| 
 | |
| // allowedDecision is an "allowed" *Decision that should be returned when a
 | |
| // checked limit is found to be disabled.
 | |
| var allowedDecision = &Decision{Allowed: true, Remaining: math.MaxInt64}
 | |
| 
 | |
| // Limiter provides a high-level interface for rate limiting requests by
 | |
| // utilizing a leaky bucket-style approach.
 | |
| type Limiter struct {
 | |
| 	// source is used to store buckets. It must be safe for concurrent use.
 | |
| 	source source
 | |
| 	clk    clock.Clock
 | |
| 
 | |
| 	spendLatency       *prometheus.HistogramVec
 | |
| 	overrideUsageGauge *prometheus.GaugeVec
 | |
| }
 | |
| 
 | |
| // NewLimiter returns a new *Limiter. The provided source must be safe for
 | |
| // concurrent use.
 | |
| func NewLimiter(clk clock.Clock, source source, stats prometheus.Registerer) (*Limiter, error) {
 | |
| 	limiter := &Limiter{source: source, clk: clk}
 | |
| 	limiter.spendLatency = prometheus.NewHistogramVec(prometheus.HistogramOpts{
 | |
| 		Name: "ratelimits_spend_latency",
 | |
| 		Help: fmt.Sprintf("Latency of ratelimit checks labeled by limit=[name] and decision=[%s|%s], in seconds", Allowed, Denied),
 | |
| 		// Exponential buckets ranging from 0.0005s to 3s.
 | |
| 		Buckets: prometheus.ExponentialBuckets(0.0005, 3, 8),
 | |
| 	}, []string{"limit", "decision"})
 | |
| 	stats.MustRegister(limiter.spendLatency)
 | |
| 
 | |
| 	limiter.overrideUsageGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{
 | |
| 		Name: "ratelimits_override_usage",
 | |
| 		Help: "Proportion of override limit used, by limit name and bucket key.",
 | |
| 	}, []string{"limit", "bucket_key"})
 | |
| 	stats.MustRegister(limiter.overrideUsageGauge)
 | |
| 
 | |
| 	return limiter, nil
 | |
| }
 | |
| 
 | |
| type Decision struct {
 | |
| 	// Allowed is true if the bucket possessed enough capacity to allow the
 | |
| 	// request given the cost.
 | |
| 	Allowed bool
 | |
| 
 | |
| 	// Remaining is the number of requests the client is allowed to make before
 | |
| 	// they're rate limited.
 | |
| 	Remaining int64
 | |
| 
 | |
| 	// RetryIn is the duration the client MUST wait before they're allowed to
 | |
| 	// make a request.
 | |
| 	RetryIn time.Duration
 | |
| 
 | |
| 	// ResetIn is the duration the bucket will take to refill to its maximum
 | |
| 	// capacity, assuming no further requests are made.
 | |
| 	ResetIn time.Duration
 | |
| 
 | |
| 	// newTAT indicates the time at which the bucket will be full. It is the
 | |
| 	// theoretical arrival time (TAT) of next request. It must be no more than
 | |
| 	// (burst * (period / count)) in the future at any single point in time.
 | |
| 	newTAT time.Time
 | |
| }
 | |
| 
 | |
| // Check DOES NOT deduct the cost of the request from the provided bucket's
 | |
| // capacity. The returned *Decision indicates whether the capacity exists to
 | |
| // satisfy the cost and represents the hypothetical state of the bucket IF the
 | |
| // cost WERE to be deducted. If no bucket exists it will NOT be created. No
 | |
| // state is persisted to the underlying datastore.
 | |
| func (l *Limiter) Check(ctx context.Context, txn Transaction) (*Decision, error) {
 | |
| 	if txn.allowOnly() {
 | |
| 		return allowedDecision, nil
 | |
| 	}
 | |
| 	// Remove cancellation from the request context so that transactions are not
 | |
| 	// interrupted by a client disconnect.
 | |
| 	ctx = context.WithoutCancel(ctx)
 | |
| 	tat, err := l.source.Get(ctx, txn.bucketKey)
 | |
| 	if err != nil {
 | |
| 		if !errors.Is(err, ErrBucketNotFound) {
 | |
| 			return nil, err
 | |
| 		}
 | |
| 		// First request from this client. No need to initialize the bucket
 | |
| 		// because this is a check, not a spend. A TAT of "now" is equivalent to
 | |
| 		// a full bucket.
 | |
| 		return maybeSpend(l.clk, txn.limit, l.clk.Now(), txn.cost), nil
 | |
| 	}
 | |
| 	return maybeSpend(l.clk, txn.limit, tat, txn.cost), nil
 | |
| }
 | |
| 
 | |
| // Spend attempts to deduct the cost from the provided bucket's capacity. The
 | |
| // returned *Decision indicates whether the capacity existed to satisfy the cost
 | |
| // and represents the current state of the bucket. If no bucket exists it WILL
 | |
| // be created WITH the cost factored into its initial state. The new bucket
 | |
| // state is persisted to the underlying datastore, if applicable, before
 | |
| // returning.
 | |
| func (l *Limiter) Spend(ctx context.Context, txn Transaction) (*Decision, error) {
 | |
| 	return l.BatchSpend(ctx, []Transaction{txn})
 | |
| }
 | |
| 
 | |
| func prepareBatch(txns []Transaction) ([]Transaction, []string, error) {
 | |
| 	var bucketKeys []string
 | |
| 	var transactions []Transaction
 | |
| 	for _, txn := range txns {
 | |
| 		if txn.allowOnly() {
 | |
| 			// Ignore allow-only transactions.
 | |
| 			continue
 | |
| 		}
 | |
| 		if slices.Contains(bucketKeys, txn.bucketKey) {
 | |
| 			return nil, nil, fmt.Errorf("found duplicate bucket %q in batch", txn.bucketKey)
 | |
| 		}
 | |
| 		bucketKeys = append(bucketKeys, txn.bucketKey)
 | |
| 		transactions = append(transactions, txn)
 | |
| 	}
 | |
| 	return transactions, bucketKeys, nil
 | |
| }
 | |
| 
 | |
| type batchDecision struct {
 | |
| 	*Decision
 | |
| }
 | |
| 
 | |
| func newBatchDecision() *batchDecision {
 | |
| 	return &batchDecision{
 | |
| 		Decision: &Decision{
 | |
| 			Allowed:   true,
 | |
| 			Remaining: math.MaxInt64,
 | |
| 		},
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func (d *batchDecision) merge(in *Decision) {
 | |
| 	d.Allowed = d.Allowed && in.Allowed
 | |
| 	d.Remaining = min(d.Remaining, in.Remaining)
 | |
| 	d.RetryIn = max(d.RetryIn, in.RetryIn)
 | |
| 	d.ResetIn = max(d.ResetIn, in.ResetIn)
 | |
| 	if in.newTAT.After(d.newTAT) {
 | |
| 		d.newTAT = in.newTAT
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // BatchSpend attempts to deduct the costs from the provided buckets'
 | |
| // capacities. If applicable, new bucket states are persisted to the underlying
 | |
| // datastore before returning. Non-existent buckets will be initialized WITH the
 | |
| // cost factored into the initial state. The following rules are applied to
 | |
| // merge the Decisions for each Transaction into a single batch Decision:
 | |
| //   - Allowed is true if all Transactions where check is true were allowed,
 | |
| //   - RetryIn and ResetIn are the largest values of each across all Decisions,
 | |
| //   - Remaining is the smallest value of each across all Decisions, and
 | |
| //   - Decisions resulting from spend-only Transactions are never merged.
 | |
| func (l *Limiter) BatchSpend(ctx context.Context, txns []Transaction) (*Decision, error) {
 | |
| 	batch, bucketKeys, err := prepareBatch(txns)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 	if len(batch) == 0 {
 | |
| 		// All Transactions were allow-only.
 | |
| 		return allowedDecision, nil
 | |
| 	}
 | |
| 
 | |
| 	// Remove cancellation from the request context so that transactions are not
 | |
| 	// interrupted by a client disconnect.
 | |
| 	ctx = context.WithoutCancel(ctx)
 | |
| 	tats, err := l.source.BatchGet(ctx, bucketKeys)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	start := l.clk.Now()
 | |
| 	batchDecision := newBatchDecision()
 | |
| 	newTATs := make(map[string]time.Time)
 | |
| 
 | |
| 	for _, txn := range batch {
 | |
| 		tat, exists := tats[txn.bucketKey]
 | |
| 		if !exists {
 | |
| 			// First request from this client.
 | |
| 			tat = l.clk.Now()
 | |
| 		}
 | |
| 
 | |
| 		d := maybeSpend(l.clk, txn.limit, tat, txn.cost)
 | |
| 
 | |
| 		if txn.limit.isOverride() {
 | |
| 			utilization := float64(txn.limit.Burst-d.Remaining) / float64(txn.limit.Burst)
 | |
| 			l.overrideUsageGauge.WithLabelValues(txn.limit.name.String(), txn.limit.overrideKey).Set(utilization)
 | |
| 		}
 | |
| 
 | |
| 		if d.Allowed && (tat != d.newTAT) && txn.spend {
 | |
| 			// New bucket state should be persisted.
 | |
| 			newTATs[txn.bucketKey] = d.newTAT
 | |
| 		}
 | |
| 
 | |
| 		if !txn.spendOnly() {
 | |
| 			batchDecision.merge(d)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	if batchDecision.Allowed {
 | |
| 		err = l.source.BatchSet(ctx, newTATs)
 | |
| 		if err != nil {
 | |
| 			return nil, err
 | |
| 		}
 | |
| 		l.spendLatency.WithLabelValues("batch", Allowed).Observe(l.clk.Since(start).Seconds())
 | |
| 	} else {
 | |
| 		l.spendLatency.WithLabelValues("batch", Denied).Observe(l.clk.Since(start).Seconds())
 | |
| 	}
 | |
| 	return batchDecision.Decision, nil
 | |
| }
 | |
| 
 | |
| // Refund attempts to refund all of the cost to the capacity of the specified
 | |
| // bucket. The returned *Decision indicates whether the refund was successful
 | |
| // and represents the current state of the bucket. The new bucket state is
 | |
| // persisted to the underlying datastore, if applicable, before returning. If no
 | |
| // bucket exists it will NOT be created. Spend-only Transactions are assumed to
 | |
| // be refundable. Check-only Transactions are never refunded.
 | |
| //
 | |
| // Note: The amount refunded cannot cause the bucket to exceed its maximum
 | |
| // capacity. Partial refunds are allowed and are considered successful. For
 | |
| // instance, if a bucket has a maximum capacity of 10 and currently has 5
 | |
| // requests remaining, a refund request of 7 will result in the bucket reaching
 | |
| // its maximum capacity of 10, not 12.
 | |
| func (l *Limiter) Refund(ctx context.Context, txn Transaction) (*Decision, error) {
 | |
| 	return l.BatchRefund(ctx, []Transaction{txn})
 | |
| }
 | |
| 
 | |
| // BatchRefund attempts to refund all or some of the costs to the provided
 | |
| // buckets' capacities. Non-existent buckets will NOT be initialized. The new
 | |
| // bucket state is persisted to the underlying datastore, if applicable, before
 | |
| // returning. Spend-only Transactions are assumed to be refundable. Check-only
 | |
| // Transactions are never refunded. The following rules are applied to merge the
 | |
| // Decisions for each Transaction into a single batch Decision:
 | |
| //   - Allowed is true if all Transactions where check is true were allowed,
 | |
| //   - RetryIn and ResetIn are the largest values of each across all Decisions,
 | |
| //   - Remaining is the smallest value of each across all Decisions, and
 | |
| //   - Decisions resulting from spend-only Transactions are never merged.
 | |
| func (l *Limiter) BatchRefund(ctx context.Context, txns []Transaction) (*Decision, error) {
 | |
| 	batch, bucketKeys, err := prepareBatch(txns)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 	if len(batch) == 0 {
 | |
| 		// All Transactions were allow-only.
 | |
| 		return allowedDecision, nil
 | |
| 	}
 | |
| 
 | |
| 	// Remove cancellation from the request context so that transactions are not
 | |
| 	// interrupted by a client disconnect.
 | |
| 	ctx = context.WithoutCancel(ctx)
 | |
| 	tats, err := l.source.BatchGet(ctx, bucketKeys)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	batchDecision := newBatchDecision()
 | |
| 	newTATs := make(map[string]time.Time)
 | |
| 
 | |
| 	for _, txn := range batch {
 | |
| 		tat, exists := tats[txn.bucketKey]
 | |
| 		if !exists {
 | |
| 			// Ignore non-existent bucket.
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		var cost int64
 | |
| 		if !txn.checkOnly() {
 | |
| 			cost = txn.cost
 | |
| 		}
 | |
| 		d := maybeRefund(l.clk, txn.limit, tat, cost)
 | |
| 		batchDecision.merge(d)
 | |
| 		if d.Allowed && tat != d.newTAT {
 | |
| 			// New bucket state should be persisted.
 | |
| 			newTATs[txn.bucketKey] = d.newTAT
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	if len(newTATs) > 0 {
 | |
| 		err = l.source.BatchSet(ctx, newTATs)
 | |
| 		if err != nil {
 | |
| 			return nil, err
 | |
| 		}
 | |
| 	}
 | |
| 	return batchDecision.Decision, nil
 | |
| }
 | |
| 
 | |
| // Reset resets the specified bucket to its maximum capacity. The new bucket
 | |
| // state is persisted to the underlying datastore before returning.
 | |
| func (l *Limiter) Reset(ctx context.Context, bucketKey string) error {
 | |
| 	// Remove cancellation from the request context so that transactions are not
 | |
| 	// interrupted by a client disconnect.
 | |
| 	ctx = context.WithoutCancel(ctx)
 | |
| 	return l.source.Delete(ctx, bucketKey)
 | |
| }
 |