diff --git a/test/jobs/sync_job_test.rb b/test/jobs/sync_job_test.rb index b392b3a32..2c375930e 100644 --- a/test/jobs/sync_job_test.rb +++ b/test/jobs/sync_job_test.rb @@ -10,4 +10,24 @@ class SyncJobTest < ActiveJob::TestCase SyncJob.perform_now(sync) end + + test "retries on TwelveData rate limit error" do + syncable = accounts(:depository) + sync = syncable.syncs.create!(window_start_date: 2.days.ago.to_date) + + # Create a rate limit error + rate_limit_error = Provider::TwelveData::RateLimitError.new( + "TwelveData rate limit exceeded", + details: { code: 429 } + ) + + # Mock sync.perform to raise the rate limit error + sync.stubs(:perform).raises(rate_limit_error) + + # Verify the job is configured to retry on this error + assert_raises(Provider::TwelveData::RateLimitError) do + SyncJob.perform_now(sync) + end + end end + diff --git a/test/models/provider/twelve_data_test.rb b/test/models/provider/twelve_data_test.rb new file mode 100644 index 000000000..0fa8f70f9 --- /dev/null +++ b/test/models/provider/twelve_data_test.rb @@ -0,0 +1,125 @@ +require "test_helper" + +class Provider::TwelveDataTest < ActiveSupport::TestCase + setup do + @provider = Provider::TwelveData.new("test_api_key") + end + + # ================================ + # Rate Limit Error Tests + # ================================ + + test "raises RateLimitError on 429 response" do + # Mock a 429 rate limit response from the API + error_body = JSON.generate({ + "code" => 429, + "message" => "You have run out of API credits for the current minute. 27 API credits were used, with the current limit being 8.", + "status" => "error" + }) + + faraday_error = Faraday::ClientError.new("the server responded with status 429") + faraday_error.instance_variable_set(:@response, { + status: 429, + body: error_body + }) + + @provider.stubs(:client).returns(mock_client = mock) + mock_client.stubs(:get).raises(faraday_error) + + response = @provider.fetch_security_prices( + symbol: "AAPL", + exchange_operating_mic: "XNAS", + start_date: Date.parse("2024-01-01"), + end_date: Date.parse("2024-01-10") + ) + + assert_not response.success? + assert_instance_of Provider::TwelveData::RateLimitError, response.error + assert_match(/rate limit exceeded/i, response.error.message) + end + + test "raises RateLimitError for exchange rates on 429 response" do + # Mock a 429 rate limit response + error_body = JSON.generate({ + "code" => 429, + "message" => "Rate limit exceeded", + "status" => "error" + }) + + faraday_error = Faraday::ClientError.new("the server responded with status 429") + faraday_error.instance_variable_set(:@response, { + status: 429, + body: error_body + }) + + @provider.stubs(:client).returns(mock_client = mock) + mock_client.stubs(:get).raises(faraday_error) + + response = @provider.fetch_exchange_rates( + from: "USD", + to: "EUR", + start_date: Date.parse("2024-01-01"), + end_date: Date.parse("2024-01-10") + ) + + assert_not response.success? + assert_instance_of Provider::TwelveData::RateLimitError, response.error + end + + test "handles non-rate-limit errors normally" do + # Mock a 500 server error + error_body = JSON.generate({ + "code" => 500, + "message" => "Internal server error", + "status" => "error" + }) + + faraday_error = Faraday::ServerError.new("the server responded with status 500") + faraday_error.instance_variable_set(:@response, { + status: 500, + body: error_body + }) + + @provider.stubs(:client).returns(mock_client = mock) + mock_client.stubs(:get).raises(faraday_error) + + response = @provider.fetch_security_prices( + symbol: "AAPL", + exchange_operating_mic: "XNAS", + start_date: Date.parse("2024-01-01"), + end_date: Date.parse("2024-01-10") + ) + + assert_not response.success? + # Should be a regular error, not a RateLimitError + assert_instance_of Provider::TwelveData::Error, response.error + assert_not_instance_of Provider::TwelveData::RateLimitError, response.error + end + + test "extracts error message from JSON response body" do + error_body = JSON.generate({ + "code" => 429, + "message" => "Custom rate limit message", + "status" => "error" + }) + + faraday_error = Faraday::ClientError.new("the server responded with status 429") + faraday_error.instance_variable_set(:@response, { + status: 429, + body: error_body + }) + + @provider.stubs(:client).returns(mock_client = mock) + mock_client.stubs(:get).raises(faraday_error) + + response = @provider.fetch_security_prices( + symbol: "AAPL", + exchange_operating_mic: "XNAS", + start_date: Date.parse("2024-01-01"), + end_date: Date.parse("2024-01-10") + ) + + assert_not response.success? + assert_match(/Custom rate limit message/, response.error.message) + end +end diff --git a/test/models/security/price/importer_test.rb b/test/models/security/price/importer_test.rb index 6a2fb8c42..83c715b62 100644 --- a/test/models/security/price/importer_test.rb +++ b/test/models/security/price/importer_test.rb @@ -412,6 +412,69 @@ class Security::Price::ImporterTest < ActiveSupport::TestCase end end + test "re-raises rate limit errors from TwelveData provider" do + Security::Price.delete_all + + # Create a rate limit error wrapped in a failed response + rate_limit_error = Provider::TwelveData::RateLimitError.new( + "TwelveData rate limit exceeded: You have run out of API credits for the current minute.", + details: { code: 429 } + ) + provider_response = Provider::Response.new( + success?: false, + data: nil, + error: rate_limit_error + ) + + @provider.expects(:fetch_security_prices) + .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic, + start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current) + .returns(provider_response) + + importer = Security::Price::Importer.new( + security: @security, + security_provider: @provider, + start_date: 2.days.ago.to_date, + end_date: Date.current + ) + + # The rate limit error should be re-raised so the job can retry + assert_raises(Provider::TwelveData::RateLimitError) do + importer.import_provider_prices + end + end + + test "does not re-raise non-rate-limit errors from provider" do + Security::Price.delete_all + + # Create a regular error (not rate limit) + regular_error = Provider::TwelveData::Error.new("API error", details: { code: 500 }) + provider_response = Provider::Response.new( + success?: false, + data: nil, + error: regular_error + ) + + @provider.expects(:fetch_security_prices) + .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic, + start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current) + .returns(provider_response) + + importer = Security::Price::Importer.new( + security: @security, + security_provider: @provider, + start_date: 2.days.ago.to_date, + end_date: Date.current + ) + + # Regular errors should not be re-raised, just logged + # The method returns 0 (no prices imported) + assert_nothing_raised do + result = importer.import_provider_prices + assert_equal 0, result + end + end + private def get_provider_fetch_start_date(start_date) start_date - Security::Price::Importer::PROVISIONAL_LOOKBACK_DAYS.days