- Go 91.2%
- PLpgSQL 8.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .github/workflows | ||
| examples | ||
| sql | ||
| .gitignore | ||
| doc.go | ||
| docker-compose.yml | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| limiter.go | ||
| limiter_integration_test.go | ||
| limiter_test.go | ||
| README.md | ||
| schema.go | ||
| Taskfile.yml | ||
ratelimiter-pg
github.com/ralscha/ratelimiter-pg is a PostgreSQL-backed token-bucket rate-limiting library for Go.
It stores bucket state in PostgreSQL and evaluates each request with one stored function call.
Install
go get github.com/ralscha/ratelimiter-pg
Requirements
- Go 1.27.1 or newer, matching the module's
godirective. - PostgreSQL 18 or newer with PL/pgSQL enabled.
Quick start
Call Init once during application startup. It is the library's single bootstrap method and prepares the schema for use.
package main
import (
"context"
"log"
"time"
"github.com/jackc/pgx/v5/pgxpool"
ratelimit "github.com/ralscha/ratelimiter-pg"
)
func main() {
ctx := context.Background()
db, err := pgxpool.New(ctx, "postgres://user:pass@localhost:5432/app?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
limiter := ratelimit.New(db, "public", ratelimit.BucketConfig{
Capacity: 5,
RefillPerSecond: 1.0 / 60.0,
CostPerRequest: 1,
DenyRetryFloor: time.Second,
})
if err := limiter.Init(ctx); err != nil {
log.Fatal(err)
}
decision, err := limiter.Allow(ctx, "login:user:alice")
if err != nil {
log.Fatal(err)
}
log.Printf("allowed=%t tokens_left=%.2f retry_after=%s", decision.Allowed, decision.TokensLeft, decision.RetryAfter)
}
When one request needs different settings than the limiter default, call AllowWithConfig:
decision, err := limiter.AllowWithConfig(ctx, "login:user:alice", ratelimit.BucketConfig{
Capacity: 10,
RefillPerSecond: 1,
CostPerRequest: 2,
DenyRetryFloor: time.Second,
})
Minimal request flow:
- Open a PostgreSQL connection pool.
- Construct
RateLimiterwith the pool and default bucket config. LeaveSchemaempty to usepublic, or set it to target a different schema. - Call
Initonce during startup. - Call
Allowfor each key you want to throttle, orAllowWithConfigwhen one call needs a different bucket config.
Public API
Newconstructs aRateLimiterwith a PostgreSQL pool, target schema, and default bucket config.RateLimiterholds the PostgreSQL pool, target schema, and default bucket config. An emptySchemavalue defaults topublic.RateLimiter.DefaultConfigis the bucket config used by(*RateLimiter).Allow.BucketConfigdefines capacity, refill rate, cost, and deny retry floor. CallBucketConfig.Validateto validate one before use. Floating-point fields must be finite and within PostgreSQL's supported positive range,CostPerRequestmust not exceedCapacity, andDenyRetryFloormust fit the whole-millisecondtime.Durationrange.Decisionreports whether a request was allowed, how many tokens remain, and when to retry.(*RateLimiter).Initprepares the limiter for use.(*RateLimiter).Allowevaluates one key with the limiter's default bucket config. It trims leading and trailing whitespace from the key and rejects an empty result.(*RateLimiter).AllowWithConfigevaluates one key with a call-specific bucket config override.(*RateLimiter).DeleteBucketdeletes one key's state, so its next request starts with a full bucket.(*RateLimiter).DeleteStaleBucketsdeletes untouched buckets older than a TTL.- Exported sentinel errors such as
ErrInvalidBucketConfig,ErrEmptyBucketKey, andErrSchemaTooNewcan be inspected witherrors.Is.
Schema management
Init is the only schema/bootstrap method exposed by the library.
(*RateLimiter).Initchecks the current schema state and applies pending migrations when needed.- Concurrent
Initcalls for the same schema are serialized with a transaction-scoped PostgreSQL advisory lock. - On a fresh database,
Initcreates the limiter objects and installs the embedded schema. - On an existing but outdated database,
Initupgrades the limiter schema to the version required by the library. - If the database schema version is newer than the library supports,
Initreturns an error instead of downgrading or modifying it. - On a database that is already current,
Initreturns without applying changes. - Set
RateLimiter.Schemawhen you want the limiter objects in a schema other thanpublic.
Examples
Runnable examples live under examples/:
examples/basicshows the smallest end-to-end limiter setup.examples/http-loginshows a login endpoint that returnsRetry-Afterwhen throttled.examples/cleanupshows how to delete stale bucket rows. Like the other examples, it still callsInitduring startup.
All examples use these environment variables when present:
DATABASE_URLfor the PostgreSQL connection string.DB_SCHEMAfor a non-default schema name.LISTEN_ADDRfor the HTTP example.STALE_TTLfor the cleanup example.
If unset, the examples default to the PostgreSQL settings from docker-compose.yml:
DATABASE_URL=postgres://ratelimit:ratelimit@localhost:5432/ratelimit?sslmode=disableDB_SCHEMA=publicLISTEN_ADDR=:8080STALE_TTL=24h
Run them with:
go run ./examples/basic
go run ./examples/http-login
go run ./examples/cleanup
Development
Common commands:
go fmt ./...
go vet ./...
go test ./...
With Task installed, the same checks are available as:
task format
task vet
task test
Use task pg-up to start the local PostgreSQL container used by the examples, and task pg-reset when you want a clean database volume.
Key design
The limiter is generic. It accepts any non-empty string key chosen by the caller.
Examples:
login:user:alice
endpoint:read_issues
db:read_table_query
tenant:acme:write
The library does not interpret key structure or normalize case. It only trims leading and trailing whitespace.
That makes it suitable for per-user login throttling, per-tenant quotas, per-endpoint limits, or any other string-addressable bucket strategy chosen by the application.
How it works
(*RateLimiter).Allow validates RateLimiter.DefaultConfig, trims leading and trailing whitespace from the key, and then calls the PostgreSQL function check_rate_limit.
Use (*RateLimiter).AllowWithConfig when a specific request should override that default configuration.
That function replenishes tokens lazily from elapsed time and atomically applies the allow-or-deny decision while holding a row lock for the bucket. It uses higher-precision intermediate arithmetic to avoid PostgreSQL floating-point underflow and caps retry values at the largest whole-millisecond time.Duration.
The Go call still makes one database round trip. Competing requests for the same bucket serialize inside PostgreSQL instead of relying on in-process memory or distributed locks, and unrelated bucket keys do not block each other.
For denied requests it computes:
retry_ms = ceil((cost_per_request - replenished) / refill_per_second * 1000)
The deny retry floor is rounded up to whole milliseconds and then applied so very small retry values still surface as a visible delay.
Database objects
Init creates these objects in the configured schema:
CREATE TABLE public.rate_limit_schema_migrations (
version BIGINT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT statement_timestamp()
);
CREATE TABLE public.rate_limit_buckets (
bucket_key TEXT PRIMARY KEY,
tokens DOUBLE PRECISION NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_rlb_updated_at ON public.rate_limit_buckets (updated_at);
CREATE OR REPLACE FUNCTION public.check_rate_limit(...)
The rate_limit_schema_migrations table records which embedded migrations have been applied.
The updated_at index supports DeleteStaleBuckets, which removes rows that have not been touched for a configurable TTL.
Status codes and retries
The library is transport-agnostic. It returns a Decision with Allowed, TokensLeft, and RetryAfter, and the caller decides how that maps to HTTP responses, gRPC errors, CLI behavior, or background job scheduling.