From c7fc56cff3f01877a7b4899e19b631ebd4fbb6c7 Mon Sep 17 00:00:00 2001 From: Brent Schroeter Date: Mon, 10 Mar 2025 14:52:02 -0700 Subject: [PATCH] implement pg-backed governors for rate limiting --- .../2025-03-09-042820_init_governors/down.sql | 2 + .../2025-03-09-042820_init_governors/up.sql | 16 ++ src/app_error.rs | 10 + src/governors.rs | 175 ++++++++++++++++++ src/main.rs | 3 + src/schema.rs | 24 +++ src/v0_router.rs | 53 +++++- src/worker.rs | 19 +- 8 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 migrations/2025-03-09-042820_init_governors/down.sql create mode 100644 migrations/2025-03-09-042820_init_governors/up.sql create mode 100644 src/governors.rs diff --git a/migrations/2025-03-09-042820_init_governors/down.sql b/migrations/2025-03-09-042820_init_governors/down.sql new file mode 100644 index 0000000..0b2e310 --- /dev/null +++ b/migrations/2025-03-09-042820_init_governors/down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS governor_entries; +DROP TABLE IF EXISTS governors; diff --git a/migrations/2025-03-09-042820_init_governors/up.sql b/migrations/2025-03-09-042820_init_governors/up.sql new file mode 100644 index 0000000..c70abc1 --- /dev/null +++ b/migrations/2025-03-09-042820_init_governors/up.sql @@ -0,0 +1,16 @@ +CREATE TABLE governors ( + id UUID PRIMARY KEY NOT NULL, + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + project_id UUID REFERENCES projects(id) ON DELETE CASCADE, + window_size INTERVAL NOT NULL DEFAULT '1 hour', + max_count INT NOT NULL, + -- incremented when an entry is created; decremented when an entry expires + rolling_count INT NOT NULL DEFAULT 0 +); + +CREATE TABLE governor_entries ( + id UUID PRIMARY KEY NOT NULL, + governor_id UUID NOT NULL REFERENCES governors(id) ON DELETE CASCADE, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX ON governor_entries(timestamp); diff --git a/src/app_error.rs b/src/app_error.rs index 7205ca0..c9f1d93 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -16,6 +16,7 @@ pub enum AppError { ForbiddenError(String), NotFoundError(String), BadRequestError(String), + TooManyRequestsError(String), AuthRedirect(AuthRedirectInfo), } @@ -45,6 +46,12 @@ impl IntoResponse for AppError { tracing::info!("Not found: {}", client_message); (StatusCode::NOT_FOUND, client_message).into_response() } + Self::TooManyRequestsError(client_message) => { + // Debug level so that if this is from a runaway loop, it won't + // overwhelm server logs + tracing::debug!("Too many requests: {}", client_message); + (StatusCode::TOO_MANY_REQUESTS, client_message).into_response() + } Self::BadRequestError(client_message) => { tracing::info!("Bad user input: {}", client_message); (StatusCode::BAD_REQUEST, client_message).into_response() @@ -78,6 +85,9 @@ impl Display for AppError { AppError::BadRequestError(client_message) => { write!(f, "BadRequestError: {}", client_message) } + AppError::TooManyRequestsError(client_message) => { + write!(f, "TooManyRequestsError: {}", client_message) + } } } } diff --git a/src/governors.rs b/src/governors.rs new file mode 100644 index 0000000..c1b7569 --- /dev/null +++ b/src/governors.rs @@ -0,0 +1,175 @@ +// Fault tolerant rate limiting backed by Postgres. + +use anyhow::Result; +use chrono::{DateTime, TimeDelta, Utc}; +use diesel::{ + dsl::{auto_type, AsSelect}, + pg::Pg, + prelude::*, + sql_types::Timestamptz, +}; +use uuid::Uuid; + +use crate::schema::{governor_entries, governors}; + +define_sql_function! { + fn greatest(a: diesel::sql_types::Integer, b: diesel::sql_types::Integer) -> Integer +} + +#[derive(Clone, Debug, Identifiable, Insertable, Queryable, Selectable)] +#[diesel(table_name = governors)] +pub struct Governor { + pub id: Uuid, + pub team_id: Uuid, + pub project_id: Option, + pub window_size: TimeDelta, + pub max_count: i32, + pub rolling_count: i32, +} + +impl Governor { + #[auto_type(no_type_alias)] + pub fn all() -> _ { + let select: AsSelect = Governor::as_select(); + governors::table.select(select) + } + + #[auto_type(no_type_alias)] + pub fn with_id(governor_id: Uuid) -> _ { + governors::id.eq(governor_id) + } + + #[auto_type(no_type_alias)] + pub fn with_team(team_id: Uuid) -> _ { + governors::team_id.eq(team_id) + } + + #[auto_type(no_type_alias)] + pub fn with_project(project_id: Option) -> _ { + governors::project_id.is_not_distinct_from(project_id) + } + + // TODO: return a custom result enum instead of a Result