HTTPower.Middleware.RateLimiter (HTTPower v0.23.1)

Copy Markdown View Source

Token bucket rate limiter for HTTPower.

Implements a token bucket algorithm to enforce rate limits on HTTP requests. Each bucket refills tokens at a configured rate, and requests consume tokens.

Features

  • Token bucket algorithm with automatic refill
  • Per-client or per-endpoint rate limiting
  • Configurable strategies: wait or error
  • ETS-based storage for high performance
  • Automatic cleanup of old buckets
  • Support for custom bucket keys

Configuration

config :httpower, :rate_limit,
  enabled: true,              # Enable/disable rate limiting (default: false)
  requests: 100,              # Max requests per time window
  per: :second,               # Time window: :second, :minute, :hour
  strategy: :wait,            # Strategy: :wait or :error
  max_wait_time: 5000,        # Max wait time in ms (default: 5000)
  adaptive: true              # Adjust rate based on circuit breaker health (default: true)

Usage

# Global rate limiting (from config)
HTTPower.get("https://api.example.com/users")

# Per-client rate limiting
client = HTTPower.new(
  base_url: "https://api.example.com",
  rate_limit: [requests: 50, per: :minute]
)
HTTPower.get(client, "/users")

# Custom bucket key
HTTPower.get("https://api.example.com/users",
  rate_limit_key: "api.example.com"
)

Token Bucket Algorithm

The token bucket algorithm works as follows:

  1. Each bucket has a maximum capacity (max_tokens)
  2. Tokens are added at a fixed rate (refill_rate)
  3. Each request consumes one or more tokens
  4. If no tokens available:
    • :wait strategy - waits until tokens are available (up to max_wait_time)
    • :error strategy - returns {:error, :too_many_requests}

Adaptive Rate Limiting

When adaptive: true is enabled, rate limits automatically adjust based on circuit breaker health to prevent thundering herd during service recovery:

  • Circuit closed (healthy) → 100% rate (full speed)
  • Circuit half-open (recovering) → 50% rate (be gentle)
  • Circuit open (down) → 10% rate (minimal health checks)

This coordination prevents overwhelming a recovering service with full traffic immediately after it comes back up.

Implementation Details

Internally this uses the GCRA (Generic Cell Rate Algorithm) formulation of a token bucket: each bucket is a single timestamp (the "theoretical arrival time") rather than a token count, which makes the hot path a lock-free compare-and-swap. The token-bucket semantics above are preserved exactly.

  • Uses a public ETS table for fast in-memory storage; one integer per bucket
  • Refill is implicit in the GCRA timestamp arithmetic (no periodic ticking)
  • Thread-safe via lock-free compare-and-swap (:ets.select_replace/2) in the caller process — no GenServer round-trip on the hot path; rejects write nothing
  • The GenServer owns only the table lifecycle and periodic cleanup of idle buckets
  • Adaptive mode queries circuit breaker state (read-only, no coupling)

Summary

Functions

Checks if a request can proceed under rate limit constraints.

Returns a specification to start this module under a supervisor.

Consumes tokens from the bucket and waits if necessary.

Returns the raw GCRA state (tat_us) of a bucket, or nil if it doesn't exist.

Returns the raw GCRA state for a bucket as a map, or nil if it doesn't exist.

Feature callback for the HTTPower pipeline.

Resets a specific bucket, clearing all tokens.

Starts the rate limiter GenServer.

Synchronizes the local bucket with a server's reported rate limit state.

Types

bucket_key()

@type bucket_key() :: String.t()

rate_limit_config()

@type rate_limit_config() :: [
  requests: pos_integer(),
  per: :second | :minute | :hour,
  strategy: :wait | :error,
  max_wait_time: pos_integer()
]

tat_us()

@type tat_us() :: integer()

Functions

check_rate_limit(bucket_key, config \\ [])

@spec check_rate_limit(bucket_key(), rate_limit_config()) ::
  {:ok, float()} | {:ok, :disabled} | {:error, :too_many_requests, integer()}

Checks if a request can proceed under rate limit constraints.

Returns:

  • {:ok, remaining_tokens} if request is allowed
  • {:error, :too_many_requests, wait_time_ms} if rate limit exceeded
  • {:ok, :disabled} if rate limiting is disabled

Examples

iex> HTTPower.Middleware.RateLimiter.check_rate_limit("api.example.com")
{:ok, 100.0}

# once the bucket is exhausted:
iex> HTTPower.Middleware.RateLimiter.check_rate_limit("api.example.com", requests: 5, per: :second)
{:error, :too_many_requests, 200}

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

consume(bucket_key, config \\ [])

@spec consume(bucket_key(), rate_limit_config()) :: :ok | {:error, :too_many_requests}

Consumes tokens from the bucket and waits if necessary.

This is the main function used by HTTPower.Client. It handles both :wait and :error strategies.

Returns:

  • :ok if request can proceed
  • {:error, :too_many_requests} if rate limit exceeded and strategy is :error
  • {:error, :too_many_requests} if wait time exceeds max_wait_time

Examples

iex> HTTPower.Middleware.RateLimiter.consume("api.example.com")
:ok

iex> HTTPower.Middleware.RateLimiter.consume("api.example.com", strategy: :error)
{:error, :too_many_requests}

get_bucket_state(bucket_key)

@spec get_bucket_state(bucket_key()) :: tat_us() | nil

Returns the raw GCRA state (tat_us) of a bucket, or nil if it doesn't exist.

This is the theoretical arrival time in monotonic microseconds. For a human-meaningful "tokens remaining" value, use check_rate_limit/2, which interprets the state against a rate config.

get_info(bucket_key)

@spec get_info(bucket_key()) :: %{tat_us: tat_us()} | nil

Returns the raw GCRA state for a bucket as a map, or nil if it doesn't exist.

Examples

iex> HTTPower.Middleware.RateLimiter.get_info("api.github.com")
%{tat_us: 1_234_567_890}

handle_request(request, config)

Feature callback for the HTTPower pipeline.

Checks and consumes rate limit tokens for the request.

Returns:

  • :ok if request can proceed
  • {:error, reason} if rate limit exceeded

Examples

iex> request = %HTTPower.Request{url: "https://api.example.com", ...}
iex> HTTPower.Middleware.RateLimiter.handle_request(request, [requests: 100, per: :minute])
:ok

reset_bucket(bucket_key)

@spec reset_bucket(bucket_key()) :: :ok

Resets a specific bucket, clearing all tokens.

Useful for testing or manual intervention.

start_link(opts \\ [])

Starts the rate limiter GenServer.

update_from_headers(bucket_key, rate_limit_info, config \\ [])

@spec update_from_headers(bucket_key(), map(), rate_limit_config()) :: :ok

Synchronizes the local bucket with a server's reported rate limit state.

Server headers report a remaining count; GCRA stores a single timestamp, so we position the bucket's tat such that remaining tokens read back under the given rate config (which supplies the window/rate the server count is relative to). remaining is clamped to [0, requests].

Examples

iex> info = %{limit: 60, remaining: 55, reset_at: ~U[2025-10-01 12:00:00Z], format: :github}
iex> HTTPower.Middleware.RateLimiter.update_from_headers("api.github.com", info, requests: 60, per: :minute)
:ok