Packages

Production adapters and pipelines for PortfolioCore. Vector stores, graph stores, embedders, Broadway pipelines, and advanced RAG strategies.

Current section

Files

Jump to
portfolio_index lib portfolio_index adapters rate_limiter.ex
Raw

lib/portfolio_index/adapters/rate_limiter.ex

defmodule PortfolioIndex.Adapters.RateLimiter do
@moduledoc """
Rate limiter adapter using Foundation resilience primitives.
Provides per-provider rate limiting with backoff windows for API calls.
Uses Foundation.RateLimit.BackoffWindow for tracking backoff state.
## Usage
# Check if request is allowed
case RateLimiter.check(:openai) do
:ok -> make_api_call()
{:backoff, ms} -> Process.sleep(ms)
end
# Or block until allowed
RateLimiter.wait(:openai)
make_api_call()
# Record outcomes for adaptive limiting
case result do
{:ok, _} -> RateLimiter.record_success(:openai, :chat)
{:error, :rate_limited} -> RateLimiter.record_failure(:openai, :chat, :rate_limited)
end
## Default Backoff Durations
- `:rate_limited` (429) - 60 seconds
- `:timeout` - 5 seconds
- `:server_error` (5xx) - 10 seconds
- Other failures - 2 seconds
"""
@behaviour PortfolioCore.Ports.RateLimiter
alias Foundation.RateLimit.BackoffWindow
# Named registry for rate limiter backoff windows
@registry_name :portfolio_rate_limiter_registry
# Default backoff durations in milliseconds
@backoff_durations %{
rate_limited: 60_000,
timeout: 5_000,
server_error: 10_000,
default: 2_000
}
# ETS table for tracking success/failure counts
@stats_table :rate_limiter_stats
@registry_override_key {__MODULE__, :registry_name}
@stats_override_key {__MODULE__, :stats_table}
@doc false
def registry_name do
Process.get(@registry_override_key) ||
Application.get_env(:portfolio_index, :rate_limiter_registry, @registry_name)
end
@doc false
def stats_table do
Process.get(@stats_override_key) ||
Application.get_env(:portfolio_index, :rate_limiter_stats_table, @stats_table)
end
@doc false
def __supertester_set_table__(:registry_name, table) do
Process.put(@registry_override_key, table)
end
def __supertester_set_table__(:stats_table, table) do
Process.put(@stats_override_key, table)
end
def __supertester_set_table__(_key, _table), do: :ok
@doc """
Check if a request to the provider is currently allowed.
"""
@impl true
def check(provider) do
check(provider, :default)
end
@doc """
Check if a request to the provider for a specific operation is allowed.
"""
@impl true
def check(provider, operation) do
key = make_key(provider, operation)
limiter = get_limiter(key)
if BackoffWindow.should_backoff?(limiter) do
# Calculate remaining backoff time
backoff_until = :atomics.get(limiter, 1)
now = System.monotonic_time(:millisecond)
remaining = max(0, backoff_until - now)
{:backoff, remaining}
else
:ok
end
end
@doc """
Block until a request to the provider is allowed.
"""
@impl true
def wait(provider) do
wait(provider, :default)
end
@doc """
Block until a request to the provider for a specific operation is allowed.
"""
@impl true
def wait(provider, operation) do
key = make_key(provider, operation)
limiter = get_limiter(key)
BackoffWindow.wait(limiter)
:ok
end
@doc """
Record a successful API call.
"""
@impl true
def record_success(provider, operation) do
key = make_key(provider, operation)
limiter = get_limiter(key)
# Clear any active backoff
BackoffWindow.clear(limiter)
# Update stats
_ = ensure_stats_table()
update_stats(key, :success)
:ok
end
@doc """
Record a failed API call.
"""
@impl true
def record_failure(provider, operation, reason) do
key = make_key(provider, operation)
limiter = get_limiter(key)
# Set backoff based on failure reason
duration = Map.get(@backoff_durations, reason, @backoff_durations.default)
BackoffWindow.set(limiter, duration)
# Update stats
_ = ensure_stats_table()
update_stats(key, :failure, reason)
:ok
end
@doc """
Configure rate limits for a provider.
Note: This implementation primarily uses backoff windows.
Configuration is stored but limits are enforced via backoff behavior.
"""
@impl true
def configure(provider, config) do
_ = ensure_stats_table()
:ets.insert(stats_table(), {{:config, provider}, config})
:ok
end
@doc """
Get the current status of rate limiting for a provider.
"""
@impl true
def status(provider) do
key = make_key(provider, :default)
limiter = get_limiter(key)
in_backoff = BackoffWindow.should_backoff?(limiter)
backoff_until =
if in_backoff do
backoff_until_ms = :atomics.get(limiter, 1)
# Convert monotonic time to DateTime (approximate)
now_mono = System.monotonic_time(:millisecond)
diff_ms = backoff_until_ms - now_mono
DateTime.add(DateTime.utc_now(), diff_ms, :millisecond)
else
nil
end
_ = ensure_stats_table()
stats = get_stats(key)
%{
provider: provider,
in_backoff: in_backoff,
backoff_until: backoff_until,
success_count: stats.success_count,
failure_count: stats.failure_count,
last_failure: stats.last_failure
}
end
# Private helpers
defp get_limiter(key) do
registry = ensure_registry()
try do
BackoffWindow.for_key(registry, key)
rescue
ArgumentError ->
registry = ensure_registry()
BackoffWindow.for_key(registry, key)
end
end
defp ensure_registry do
registry = registry_name() || @registry_name
cond do
is_atom(registry) ->
case :ets.whereis(registry) do
:undefined ->
try do
BackoffWindow.new_registry(name: registry)
rescue
ArgumentError ->
registry
end
_tid ->
registry
end
is_reference(registry) ->
case :ets.info(registry) do
:undefined ->
new_registry = BackoffWindow.new_registry()
set_registry_override(new_registry)
new_registry
_info ->
registry
end
true ->
registry
end
end
defp make_key(provider, operation) do
{provider, operation}
end
defp ensure_stats_table do
table = stats_table() || @stats_table
cond do
is_atom(table) ->
case :ets.whereis(table) do
:undefined ->
create_named_stats_table(table)
_tid ->
:ok
end
is_reference(table) ->
case :ets.info(table) do
:undefined ->
new_table = create_stats_table()
set_stats_override(new_table)
:ok
_info ->
:ok
end
true ->
:ok
end
end
defp create_named_stats_table(name) do
:ets.new(name, [
:named_table,
:public,
:set,
{:read_concurrency, true},
{:write_concurrency, true}
])
rescue
ArgumentError -> :ok
end
defp create_stats_table do
:ets.new(:rate_limiter_stats, [
:public,
:set,
{:read_concurrency, true},
{:write_concurrency, true}
])
end
defp set_registry_override(registry) do
if Process.get(@registry_override_key) do
Process.put(@registry_override_key, registry)
else
Application.put_env(:portfolio_index, :rate_limiter_registry, registry)
end
end
defp set_stats_override(table) do
if Process.get(@stats_override_key) do
Process.put(@stats_override_key, table)
else
Application.put_env(:portfolio_index, :rate_limiter_stats_table, table)
end
end
defp update_stats(key, :success) do
table = stats_table()
case safe_lookup(table, {:stats, key}) do
[{_, stats}] ->
new_stats = %{stats | success_count: stats.success_count + 1}
:ets.insert(table, {{:stats, key}, new_stats})
[] ->
:ets.insert(
table,
{{:stats, key}, %{success_count: 1, failure_count: 0, last_failure: nil}}
)
:missing ->
_ = ensure_stats_table()
update_stats(key, :success)
end
end
defp update_stats(key, :failure, reason) do
table = stats_table()
case safe_lookup(table, {:stats, key}) do
[{_, stats}] ->
new_stats = %{stats | failure_count: stats.failure_count + 1, last_failure: reason}
:ets.insert(table, {{:stats, key}, new_stats})
[] ->
:ets.insert(
table,
{{:stats, key}, %{success_count: 0, failure_count: 1, last_failure: reason}}
)
:missing ->
_ = ensure_stats_table()
update_stats(key, :failure, reason)
end
end
defp get_stats(key) do
case safe_lookup(stats_table(), {:stats, key}) do
[{_, stats}] -> stats
[] -> %{success_count: 0, failure_count: 0, last_failure: nil}
:missing -> %{success_count: 0, failure_count: 0, last_failure: nil}
end
end
defp safe_lookup(table, key) do
:ets.lookup(table, key)
rescue
ArgumentError -> :missing
end
end