From 72d888b2cace0e597b3316a8ecc8f0f9dcbdd529 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 07:03:56 +0000 Subject: [PATCH] Add rate limit error handling for TwelveData provider - Add RateLimitError class to Provider::TwelveData - Implement custom error transformer to detect 429 errors - Re-raise rate limit errors in Security::Price::Importer - Configure SyncJob to retry on rate limit errors with 70s initial delay Co-authored-by: jjmata <187772+jjmata@users.noreply.github.com> --- app/jobs/sync_job.rb | 7 +++++ app/models/provider/twelve_data.rb | 37 +++++++++++++++++++++++++++ app/models/security/price/importer.rb | 9 ++++++- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/app/jobs/sync_job.rb b/app/jobs/sync_job.rb index 0c8c3922c..929f44857 100644 --- a/app/jobs/sync_job.rb +++ b/app/jobs/sync_job.rb @@ -1,6 +1,13 @@ class SyncJob < ApplicationJob queue_as :high_priority + # Retry on TwelveData rate limit errors with custom backoff + # TwelveData has a per-minute rate limit, so we start with 70 seconds + # to ensure the minute window has passed, then increase exponentially + retry_on Provider::TwelveData::RateLimitError, + wait: ->(executions) { [ 70 * (2 ** (executions - 1)), 600 ].min }, + attempts: 5 + # Accept a runtime-only flag to influence sync behavior without persisting config def perform(sync, balances_only: false) # Attach a transient predicate for this execution only diff --git a/app/models/provider/twelve_data.rb b/app/models/provider/twelve_data.rb index 8f9d81a42..bfc6fef1d 100644 --- a/app/models/provider/twelve_data.rb +++ b/app/models/provider/twelve_data.rb @@ -5,6 +5,7 @@ class Provider::TwelveData < Provider Error = Class.new(Provider::Error) InvalidExchangeRateError = Class.new(Error) InvalidSecurityPriceError = Class.new(Error) + RateLimitError = Class.new(Error) def initialize(api_key) @api_key = api_key @@ -231,4 +232,40 @@ class Provider::TwelveData < Provider faraday.headers["Authorization"] = "apikey #{api_key}" end end + + # Custom error transformer to detect rate limiting errors + def default_error_transformer(error) + if error.is_a?(Faraday::Error) + response_body = error.response&.dig(:body) + status_code = error.response&.dig(:status) + + # Detect 429 rate limit errors + if status_code == 429 + message = extract_error_message(response_body) || error.message + raise RateLimitError.new( + "TwelveData rate limit exceeded: #{message}", + details: response_body + ) + end + + self.class::Error.new( + error.message, + details: response_body + ) + else + self.class::Error.new(error.message) + end + end + + # Extract error message from TwelveData API response + def extract_error_message(response_body) + return nil unless response_body.is_a?(String) + + begin + parsed = JSON.parse(response_body) + parsed.dig("message") || parsed.dig("error") + rescue JSON::ParserError + nil + end + end end diff --git a/app/models/security/price/importer.rb b/app/models/security/price/importer.rb index def62fc03..26b1e5e5c 100644 --- a/app/models/security/price/importer.rb +++ b/app/models/security/price/importer.rb @@ -113,7 +113,14 @@ class Security::Price::Importer if response.success? response.data.index_by(&:date) else - Rails.logger.warn("#{security_provider.class.name} could not fetch prices for #{security.ticker} between #{provider_fetch_start_date} and #{end_date}. Provider error: #{response.error.message}") + error = response.error + + # If this is a rate limit error, re-raise it so the job can be retried + if error.is_a?(Provider::TwelveData::RateLimitError) + raise error + end + + Rails.logger.warn("#{security_provider.class.name} could not fetch prices for #{security.ticker} between #{provider_fetch_start_date} and #{end_date}. Provider error: #{error.message}") Sentry.capture_exception(MissingSecurityPriceError.new("Could not fetch prices for ticker"), level: :warning) do |scope| scope.set_tags(security_id: security.id) scope.set_context("security", { id: security.id, start_date: start_date, end_date: end_date })