PostgreSQL-backed token-bucket rate-limiting library for Go.
  • Go 91.2%
  • PLpgSQL 8.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Ralph Schaer 6dc3cc70c1 upgrade
2026-09-12 13:48:06 +02:00
.github/workflows upgrade 2026-07-27 05:54:26 +02:00
examples refactor: update RateLimiter to use default bucket config and simplify Allow method 2026-03-09 14:39:36 +01:00
sql Harden rate limiter and simplify schema setup 2026-09-05 06:09:18 +02:00
.gitignore initial commit 2026-03-09 14:17:04 +01:00
doc.go initial commit 2026-03-09 14:17:04 +01:00
docker-compose.yml initial commit 2026-03-09 14:17:04 +01:00
go.mod upgrade 2026-09-12 13:48:06 +02:00
go.sum upgrade 2026-09-12 13:48:06 +02:00
LICENSE initial commit 2026-03-09 14:17:04 +01:00
limiter.go Harden rate limiter and simplify schema setup 2026-09-05 06:09:18 +02:00
limiter_integration_test.go Harden rate limiter and simplify schema setup 2026-09-05 06:09:18 +02:00
limiter_test.go Harden rate limiter and simplify schema setup 2026-09-05 06:09:18 +02:00
README.md Harden rate limiter and simplify schema setup 2026-09-05 06:09:18 +02:00
schema.go Harden rate limiter and simplify schema setup 2026-09-05 06:09:18 +02:00
Taskfile.yml upgrade 2026-09-03 20:39:10 +02:00

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 go directive.
  • 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:

  1. Open a PostgreSQL connection pool.
  2. Construct RateLimiter with the pool and default bucket config. Leave Schema empty to use public, or set it to target a different schema.
  3. Call Init once during startup.
  4. Call Allow for each key you want to throttle, or AllowWithConfig when one call needs a different bucket config.

Public API

  • New constructs a RateLimiter with a PostgreSQL pool, target schema, and default bucket config.
  • RateLimiter holds the PostgreSQL pool, target schema, and default bucket config. An empty Schema value defaults to public.
  • RateLimiter.DefaultConfig is the bucket config used by (*RateLimiter).Allow.
  • BucketConfig defines capacity, refill rate, cost, and deny retry floor. Call BucketConfig.Validate to validate one before use. Floating-point fields must be finite and within PostgreSQL's supported positive range, CostPerRequest must not exceed Capacity, and DenyRetryFloor must fit the whole-millisecond time.Duration range.
  • Decision reports whether a request was allowed, how many tokens remain, and when to retry.
  • (*RateLimiter).Init prepares the limiter for use.
  • (*RateLimiter).Allow evaluates one key with the limiter's default bucket config. It trims leading and trailing whitespace from the key and rejects an empty result.
  • (*RateLimiter).AllowWithConfig evaluates one key with a call-specific bucket config override.
  • (*RateLimiter).DeleteBucket deletes one key's state, so its next request starts with a full bucket.
  • (*RateLimiter).DeleteStaleBuckets deletes untouched buckets older than a TTL.
  • Exported sentinel errors such as ErrInvalidBucketConfig, ErrEmptyBucketKey, and ErrSchemaTooNew can be inspected with errors.Is.

Schema management

Init is the only schema/bootstrap method exposed by the library.

  • (*RateLimiter).Init checks the current schema state and applies pending migrations when needed.
  • Concurrent Init calls for the same schema are serialized with a transaction-scoped PostgreSQL advisory lock.
  • On a fresh database, Init creates the limiter objects and installs the embedded schema.
  • On an existing but outdated database, Init upgrades the limiter schema to the version required by the library.
  • If the database schema version is newer than the library supports, Init returns an error instead of downgrading or modifying it.
  • On a database that is already current, Init returns without applying changes.
  • Set RateLimiter.Schema when you want the limiter objects in a schema other than public.

Examples

Runnable examples live under examples/:

  • examples/basic shows the smallest end-to-end limiter setup.
  • examples/http-login shows a login endpoint that returns Retry-After when throttled.
  • examples/cleanup shows how to delete stale bucket rows. Like the other examples, it still calls Init during startup.

All examples use these environment variables when present:

  • DATABASE_URL for the PostgreSQL connection string.
  • DB_SCHEMA for a non-default schema name.
  • LISTEN_ADDR for the HTTP example.
  • STALE_TTL for 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=disable
  • DB_SCHEMA=public
  • LISTEN_ADDR=:8080
  • STALE_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.