ReqThrottle

A Req plugin for rate limiting and throttling HTTP requests to external services.

ReqThrottle provides a flexible, pluggable rate limiting solution that works with any rate limiter implementation. It supports both blocking (with retries) and error modes.

Features

Installation

Add req_throttle to your list of dependencies in mix.exs:

def deps do
  [
    {:req_throttle, "~> 0.1.0"}
  ]
end

Usage

Basic Usage

# Blocking mode (default) - using atom shortcut
Req.new()
|> ReqThrottle.attach(
  rate_limiter: MyApp.RateLimiter,
  key_generator: :host
)
|> Req.get("https://api.example.com/data")

Error Mode

# Error mode - returns an exception immediately when rate limit is exceeded
Req.new()
|> ReqThrottle.attach(
  rate_limiter: MyApp.RateLimiter,
  mode: :error
)
|> Req.get("https://api.example.com/data")

Using Hammer

First, add Hammer to your dependencies:

def deps do
  [
    {:req_throttle, "~> 0.1.0"},
    {:hammer, "~> 7.0"}
  ]
end

Set up Hammer in your application:

defmodule MyApp.RateLimiter do
  use Hammer, backend: :ets
end

# In your application supervision tree
defmodule MyApp.Application do
  def start(_type, _args) do
    children = [
      MyApp.RateLimiter
      # ... other children
    ]
    Supervisor.start_link(children, strategy: :one_for_one)
  end
end

Then use it with ReqThrottle:

Req.new()
|> ReqThrottle.attach(
  rate_limiter: MyApp.RateLimiter,
  key_generator: :host
)
|> Req.get("https://api.example.com/data")

Note: Hammer’s hit/3 function takes (key, scale, limit), but ReqThrottle only calls it with hit(key). You’ll need to create a wrapper that handles the scale and limit configuration:

defmodule MyApp.RateLimiter do
  use Hammer, backend: :ets

  @scale :timer.minutes(1)
  @limit 100

  def hit(key) do
    hit(key, @scale, @limit)
  end
end

Using Agent

You can create a simple rate limiter using Agent:

defmodule MyApp.SimpleRateLimiter do
  use Agent

  @scale_ms :timer.minutes(1)
  @limit 10

  def start_link(_opts) do
    Agent.start_link(fn -> %{} end, name: __MODULE__)
  end

  def hit(key) do
    now = System.system_time(:millisecond)
    window_start = div(now, @scale_ms) * @scale_ms

    Agent.get_and_update(__MODULE__, fn state ->
      # Clean up old windows (keep only current window)
      cleaned_state =
        Enum.filter(state, fn {{_k, w}, _} ->
          w >= window_start
        end)
        |> Map.new()

      window_key = {key, window_start}
      count = Map.get(cleaned_state, window_key, 0)

      if count < @limit do
        new_state = Map.put(cleaned_state, window_key, count + 1)
        {{:allow, count + 1}, new_state}
      else
        retry_after = @scale_ms - (now - window_start)
        {{:deny, retry_after}, cleaned_state}
      end
    end)
  end
end

Then use it:

# Start the agent in your supervision tree
MyApp.SimpleRateLimiter.start_link([])

Req.new()
|> ReqThrottle.attach(
  rate_limiter: MyApp.SimpleRateLimiter,
  key_generator: :host
)
|> Req.get("https://api.example.com/data")

Using Anonymous Functions

You can also pass an anonymous function directly:

rate_limiter_fn = fn key ->
  # Your custom rate limiting logic
  # Must return {:allow, count} or {:deny, retry_after_ms}
  {:allow, 1}
end

Req.new()
|> ReqThrottle.attach(rate_limiter: rate_limiter_fn)
|> Req.get("https://api.example.com/data")

Configuration Options

Key Generators

ReqThrottle provides several pre-configured key generators that can be used as atoms or functions:

# Using atom shortcuts (recommended)
key_generator: :host
key_generator: :path
key_generator: :host_and_path
key_generator: :url

# Or using functions directly
key_generator: &ReqThrottle.KeyGenerators.key_by_host/1
key_generator: &ReqThrottle.KeyGenerators.key_by_path/1
key_generator: &ReqThrottle.KeyGenerators.key_by_host_and_path/1
key_generator: &ReqThrottle.KeyGenerators.key_by_url/1

You can also create custom key generators:

custom_key_generator = fn request ->
  # Extract user ID from request headers or other logic
  user_id = Req.get_header(request, "x-user-id")
  "#{user_id}:#{URI.parse(request.url).host}"
end

Req.new()
|> ReqThrottle.attach(
  rate_limiter: MyApp.RateLimiter,
  key_generator: custom_key_generator
)

Error Handling

When using mode: :error, the plugin returns an exception:

try do
  Req.get!(client, "https://api.example.com/data")
rescue
  %ReqThrottle.RateLimitError{} = error ->
    # Rate limit exceeded
    IO.puts("Rate limit exceeded for key &#39;#{error.key}&#39;. Retry after #{error.retry_after_ms}ms")
end

Examples

Rate Limit by Host

Req.new()
|> ReqThrottle.attach(
  rate_limiter: MyApp.RateLimiter,
  key_generator: :host
)

Rate Limit by Endpoint

Req.new()
|> ReqThrottle.attach(
  rate_limiter: MyApp.RateLimiter,
  key_generator: :path
)

Custom Rate Limiting Logic

custom_rate_limiter = fn key ->
  # Check external cache, database, etc.
  current_count = get_count_from_cache(key)
  scale_ms = :timer.minutes(1)
  limit = 50
  
  if current_count < limit do
    increment_cache(key, scale_ms)
    {:allow, current_count + 1}
  else
    {:deny, scale_ms}
  end
end

Req.new()
|> ReqThrottle.attach(rate_limiter: custom_rate_limiter)

License

Copyright (c) 2025

This library is MIT licensed.