module ActiveGenie
module Clients
class BaseClient
class ClientError < StandardError; end
class RateLimitError < ClientError; end
class TimeoutError < ClientError; end
class NetworkError < ClientError; end
DEFAULT_HEADERS = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'ActiveGenie/1.0',
}.freeze
DEFAULT_TIMEOUT = 60 # seconds
DEFAULT_OPEN_TIMEOUT = 10 # seconds
DEFAULT_MAX_RETRIES = 3
DEFAULT_RETRY_DELAY = 1 # seconds
attr_reader :app_config
def initialize(config)
@app_config = config
end
# Make a GET request to the specified endpoint
#
# @param endpoint [String] The API endpoint to call
# @param headers [Hash] Additional headers to include in the request
# @param params [Hash] Query parameters for the request
# @param config [Hash] Configuration options including timeout, retries, etc.
# @return [Hash, nil] The parsed JSON response or nil if empty
def get(endpoint, params: {}, headers: {}, config: {})
uri = build_uri(endpoint, params)
request = Net::HTTP::Get.new(uri)
execute_request(uri, request, headers, config)
end
# Make a POST request to the specified endpoint
#
# @param endpoint [String] The API endpoint to call
# @param payload [Hash] The request body to send
# @param headers [Hash] Additional headers to include in the request
# @param config [Hash] Configuration options including timeout, retries, etc.
# @return [Hash, nil] The parsed JSON response or nil if empty
def post(endpoint, payload, params: {}, headers: {}, config: {})
uri = build_uri(endpoint, params)
request = Net::HTTP::Post.new(uri)
request.body = payload.to_json
execute_request(uri, request, headers, config)
end
# Make a PUT request to the specified endpoint
#
# @param endpoint [String] The API endpoint to call
# @param payload [Hash] The request body to send
# @param headers [Hash] Additional headers to include in the request
# @param config [Hash] Configuration options including timeout, retries, etc.
# @return [Hash, nil] The parsed JSON response or nil if empty
def put(endpoint, payload, headers: {}, config: {})
uri = build_uri(endpoint)
request = Net::HTTP::Put.new(uri)
request.body = payload.to_json
execute_request(uri, request, headers, config)
end
# Make a DELETE request to the specified endpoint
#
# @param endpoint [String] The API endpoint to call
# @param headers [Hash] Additional headers to include in the request
# @param params [Hash] Query parameters for the request
# @param config [Hash] Configuration options including timeout, retries, etc.
# @return [Hash, nil] The parsed JSON response or nil if empty
def delete(endpoint, headers: {}, params: {}, config: {})
uri = build_uri(endpoint, params)
request = Net::HTTP::Delete.new(uri)
execute_request(uri, request, headers, config)
end
protected
# Execute a request with retry logic and proper error handling
#
# @param uri [URI] The URI for the request
# @param request [Net::HTTP::Request] The request object
# @param headers [Hash] Additional headers to include
# @param config [Hash] Configuration options
# @return [Hash, nil] The parsed JSON response or nil if empty
def execute_request(uri, request, headers, config)
start_time = Time.now
# Apply headers
apply_headers(request, headers)
# Apply retry logic
retry_with_backoff(config) do
http = create_http_client(uri, config)
begin
response = http.request(request)
# Handle common HTTP errors
case response
when Net::HTTPSuccess
parsed_response = parse_response(response)
# Log request details if logging is enabled
log_request_details(
uri: uri,
method: request.method,
status: response.code,
duration: Time.now - start_time,
response: parsed_response
)
parsed_response
when Net::HTTPTooManyRequests
raise RateLimitError, "Rate limit exceeded: #{response.body}"
when Net::HTTPClientError, Net::HTTPServerError
raise ClientError, "HTTP Error #{response.code}: #{response.body}"
else
raise ClientError, "Unexpected response: #{response.code} - #{response.body}"
end
rescue Timeout::Error, Errno::ETIMEDOUT
raise TimeoutError, "Request to #{uri} timed out"
rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, SocketError => e
raise NetworkError, "Network error: #{e.message}"
end
end
end
# Create and configure an HTTP client
#
# @param uri [URI] The URI for the request
# @param config [Hash] Configuration options
# @return [Net::HTTP] Configured HTTP client
def create_http_client(uri, config)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == 'https')
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
http.read_timeout = config.dig(:runtime, :timeout) || DEFAULT_TIMEOUT
http.open_timeout = config.dig(:runtime, :open_timeout) || DEFAULT_OPEN_TIMEOUT
http
end
# Apply headers to the request
#
# @param request [Net::HTTP::Request] The request object
# @param headers [Hash] Additional headers to include
def apply_headers(request, headers)
DEFAULT_HEADERS.each do |key, value|
request[key] = value
end
headers.each do |key, value|
request[key.to_s] = value
end
end
# Build a URI for the request
#
# @param endpoint [String] The API endpoint
# @param params [Hash] Query parameters
# @return [URI] The constructed URI
def build_uri(endpoint, params = {})
base_url = @app_config.api_url
uri = URI("#{base_url}#{endpoint}")
unless params.empty?
uri.query = URI.encode_www_form(params)
end
uri
end
# Parse the response body
#
# @param response [Net::HTTPResponse] The HTTP response
# @return [Hash, nil] Parsed JSON or nil if empty
def parse_response(response)
return nil if response.body.nil? || response.body.empty?
begin
JSON.parse(response.body)
rescue JSON::ParserError => e
raise ClientError, "Failed to parse JSON response: #{e.message}"
end
end
# Log request details if logging is enabled
#
# @param details [Hash] Request and response details
def log_request_details(details)
return unless defined?(ActiveGenie::Logger)
ActiveGenie::Logger.trace({
code: :http_request,
uri: details[:uri].to_s,
method: details[:method],
status: details[:status],
duration: details[:duration],
response_size: details[:response].to_s.bytesize
})
end
# Retry a block with exponential backoff
#
# @param config [Hash] Configuration options
# @yield The block to retry
# @return [Object] The result of the block
def retry_with_backoff(config = {})
max_retries = config.dig(:runtime, :max_retries) || DEFAULT_MAX_RETRIES
retry_delay = config.dig(:runtime, :retry_delay) || DEFAULT_RETRY_DELAY
retries = 0
begin
yield
rescue RateLimitError, NetworkError => e
if retries < max_retries
sleep_time = retry_delay * (2 ** retries)
retries += 1
ActiveGenie::Logger.trace({
code: :retry_attempt,
attempt: retries,
max_retries: max_retries,
delay: sleep_time,
error: e.message
}) if defined?(ActiveGenie::Logger)
sleep(sleep_time)
retry
else
raise
end
end
end
end
end
end