Merge pull request #2762 from internetee/http-timeout-phone-validation

Http timeout phone validation
This commit is contained in:
Timo Võhmar 2025-03-07 16:04:32 +02:00 committed by GitHub
commit 696a077f98
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 210 additions and 7 deletions

3
.gitignore vendored
View file

@ -21,3 +21,6 @@
/import
ettevotja_rekvisiidid__lihtandmed.csv.zip
Dockerfile.dev
.cursor/
.cursorrules
certs/

View file

@ -1,8 +1,21 @@
class OrgRegistrantPhoneCheckerJob < ApplicationJob
queue_as :default
include Retryable
# Constants for API error types
API_EXCEPTIONS = [
HTTPClient::KeepAliveDisconnected,
Net::OpenTimeout,
Timeout::Error,
Savon::HTTPError,
Savon::SOAPFault,
Wasabi::Resolver::HTTPError
].freeze
CACHE_EXPIRES_IN = 1.day
def perform(type: 'bulk', registrant_user_code: nil, spam_delay: 1)
puts '??? PERFROMED ???'
case type
when 'bulk'
execute_bulk_checker(spam_delay)
@ -14,7 +27,7 @@ class OrgRegistrantPhoneCheckerJob < ApplicationJob
end
def execute_bulk_checker(spam_delay)
log("Bulk checker started")
log('Bulk checker started')
Contact.where(ident_type: 'org', ident_country_code: 'EE').joins(:registrant_domains).each do |registrant_user|
is_phone_number_matching = check_the_registrant_phone_number(registrant_user)
@ -22,15 +35,16 @@ class OrgRegistrantPhoneCheckerJob < ApplicationJob
sleep(spam_delay)
end
log("Bulk checker finished")
log('Bulk checker finished')
end
def execute_single_checker(registrant_user_code)
registrant_user = Contact.where(ident_type: 'org', ident_country_code: 'EE').joins(:registrant_domains).find_by(code: registrant_user_code)
registrant_user = Contact.where(ident_type: 'org', ident_country_code: 'EE')
.joins(:registrant_domains)
.find_by(code: registrant_user_code)
return if registrant_user.nil?
is_phone_number_matching = check_the_registrant_phone_number(registrant_user)
call_disclosure_action(is_phone_number_matching, registrant_user)
end
@ -74,7 +88,24 @@ class OrgRegistrantPhoneCheckerJob < ApplicationJob
end
def fetch_phone_number_from_company_register(company_code)
data = company_register.company_details(registration_number: company_code.to_s)
data[0].phone_numbers
cache_key = "company_register:#{company_code}:phone_numbers"
return fetch_from_company_register(company_code) if Rails.env.test? && ENV['SKIP_COMPANY_REGISTER_CACHE']
Rails.cache.fetch(cache_key, expires_in: CACHE_EXPIRES_IN) do
fetch_from_company_register(company_code)
end
end
def fetch_from_company_register(company_code)
with_retry(
exceptions: API_EXCEPTIONS,
logger: Rails.logger,
fallback: -> { log("Failed to get data for company #{company_code}, returning empty array"); [] }
) do
data = company_register.company_details(registration_number: company_code.to_s)
log("Successfully retrieved data for company #{company_code}")
data[0].phone_numbers
end
end
end

39
app/lib/retryable.rb Normal file
View file

@ -0,0 +1,39 @@
# frozen_string_literal: true
# Module for retrying operations with external APIs
module Retryable
# Executes a code block with a specified number of retry attempts in case of specific errors
# @param max_attempts [Integer] maximum number of attempts (defaults to 3)
# @param retry_delay [Integer] delay between attempts in seconds (defaults to 2)
# @param exceptions [Array<Class>] exception classes to catch (defaults to all exceptions)
# @param logger [Object] logger object (must support info, warn, error methods)
# @param fallback [Proc] code block executed if all attempts fail
# @return [Object] result of the block execution or fallback
def with_retry(
max_attempts: 3,
retry_delay: 2,
exceptions: [StandardError],
logger: Rails.logger,
fallback: -> { [] }
)
attempts = 0
retry_attempt = lambda do
attempts += 1
yield
rescue *exceptions => e
logger.warn("Attempt #{attempts}/#{max_attempts} failed with error: #{e.class} - #{e.message}")
if attempts < max_attempts
logger.info("Retrying in #{retry_delay} seconds...")
sleep retry_delay
retry_attempt.call
else
logger.error("All attempts exhausted. Last error: #{e.class} - #{e.message}")
fallback.call
end
end
retry_attempt.call
end
end

View file

@ -12,6 +12,13 @@ class OrgRegistrantPhoneCheckerJobTest < ActiveSupport::TestCase
ident_country_code: 'EE',
ident: '12345678'
)
ENV['SKIP_COMPANY_REGISTER_CACHE'] = 'true'
Rails.cache.clear if defined?(Rails.cache)
end
teardown do
ENV['SKIP_COMPANY_REGISTER_CACHE'] = nil
end
def test_bulk_checker_processes_all_ee_org_contacts
@ -106,4 +113,59 @@ class OrgRegistrantPhoneCheckerJobTest < ActiveSupport::TestCase
CompanyRegister::Client.define_singleton_method(:new, original_new_method)
end
# Test that successfully retries after a connection error
def test_retries_and_recovers_from_connection_error
call_count = 0
original_new_method = CompanyRegister::Client.method(:new)
CompanyRegister::Client.define_singleton_method(:new) do
object = original_new_method.call
def object.company_details(registration_number:)
parent_call_count = CompanyRegister::Client.class_variable_get(:@@test_call_count)
parent_call_count += 1
CompanyRegister::Client.class_variable_set(:@@test_call_count, parent_call_count)
if parent_call_count == 1
raise HTTPClient::KeepAliveDisconnected, "Connection error"
end
[OpenStruct.new(phone_numbers: ['+372.555666777'])]
end
object
end
CompanyRegister::Client.class_variable_set(:@@test_call_count, 0)
@contact.disclosed_attributes = []
@contact.save!
OrgRegistrantPhoneCheckerJob.perform_now(type: 'single', registrant_user_code: @contact.code)
@contact.reload
assert @contact.disclosed_attributes.include?('phone')
assert_operator CompanyRegister::Client.class_variable_get(:@@test_call_count), :>=, 2
CompanyRegister::Client.remove_class_variable(:@@test_call_count)
CompanyRegister::Client.define_singleton_method(:new, original_new_method)
end
def test_returns_empty_array_when_max_retries_exceeded
original_new_method = CompanyRegister::Client.method(:new)
CompanyRegister::Client.define_singleton_method(:new) do
object = original_new_method.call
def object.company_details(registration_number:)
raise HTTPClient::KeepAliveDisconnected, "Persistent connection error"
end
object
end
@contact.disclosed_attributes = ['phone']
@contact.save!
OrgRegistrantPhoneCheckerJob.perform_now(type: 'single', registrant_user_code: @contact.code)
@contact.reload
assert_not @contact.disclosed_attributes.include?('phone')
CompanyRegister::Client.define_singleton_method(:new, original_new_method)
end
end

View file

@ -0,0 +1,68 @@
# frozen_string_literal: true
require 'test_helper'
class RetryableTest < ActiveSupport::TestCase
class TestClass
include Retryable
attr_reader :call_count
def initialize
@call_count = 0
end
def test_retriable_method(should_fail: false, fail_count: 2)
with_retry(
max_attempts: 3,
retry_delay: 0.1,
logger: Rails.logger
) do
@call_count += 1
if should_fail && @call_count <= fail_count
raise StandardError, "Тестовая ошибка #{@call_count}"
end
'success'
end
end
def test_retriable_with_fallback(should_fail: true)
with_retry(
max_attempts: 2,
retry_delay: 0.1,
logger: Rails.logger,
fallback: -> { 'fallback' }
) do
@call_count += 1
raise StandardError, 'Постоянная ошибка' if should_fail
'success'
end
end
end
test 'should retry specified number of times and succeed' do
test_object = TestClass.new
result = test_object.test_retriable_method(should_fail: true, fail_count: 2)
assert_equal 3, test_object.call_count
assert_equal 'success', result
end
test 'should succeed on first try if no errors' do
test_object = TestClass.new
result = test_object.test_retriable_method(should_fail: false)
assert_equal 1, test_object.call_count
assert_equal 'success', result
end
test 'should use fallback when all retries fail' do
test_object = TestClass.new
result = test_object.test_retriable_with_fallback(should_fail: true)
assert_equal 2, test_object.call_count
assert_equal 'fallback', result
end
end