Request Rate

api

Request Rate

Definition

Request rate measures the volume of incoming requests per unit of time, typically expressed as requests per second (RPS) or requests per minute (RPM). It is a primary throughput metric that drives capacity planning, auto-scaling decisions, rate limiting policies, and performance monitoring. A sudden spike in request rate may indicate legitimate traffic growth, a viral event, a DDoS attack, or a misconfigured client in a retry loop.

Understanding request rate patterns -- baseline, peak, seasonal, and anomalous -- is essential for operating reliable systems. Rate limiting, the enforcement side of request rate management, protects backend systems from overload by capping the number of requests a client can make within a time window. Without rate limiting, a single misbehaving client can exhaust database connections, consume all worker processes, and degrade service for all other users.

The Prismatic Platform monitors request rates across all entry points: the Phoenix web application (port 4000), the API gateway (port 4004), internal GenServer call rates, and OSINT tool execution rates against external intelligence APIs. Rate limiting is enforced at multiple layers -- Plug middleware for HTTP endpoints, GenServer-based token buckets for internal services, and per-tool rate limiters for OSINT operations that must respect external API quotas.

Core Concepts

Rate Limiting Algorithm Comparison

AlgorithmBurst ToleranceMemory UsageAccuracyComplexityBest For
Fixed WindowHigh (boundary burst)Very low (1 counter)LowSimpleBasic protection
Sliding Window LogNone (exact)High (per-request timestamps)ExactMediumPrecise limits
Sliding Window CounterLow (approximated)Low (2 counters)HighMediumGeneral purpose
Token BucketConfigurable (bucket size)Low (2 values)HighMediumBursty traffic
Leaky BucketNone (constant drain)Medium (queue)HighMediumSmooth output rate
GCRAConfigurableLow (1 value)HighMediumDistributed systems

Request Rate Metrics

MetricUnitDescriptionPrismatic Threshold
RPS (Requests Per Second)req/sInstantaneous throughputMonitor, alert > 1000
RPM (Requests Per Minute)req/minSmoothed throughputReporting metric
P50 LatencymsMedian response time< 50ms
P99 Latencyms99th percentile response time< 250ms
Error Rate%Percentage of 4xx/5xx responses< 1%
Rate Limit Hit Rate%Percentage of 429 responses< 5% (if higher, limits too tight)
Retry-After Compliance%Clients respecting Retry-After headerMonitor
Queue DepthcountRequests waiting for processing< 100

Rate Limit Tiers

TierTargetLimit (RPM)Burst (RPS)ScopeEnforcement
Public APIUnauthenticated605Per IPPlug middleware
Authenticated APIAPI key holders60030Per API keyPlug middleware
Premium APIPaid tier6000100Per API keyPlug middleware
OSINT ToolsPer tool adapterVaries1-10Per tool + userGenServer limiter
Internal ServicesGenServer calls10000500Per serviceProcess mailbox
Admin OperationsAdmin endpoints12010Per userPlug middleware
Webhook DeliveryOutbound webhooks30015Per destinationToken bucket

HTTP Rate Limit Headers

HeaderStandardDescriptionExample
X-RateLimit-LimitDraft RFCMaximum requests in window600
X-RateLimit-RemainingDraft RFCRequests remaining in window423
X-RateLimit-ResetDraft RFCWindow reset time (Unix epoch)1711929600
Retry-AfterRFC 7231Seconds until client should retry30
RateLimit-PolicyDraft RFCRate limit policy description600;w=60

Technical Deep Dive

Token Bucket Algorithm

The token bucket is the most versatile rate limiting algorithm and the primary choice for the Prismatic Platform's API rate limiting. A bucket starts full with capacity tokens. Each request consumes one token. Tokens are replenished at a fixed refill_rate per second. If the bucket is empty, the request is rejected with HTTP 429.

The key advantage is configurable burst tolerance: a client that has been idle accumulates tokens up to the bucket capacity, allowing short bursts of traffic. The capacity parameter controls the maximum burst size, while the refill_rate controls the sustained throughput.

Leaky Bucket Algorithm

The leaky bucket processes requests at a constant rate regardless of input burstiness. Requests enter a queue (the bucket) and are processed (leak out) at a fixed rate. If the queue is full, new requests are rejected. This produces a perfectly smooth output rate, which is ideal for rate-limiting calls to external APIs that have strict per-second limits.

The Prismatic Platform uses leaky buckets for OSINT tool execution against external APIs (e.g., Czech ARES business registry, sanctions databases) where the external service enforces strict rate limits and bursty traffic would trigger blocks.

Sliding Window Counter

The sliding window counter combines the memory efficiency of fixed windows with better accuracy. It maintains counters for the current and previous time windows, then estimates the request count using a weighted average:

estimated_count = previous_window_count * (1 - elapsed_fraction) + current_window_count

This eliminates the boundary burst problem of fixed windows (where a client could make 2x the limit at a window boundary) while using only two counters per client.

BEAM Process Model and Rate Limiting

The BEAM VM's lightweight process model naturally distributes request handling across scheduler threads. Each incoming HTTP request is handled by a separate process, providing isolation. However, this means rate limiting cannot rely on in-process state -- it must use shared state via ETS, GenServer, or a distributed store.

ETS (Erlang Term Storage) is the preferred backing store for rate limit counters in the Prismatic Platform. ETS provides atomic increment operations (:ets.update_counter/3), concurrent read/write access, and sub-microsecond lookup times. For multi-node deployments on Fly.io, rate limits are enforced per-node with a global coordination layer that shares aggregate counts.

Backpressure vs. Rate Limiting

Rate limiting rejects excess requests outright (HTTP 429). Backpressure, by contrast, slows down request processing to match downstream capacity. The BEAM's GenServer mailbox naturally provides backpressure -- when a GenServer is overwhelmed, callers block on GenServer.call/3 until a timeout.

The Prismatic Platform uses both strategies: rate limiting at the API boundary to protect against abuse, and backpressure internally to manage flow through GenServer-based pipelines (DD processing, OSINT tool orchestration).

Usage in Prismatic Platform

  • API Gateway (port 4004): Per-client rate limiting via Plug middleware with configurable tiers per API key
  • Phoenix Web (port 4000): Per-IP rate limiting for public endpoints, per-session for authenticated users
  • OSINT Tool Execution: Per-tool rate limiters that respect external API quotas (e.g., ARES: 5 req/s, sanctions: 2 req/s)
  • DD Pipeline: Leaky bucket rate limiting for bulk entity fetching to avoid overwhelming source registries
  • Perimeter Asset Discovery: Configurable rate limits for active scanning to avoid triggering target security controls
  • Webhook Delivery: Token bucket per destination URL to prevent overwhelming webhook consumers
  • Telemetry Export: Rate-limited metric export to prevent overwhelming monitoring backends
  • LiveView Connections: Per-IP connection limits to prevent WebSocket exhaustion
  • Search API: Per-user rate limits on Meilisearch queries to prevent index saturation
  • Intelligence Report Generation: Throttled report generation to manage CPU-intensive analysis

Code Examples

Token Bucket Rate Limiter

defmodule PrismaticApi.RateLimiter.TokenBucket do
  @moduledoc """
  Token bucket rate limiter using ETS for concurrent access.
  Supports configurable capacity (burst size) and refill rate
  (sustained throughput).

  Each client is tracked independently by a configurable key
  (IP address, API key, user ID). Tokens are replenished lazily
  on each request check rather than via a background timer,
  eliminating per-client timer overhead.

  ## Configuration

    - `capacity` - Maximum tokens (burst size). Default: 30
    - `refill_rate` - Tokens added per second. Default: 10.0
    - `table_name` - ETS table name. Default: `:rate_limiter_buckets`

  ## Examples

      iex> {:ok, pid} = PrismaticApi.RateLimiter.TokenBucket.start_link(capacity: 10, refill_rate: 2.0)
      iex> PrismaticApi.RateLimiter.TokenBucket.check("client-123")
      {:allow, 9}

      iex> # After exhausting all tokens:
      iex> PrismaticApi.RateLimiter.TokenBucket.check("client-123")
      {:deny, 0.5}

  """

  use GenServer

  require Logger

  @type check_result :: {:allow, remaining :: non_neg_integer()} | {:deny, retry_after :: float()}

  @default_capacity 30
  @default_refill_rate 10.0

  @spec start_link(keyword()) :: GenServer.on_start()
  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
  end

  @doc """
  Checks if a request from the given client should be allowed.
  Returns `{:allow, remaining_tokens}` or `{:deny, retry_after_seconds}`.

  Token replenishment is calculated lazily based on elapsed time
  since the last request, avoiding per-client background timers.

  ## Parameters

    - `client_key` - Unique client identifier (IP, API key, user ID)
    - `cost` - Number of tokens to consume (default: 1)

  ## Examples

      iex> PrismaticApi.RateLimiter.TokenBucket.check("192.168.1.1")
      {:allow, 29}

      iex> PrismaticApi.RateLimiter.TokenBucket.check("api-key-abc", cost: 5)
      {:allow, 25}

  """
  @spec check(String.t(), keyword()) :: check_result()
  def check(client_key, opts \\ []) do
    cost = Keyword.get(opts, :cost, 1)
    GenServer.call(__MODULE__, {:check, client_key, cost})
  end

  @doc """
  Returns the current token count for a client without consuming tokens.

  ## Examples

      iex> PrismaticApi.RateLimiter.TokenBucket.inspect_bucket("client-123")
      {:ok, %{tokens: 25.5, capacity: 30, refill_rate: 10.0}}

  """
  @spec inspect_bucket(String.t()) :: {:ok, map()} | {:error, :not_found}
  def inspect_bucket(client_key) do
    GenServer.call(__MODULE__, {:inspect, client_key})
  end

  @doc """
  Resets the rate limit for a specific client. Used for administrative
  override or testing.

  ## Examples

      iex> PrismaticApi.RateLimiter.TokenBucket.reset("client-123")
      :ok

  """
  @spec reset(String.t()) :: :ok
  def reset(client_key) do
    GenServer.call(__MODULE__, {:reset, client_key})
  end

  @impl GenServer
  def init(opts) do
    capacity = Keyword.get(opts, :capacity, @default_capacity)
    refill_rate = Keyword.get(opts, :refill_rate, @default_refill_rate)
    table_name = Keyword.get(opts, :table_name, :rate_limiter_buckets)

    table = :ets.new(table_name, [:set, :private])
    schedule_cleanup()

    state = %{
      table: table,
      capacity: capacity,
      refill_rate: refill_rate
    }

    Logger.info("TokenBucket started: capacity=#{capacity}, refill_rate=#{refill_rate}/s")
    {:ok, state}
  end

  @impl GenServer
  def handle_call({:check, client_key, cost}, _from, state) do
    %{table: table, capacity: capacity, refill_rate: refill_rate} = state
    now = System.monotonic_time(:millisecond)

    {tokens, _last_time} = get_or_init_bucket(table, client_key, capacity, now)

    # Lazily refill tokens based on elapsed time
    {current_tokens, last_check} =
      case :ets.lookup(table, client_key) do
        [{_, stored_tokens, last_time}] ->
          elapsed_seconds = (now - last_time) / 1000.0
          refilled = min(stored_tokens + elapsed_seconds * refill_rate, capacity * 1.0)
          {refilled, last_time}

        [] ->
          {capacity * 1.0, now}
      end

    if current_tokens >= cost do
      new_tokens = current_tokens - cost
      :ets.insert(table, {client_key, new_tokens, now})

      :telemetry.execute(
        [:prismatic, :rate_limiter, :allow],
        %{remaining: trunc(new_tokens), cost: cost},
        %{client: client_key}
      )

      {:reply, {:allow, trunc(new_tokens)}, state}
    else
      retry_after = (cost - current_tokens) / refill_rate
      :ets.insert(table, {client_key, current_tokens, now})

      :telemetry.execute(
        [:prismatic, :rate_limiter, :deny],
        %{retry_after: retry_after, cost: cost},
        %{client: client_key}
      )

      {:reply, {:deny, Float.round(retry_after, 2)}, state}
    end
  end

  @impl GenServer
  def handle_call({:inspect, client_key}, _from, state) do
    case :ets.lookup(state.table, client_key) do
      [{_, tokens, _last_time}] ->
        result = %{
          tokens: Float.round(tokens, 2),
          capacity: state.capacity,
          refill_rate: state.refill_rate
        }

        {:reply, {:ok, result}, state}

      [] ->
        {:reply, {:error, :not_found}, state}
    end
  end

  @impl GenServer
  def handle_call({:reset, client_key}, _from, state) do
    now = System.monotonic_time(:millisecond)
    :ets.insert(state.table, {client_key, state.capacity * 1.0, now})
    {:reply, :ok, state}
  end

  @impl GenServer
  def handle_info(:cleanup, state) do
    # Remove entries for clients inactive for > 10 minutes
    cutoff = System.monotonic_time(:millisecond) - 600_000

    :ets.tab2list(state.table)
    |> Enum.each(fn {key, _tokens, last_time} ->
      if last_time < cutoff, do: :ets.delete(state.table, key)
    end)

    schedule_cleanup()
    {:noreply, state}
  end

  defp get_or_init_bucket(table, key, capacity, now) do
    case :ets.lookup(table, key) do
      [{_, tokens, last_time}] -> {tokens, last_time}
      [] ->
        :ets.insert(table, {key, capacity * 1.0, now})
        {capacity * 1.0, now}
    end
  end

  defp schedule_cleanup do
    Process.send_after(self(), :cleanup, :timer.minutes(5))
  end
end

Plug Rate Limiting Middleware

defmodule PrismaticApi.Plugs.RateLimit do
  @moduledoc """
  Plug middleware for HTTP request rate limiting.
  Integrates with the TokenBucket rate limiter and adds standard
  rate limit headers to responses.

  ## Usage in Router

      pipeline :api do
        plug PrismaticApi.Plugs.RateLimit, limit: 600, window: 60
      end

  ## Headers Added

    - `X-RateLimit-Limit` - Maximum requests per window
    - `X-RateLimit-Remaining` - Requests remaining
    - `X-RateLimit-Reset` - Window reset time (Unix epoch)
    - `Retry-After` - Seconds to wait (only on 429 responses)

  """

  import Plug.Conn

  require Logger

  @behaviour Plug

  @impl Plug
  def init(opts) do
    %{
      limit: Keyword.get(opts, :limit, 600),
      window: Keyword.get(opts, :window, 60),
      key_func: Keyword.get(opts, :key_func, &default_key/1)
    }
  end

  @impl Plug
  def call(conn, config) do
    client_key = config.key_func.(conn)

    case PrismaticApi.RateLimiter.TokenBucket.check(client_key) do
      {:allow, remaining} ->
        conn
        |> put_resp_header("x-ratelimit-limit", to_string(config.limit))
        |> put_resp_header("x-ratelimit-remaining", to_string(remaining))
        |> put_resp_header("x-ratelimit-reset", to_string(next_reset(config.window)))

      {:deny, retry_after} ->
        conn
        |> put_resp_header("x-ratelimit-limit", to_string(config.limit))
        |> put_resp_header("x-ratelimit-remaining", "0")
        |> put_resp_header("retry-after", to_string(ceil(retry_after)))
        |> put_resp_content_type("application/json")
        |> send_resp(429, Jason.encode!(%{
          error: "rate_limit_exceeded",
          message: "Too many requests. Please retry after #{ceil(retry_after)} seconds.",
          retry_after: ceil(retry_after)
        }))
        |> halt()
    end
  end

  defp default_key(conn) do
    forwarded_for = get_req_header(conn, "x-forwarded-for")

    case forwarded_for do
      [ip | _] -> "ip:#{String.split(ip, ",") |> List.first() |> String.trim()}"
      [] -> "ip:#{:inet.ntoa(conn.remote_ip) |> to_string()}"
    end
  end

  defp next_reset(window_seconds) do
    now = System.system_time(:second)
    now + window_seconds - rem(now, window_seconds)
  end
end

Rate Monitoring Telemetry

defmodule PrismaticApi.RateMonitor do
  @moduledoc """
  Monitors request rates across all platform entry points using
  telemetry events. Provides real-time throughput metrics for
  dashboards, alerting, and capacity planning.

  Subscribes to Phoenix endpoint telemetry and emits aggregated
  rate metrics every second for downstream consumers.

  ## Metrics Emitted

    - `[:prismatic, :request_rate, :rps]` - Requests per second
    - `[:prismatic, :request_rate, :error_rate]` - Error percentage
    - `[:prismatic, :request_rate, :p99_latency]` - 99th percentile latency

  ## Examples

      iex> PrismaticApi.RateMonitor.current_rps()
      42.5

      iex> PrismaticApi.RateMonitor.current_rps(endpoint: :api)
      18.3

  """

  use GenServer

  require Logger

  @spec start_link(keyword()) :: GenServer.on_start()
  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @doc """
  Returns the current requests per second across all endpoints
  or filtered by a specific endpoint.

  ## Examples

      iex> PrismaticApi.RateMonitor.current_rps()
      42.5

  """
  @spec current_rps(keyword()) :: float()
  def current_rps(opts \\ []) do
    GenServer.call(__MODULE__, {:rps, opts})
  end

  @impl GenServer
  def init(_opts) do
    table = :ets.new(:rate_monitor, [:set, :private])
    schedule_aggregate()

    {:ok, %{table: table, window: [], window_size: 60}}
  end

  @impl GenServer
  def handle_call({:rps, _opts}, _from, %{window: window} = state) do
    rps = if window == [], do: 0.0, else: length(window) / min(length(window), 60)
    {:reply, Float.round(rps, 1), state}
  end

  @impl GenServer
  def handle_info(:aggregate, state) do
    now = System.system_time(:second)
    cutoff = now - state.window_size

    # Prune old entries
    new_window = Enum.filter(state.window, fn ts -> ts > cutoff end)

    :telemetry.execute(
      [:prismatic, :request_rate, :rps],
      %{value: length(new_window) / state.window_size},
      %{}
    )

    schedule_aggregate()
    {:noreply, %{state | window: new_window}}
  end

  @impl GenServer
  def handle_info({:request, timestamp}, state) do
    {:noreply, %{state | window: [timestamp | state.window]}}
  end

  defp schedule_aggregate do
    Process.send_after(self(), :aggregate, 1_000)
  end
end

Common Pitfalls

PitfallImpactSeverityMitigation
No rate limiting at allSystem vulnerable to abuse/DDoSCriticalImplement at minimum per-IP token bucket
Global rate limit onlyPenalizes good clients for one bad actorHighUse per-client rate limiting
Missing Retry-After headerClients retry immediately, amplifying loadHighAlways include Retry-After on 429 responses
Fixed window boundary burst2x limit possible at window boundaryMediumUse sliding window or token bucket
Rate limiting after authenticationAuth endpoints unprotectedHighRate limit before authentication
Not monitoring rate limit hitsCannot detect tight limits or attacksMediumTrack and alert on 429 response rates
Same limits for all endpointsExpensive ops get same limit as cheap readsHighPer-endpoint rate limits by cost
In-memory only (no persistence)Rate limits reset on restartLowAcceptable for most use cases; use Redis for strict
Not rate limiting OSINT toolsExternal API bans, legal issuesCriticalPer-tool leaky bucket matching external limits
Rate limiting by IP onlyShared NAT IPs penalize multiple usersMediumUse API keys for authenticated endpoints
No backpressure on internal servicesGenServer mailbox overflowHighMonitor mailbox size, apply backpressure
Ignoring client retry behaviorRetry storms after rate limitingHighImplement exponential backoff guidance

Best Practices

  1. Establish baseline request rates before setting limits: Monitor production traffic for at least a week to understand normal patterns, peak hours, and seasonal variations before defining rate limits.

  2. Use per-client rate limiting with multiple key types: Limit by IP for unauthenticated requests, by API key for authenticated requests, and by user ID for session-based access.

  3. Always return 429 with Retry-After header: Rate-limited responses must include a Retry-After header telling clients exactly when to retry. Include X-RateLimit-Remaining for proactive throttling.

  4. Separate rate limits by endpoint cost: Expensive operations (OSINT tool execution, DD analysis, report generation) need tighter limits than lightweight reads (health checks, static content).

  5. Monitor rate limit hit rates: If more than 5% of requests are rate-limited, either the limits are too tight or clients need better documentation. Frequent limiting indicates a problem.

  6. Rate limit before authentication: Authentication endpoints are prime targets for brute-force attacks. Apply rate limits before the authentication check, not after.

  7. Use leaky bucket for external API calls: When calling external services with strict rate limits (Czech ARES, sanctions databases), use leaky bucket to produce a perfectly smooth request flow.

  8. Implement token bucket for user-facing APIs: Token bucket allows controlled bursts while maintaining average throughput, matching real user behavior patterns.

  9. Track per-endpoint telemetry: Emit telemetry events for every rate limit decision (allow/deny) with client key, endpoint, and remaining tokens for dashboard visibility.

  10. Clean up stale rate limit entries: Periodically sweep rate limit state to remove entries for clients that haven't been seen in 10+ minutes, preventing memory growth.

  • Throttling -- the enforcement mechanism for request rate limits
  • Sliding Window -- time-based window for rate calculation
  • SLA -- service level agreements defining acceptable request rates
  • Status Code -- HTTP 429 indicates rate limit exceeded
  • Throughput -- the capacity metric that rate limits protect
  • API Gateway -- entry point where rate limits are enforced
  • Telemetry -- monitoring infrastructure for rate metrics
  • GenServer -- BEAM process model for rate limiter state
  • ETS -- in-memory storage for rate limit counters
  • Plug -- middleware framework for HTTP rate limiting
  • Performance -- platform performance standards
  • DDoS -- attack vector mitigated by rate limiting

See Also


Connect & Contribute

Created by Tomas Korcak (korczis) | Open Source under GHL