we-promise/sure · warning · Error
rate_limited
rate_limited
Error message
Rate limit exceeded. Please try again later.
What it means
api.indexacapital.com answered HTTP 429 - Indexa throttles requests per token/IP. Raised as Error(:rate_limited) with no Retry-After surfaced, so the caller owns pacing and backoff. Unlike transport errors, 429 responses are not retried by with_retries (it only rescues socket-level exceptions).
Source
Thrown at app/models/provider/indexa_capital.rb:210
def handle_response(response)
case response.code
when 200, 201
begin
JSON.parse(response.body, symbolize_names: true)
rescue JSON::ParserError => e
raise Error.new("Invalid JSON in response: #{e.message}", :bad_response)
end
when 400
Rails.logger.error "IndexaCapital API: Bad request - #{response.body}"
raise Error.new("Bad request: #{response.body}", :bad_request)
when 401
raise AuthenticationError.new("Invalid credentials", :unauthorized)
when 403
raise AuthenticationError.new("Access forbidden - check your permissions", :access_forbidden)
when 404
raise Error.new("Resource not found", :not_found)
when 429
raise Error.new("Rate limit exceeded. Please try again later.", :rate_limited)
when 500..599
raise Error.new("IndexaCapital server error (#{response.code}). Please try again later.", :server_error)
else
Rails.logger.error "IndexaCapital API: Unexpected response - Code: #{response.code}, Body: #{response.body}"
raise Error.new("Unexpected error: #{response.code} - #{response.body}", :unknown)
end
end
# Extract accounts array from /users/me response
# API returns: { accounts: [{ account_number: "ABC12345", type: "mutual", status: "active", ... }] }
def extract_accounts(user_data)
accounts = user_data[:accounts] || []
accounts.map do |acct|
{
account_number: acct[:account_number],
name: account_display_name(acct),
type: acct[:type],
status: acct[:status],View on GitHub (pinned to e69894adb9)
Solutions
- Retry with exponential backoff (start ~30-60s) and jitter; Indexa's window resets on its own
- Serialize Indexa calls per token and add a minimum interval between account fetches
- Stagger sync schedules (spread over the hour) instead of a single cron slot
- Give dev/staging separate tokens so they don't consume production's quota
Example fix
# before
accounts.each { |a| provider.get_account_balance(account_number: a[:account_number]) }
# after
accounts.each_with_index do |a, i|
sleep 0.5 if i.positive?
provider.get_account_balance(account_number: a[:account_number])
rescue Provider::IndexaCapital::Error => e
retry if (e.error_type == :rate_limited) && (sleep(30) || true) # simplified
end Defensive patterns
Strategy: retry
Type guard
def indexa_rate_limited?(error) error.is_a?(Provider::IndexaCapital::Error) && error.error_type == :rate_limited end
Try / catch
retries = 0 begin provider.get_account_balance(account_number: num) rescue Provider::IndexaCapital::Error => e retries += 1 retry if e.error_type == :rate_limited && retries <= 4 && sleep((2**retries) + rand(4)) raise end
Prevention
- Serialize Indexa calls per token with a minimum interval (e.g. 0.5s) between accounts
- Stagger sync schedules instead of one cron slot for all users
- Separate tokens per environment so dev doesn't burn prod quota
- Remember with_retries will NOT retry 429 responses - only socket errors - so wrap calls yourself
When it happens
Trigger: Looping get_account_balance/get_portfolio across many accounts in one tight loop; scheduled syncs piling up (every user at :00); the same API token shared by dev, staging and prod tripling the request rate.
Common situations: Fan-out sync jobs without throttling, retry storms after a 5xx, aggressive polling during market hours for performance data.
Related errors
AI-assisted analysis of we-promise/sure@e69894adb9 (2026-08-21).
Data as JSON: /api/errors/9ac26e595ed0f071.
Report an issue: GitHub.