lib/attio/errors.rb



# frozen_string_literal: true

module Attio
  # Base error class for all Attio errors
  class Error < StandardError
    attr_reader :response, :code, :request_id

    def initialize(message, response = nil)
      @response = response

      if response
        @code = response[:status]
        @request_id = extract_request_id(response)

        # Try to extract a better error message from the response
        if response[:body].is_a?(Hash)
          api_message = response[:body][:error] || response[:body][:message]
          message = "#{message}: #{api_message}" if api_message
        end
      end

      super(message)
    end

    private

    def extract_request_id(response)
      return nil unless response[:headers]
      response[:headers]["x-request-id"] || response[:headers]["X-Request-Id"]
    end
  end

  # Client errors (4xx)
  class ClientError < Error; end

  # Specific client errors
  class BadRequestError < ClientError; end          # 400

  class AuthenticationError < ClientError; end      # 401

  class ForbiddenError < ClientError; end          # 403

  class NotFoundError < ClientError; end           # 404

  class ConflictError < ClientError; end           # 409

  class UnprocessableEntityError < ClientError; end # 422

  class RateLimitError < ClientError               # 429
    attr_reader :retry_after

    def initialize(message, response = nil)
      super
      @retry_after = extract_retry_after(response) if response
    end

    private

    def extract_retry_after(response)
      return nil unless response[:headers]
      value = response[:headers]["retry-after"] || response[:headers]["Retry-After"]
      value&.to_i
    end
  end

  # Server errors (5xx)
  class ServerError < Error; end

  # Connection errors
  class ConnectionError < Error; end

  # Request timeout error
  class TimeoutError < ConnectionError; end

  # Network-level connection error
  class NetworkError < ConnectionError; end

  # Configuration errors
  class ConfigurationError < Error; end

  # Request errors
  class InvalidRequestError < ClientError; end

  # Factory module for creating appropriate error instances
  module ErrorFactory
    # Create an error instance from an HTTP response
    # @param response [Hash] Response hash with :status, :body, and :headers
    # @param message [String, nil] Optional custom error message
    # @return [Error] Appropriate error instance based on status code
    def self.from_response(response, message = nil)
      status = response[:status].to_i
      message ||= "API request failed with status #{status}"

      case status
      when 400 then BadRequestError.new(message, response)
      when 401 then AuthenticationError.new(message, response)
      when 403 then ForbiddenError.new(message, response)
      when 404 then NotFoundError.new(message, response)
      when 409 then ConflictError.new(message, response)
      when 422 then UnprocessableEntityError.new(message, response)
      when 429 then RateLimitError.new(message, response)
      when 400..499 then ClientError.new(message, response)
      when 500..599 then ServerError.new(message, response)
      else
        Error.new(message, response)
      end
    end

    # Create an error instance from a caught exception
    # @param exception [Exception] The caught exception
    # @param context [Hash] Additional context (currently unused)
    # @return [Error] Appropriate error instance based on exception type
    def self.from_exception(exception, context = {})
      case exception
      when Faraday::TimeoutError, Net::ReadTimeout, Net::OpenTimeout
        TimeoutError.new("Request timed out: #{exception.message}")
      when Faraday::ConnectionFailed, SocketError, Errno::ECONNREFUSED
        NetworkError.new("Network error: #{exception.message}")
      when Faraday::ClientError
        from_response({status: exception.response_status, body: exception.response_body})
      else
        ConnectionError.new("Connection error: #{exception.message}")
      end
    end
  end
end