← back to Stayclaim

db/migrations/023_rate_limit.sql

48 lines

-- Postgres-backed token bucket rate limiter. No Redis dependency.
--
-- Bucket per (ip, scope). Window resets when the stored window_start is
-- older than now() - p_window_seconds. Caller compares the returned count
-- against its own per-scope ceiling and 429s if over.
--
-- Designed to fail-OPEN: if PG is unavailable, callers should let the
-- request through rather than 503 the public archive. PG-down is surfaced
-- via /admin or pm2 logs separately.
--
-- Sweep job recommended hourly:
--   DELETE FROM rate_limit_bucket WHERE window_start < now() - interval '24 hours';

CREATE TABLE IF NOT EXISTS rate_limit_bucket (
  ip           inet         NOT NULL,
  scope        text         NOT NULL,
  window_start timestamptz  NOT NULL,
  count        int          NOT NULL DEFAULT 0,
  PRIMARY KEY (ip, scope)
);

CREATE INDEX IF NOT EXISTS rate_limit_bucket_window_idx
  ON rate_limit_bucket (window_start);

CREATE OR REPLACE FUNCTION rate_limit_hit(
  p_ip inet,
  p_scope text,
  p_window_seconds int
) RETURNS int LANGUAGE plpgsql AS $$
DECLARE v_count int;
BEGIN
  INSERT INTO rate_limit_bucket (ip, scope, window_start, count)
  VALUES (p_ip, p_scope, now(), 1)
  ON CONFLICT (ip, scope) DO UPDATE
    SET window_start = CASE
          WHEN rate_limit_bucket.window_start < now() - (p_window_seconds || ' seconds')::interval
            THEN now()
          ELSE rate_limit_bucket.window_start
        END,
        count = CASE
          WHEN rate_limit_bucket.window_start < now() - (p_window_seconds || ' seconds')::interval
            THEN 1
          ELSE rate_limit_bucket.count + 1
        END
  RETURNING count INTO v_count;
  RETURN v_count;
END $$;