class Telnyx::TelnyxClient

TelnyxClient executes requests against the Telnyx API and allows a user to recover both a resource a call returns as well as a response object that contains information on the HTTP call.

Attributes

conn[RW]

Public Class Methods

active_client() click to toggle source
# File lib/telnyx/telnyx_client.rb, line 18
def self.active_client
  Thread.current[:telnyx_client] || default_client
end
default_client() click to toggle source
# File lib/telnyx/telnyx_client.rb, line 22
def self.default_client
  Thread.current[:telnyx_client_default_client] ||= TelnyxClient.new(default_conn)
end
default_conn() click to toggle source

A default Faraday connection to be used when one isn't configured. This object should never be mutated, and instead instantiating your own connection and wrapping it in a TelnyxClient object should be preferred.

# File lib/telnyx/telnyx_client.rb, line 29
def self.default_conn
  # We're going to keep connections around so that we can take advantage
  # of connection re-use, so make sure that we have a separate connection
  # object per thread.
  Thread.current[:telnyx_client_default_conn] ||= begin
    conn = Faraday.new do |builder|
      builder.use Faraday::Request::Multipart
      builder.use Faraday::Request::UrlEncoded
      builder.use Faraday::Response::RaiseError

      # Net::HTTP::Persistent doesn't seem to do well on Windows or JRuby,
      # so fall back to default there.
      if Gem.win_platform? || RUBY_PLATFORM == "java"
        builder.adapter :net_http
      else
        builder.adapter :net_http_persistent
      end
    end

    if Telnyx.verify_ssl_certs
      conn.ssl.verify = true
    else
      conn.ssl.verify = false

      unless @verify_ssl_warned
        @verify_ssl_warned = true
        $stderr.puts("WARNING: Running without SSL cert verification. " \
          "You should never do this in production. " \
          "Execute 'Telnyx.verify_ssl_certs = true' to enable verification.")
      end
    end

    conn
  end
end
new(conn = nil) click to toggle source

Initializes a new TelnyxClient. Expects a Faraday connection object, and uses a default connection unless one is passed.

# File lib/telnyx/telnyx_client.rb, line 12
def initialize(conn = nil)
  self.conn = conn || self.class.default_conn
  @system_profiler = SystemProfiler.new
  @last_request_metrics = nil
end
should_retry?(e, num_retries) click to toggle source

Checks if an error is a problem that we should retry on. This includes both socket errors that may represent an intermittent problem and some special HTTP statuses.

# File lib/telnyx/telnyx_client.rb, line 68
def self.should_retry?(e, num_retries)
  return false if num_retries >= Telnyx.max_network_retries

  # Retry on timeout-related problems (either on open or read).
  return true if e.is_a?(Faraday::TimeoutError)

  # Destination refused the connection, the connection was reset, or a
  # variety of other connection failures. This could occur from a single
  # saturated server, so retry in case it's intermittent.
  return true if e.is_a?(Faraday::ConnectionFailed)

  false
end
sleep_time(num_retries) click to toggle source
# File lib/telnyx/telnyx_client.rb, line 82
def self.sleep_time(num_retries)
  # Apply exponential backoff with initial_network_retry_delay on the
  # number of num_retries so far as inputs. Do not allow the number to exceed
  # max_network_retry_delay.
  sleep_seconds = [Telnyx.initial_network_retry_delay * (2**(num_retries - 1)), Telnyx.max_network_retry_delay].min

  # Apply some jitter by randomizing the value in the range of (sleep_seconds
  # / 2) to (sleep_seconds).
  sleep_seconds *= (0.5 * (1 + rand))

  # But never sleep less than the base sleep seconds.
  sleep_seconds = [Telnyx.initial_network_retry_delay, sleep_seconds].max

  sleep_seconds
end

Public Instance Methods

execute_request(method, path, api_base: nil, api_key: nil, headers: {}, params: {}) click to toggle source
# File lib/telnyx/telnyx_client.rb, line 116
def execute_request(method, path,
                    api_base: nil, api_key: nil, headers: {}, params: {})
  api_base ||= Telnyx.api_base
  api_key ||= Telnyx.api_key

  check_api_key!(api_key)

  params = Util.objects_to_ids(params)
  url = api_url(path, api_base)

  body = nil
  query_params = nil

  case method.to_s.downcase.to_sym
  when :get, :head, :delete
    query_params = params
  else
    body = if headers[:content_type] && headers[:content_type] == "multipart/form-data"
             params
           else
             JSON.generate(params)
           end
  end

  # This works around an edge case where we end up with both query
  # parameters in `query_params` and query parameters that are appended
  # onto the end of the given path. In this case, Faraday will silently
  # discard the URL's parameters which may break a request.
  #
  # Here we decode any parameters that were added onto the end of a path
  # and add them to `query_params` so that all parameters end up in one
  # place and all of them are correctly included in the final request.
  u = URI.parse(path)
  unless u.query.nil?
    query_params ||= {}
    query_params = Hash[URI.decode_www_form(u.query)].merge(query_params)

    # Reset the path minus any query parameters that were specified.
    path = u.path
  end

  headers = request_headers(api_key, method)
            .update(Util.normalize_headers(headers))

  # stores information on the request we're about to make so that we don't
  # have to pass as many parameters around for logging.
  context = RequestLogContext.new
  context.api_key         = api_key
  context.body            = body
  context.method          = method
  context.path            = path
  context.query_params    = query_params ? Util.encode_parameters(query_params) : nil

  http_resp = execute_request_with_rescues(api_base, context) do
    conn.run_request(method, url, body, headers) do |req|
      req.options.open_timeout = Telnyx.open_timeout
      req.options.timeout = Telnyx.read_timeout
      req.params = query_params unless query_params.nil?
    end
  end

  begin
    resp = TelnyxResponse.from_faraday_response(http_resp)
  rescue JSON::ParserError
    raise general_api_error(http_resp.status, http_resp.body)
  end

  # Allows TelnyxClient#request to return a response object to a caller.
  @last_response = resp
  [resp, api_key]
end
request() { || ... } click to toggle source

Executes the API call within the given block. Usage looks like:

client = TelnyxClient.new
messaging_profile, resp = client.request { MessagingProfile.create }
# File lib/telnyx/telnyx_client.rb, line 103
def request
  @last_response = nil
  old_telnyx_client = Thread.current[:telnyx_client]
  Thread.current[:telnyx_client] = self

  begin
    res = yield
    [res, @last_response]
  ensure
    Thread.current[:telnyx_client] = old_telnyx_client
  end
end

Private Instance Methods

api_url(url = "", api_base = nil) click to toggle source
# File lib/telnyx/telnyx_client.rb, line 190
def api_url(url = "", api_base = nil)
  (api_base || Telnyx.api_base) + url
end
check_api_key!(api_key) click to toggle source
# File lib/telnyx/telnyx_client.rb, line 194
def check_api_key!(api_key)
  unless api_key
    raise AuthenticationError, "No API key provided. " \
      'Set your API key using "Telnyx.api_key = <API-KEY>". ' \
      "You can generate API keys from the Telnyx web interface. " \
      "See https://telnyx.com/api for details, or email support@telnyx.com " \
      "if you have any questions."
  end

  return unless api_key =~ /\s/

  raise AuthenticationError, "Your API key is invalid, as it contains " \
    "whitespace. (HINT: You can double-check your API key from the " \
    "Telnyx web interface. See https://telnyx.com/api for details, or " \
    "email support@telnyx.com if you have any questions.)"
end
execute_request_with_rescues(api_base, context) { || ... } click to toggle source
# File lib/telnyx/telnyx_client.rb, line 211
def execute_request_with_rescues(api_base, context)
  num_retries = 0
  begin
    request_start = Time.now
    log_request(context, num_retries)
    resp = yield
    context = context.dup_from_response(resp)
    log_response(context, request_start, resp.status, resp.body)

  # We rescue all exceptions from a request so that we have an easy spot to
  # implement our retry logic across the board. We'll re-raise if it's a type
  # of exception that we didn't expect to handle.
  rescue StandardError => e
    # If we modify context we copy it into a new variable so as not to
    # taint the original on a retry.
    error_context = context

    if e.respond_to?(:response) && e.response
      error_context = context.dup_from_response(e.response)
      log_response(error_context, request_start,
                   e.response[:status], e.response[:body])
    else
      log_response_error(error_context, request_start, e)
    end

    if self.class.should_retry?(e, num_retries)
      num_retries += 1
      sleep self.class.sleep_time(num_retries)
      retry
    end

    case e
    when Faraday::Error
      if e.response
        handle_error_response(e.response, error_context)
      else
        handle_network_error(e, error_context, num_retries, api_base)
      end

    # Only handle errors when we know we can do so, and re-raise otherwise.
    # This should be pretty infrequent.
    else
      raise
    end
  end

  resp
end
format_app_info(info) click to toggle source

Formats a plugin “app info” hash into a string that we can tack onto the end of a User-Agent string where it'll be fairly prominent in places like the Dashboard. Note that this formatting has been implemented to match other libraries, and shouldn't be changed without universal consensus.

# File lib/telnyx/telnyx_client.rb, line 270
def format_app_info(info)
  str = info[:name]
  str = "#{str}/#{info[:version]}" unless info[:version].nil?
  str = "#{str} (#{info[:url]})" unless info[:url].nil?
  str
end
general_api_error(status, body) click to toggle source
# File lib/telnyx/telnyx_client.rb, line 260
def general_api_error(status, body)
  APIError.new("Invalid response object from API: #{body.inspect} " \
               "(HTTP response code was #{status})",
               http_status: status, http_body: body)
end
handle_error_response(http_resp, context) click to toggle source
# File lib/telnyx/telnyx_client.rb, line 277
def handle_error_response(http_resp, context)
  begin
    resp = TelnyxResponse.from_faraday_hash(http_resp)
    error_list = resp.data[:errors]

    raise TelnyxError, "Indeterminate error" unless error_list
  rescue JSON::ParserError, TelnyxError
    raise general_api_error(http_resp[:status], http_resp[:body])
  end

  error = specific_api_error(resp, error_list, context)

  error.response = resp
  raise(error)
end
handle_network_error(e, context, num_retries, api_base = nil) click to toggle source
# File lib/telnyx/telnyx_client.rb, line 330
def handle_network_error(e, context, num_retries, api_base = nil)
  Util.log_error("Telnyx network error",
                 error_message: e.message,
                 request_id: context.request_id)

  case e
  when Faraday::ConnectionFailed
    message = "Unexpected error communicating when trying to connect to Telnyx. " \
      "You may be seeing this message because your DNS is not working. " \
      "To check, try running 'host telnyx.com' from the command line."

  when Faraday::SSLError
    message = "Could not establish a secure connection to Telnyx, you may " \
              "need to upgrade your OpenSSL version. To check, try running " \
              "'openssl s_client -connect api.telnyx.com:443' from the " \
              "command line."

  when Faraday::TimeoutError
    api_base ||= Telnyx.api_base
    message = "Could not connect to Telnyx (#{api_base}). " \
      "Please check your internet connection and try again. " \
      "If this problem persists, you should check Telnyx's service status at " \
      "https://status.telnyx.com, or let us know at support@telnyx.com."

  else
    message = "Unexpected error communicating with Telnyx. " \
      "If this problem persists, let us know at support@telnyx.com."

  end

  message += " Request was retried #{num_retries} times." if num_retries > 0

  raise APIConnectionError, message + "\n\n(Network error: #{e.message})"
end
log_request(context, num_retries) click to toggle source
# File lib/telnyx/telnyx_client.rb, line 392
def log_request(context, num_retries)
  Util.log_info("Request to Telnyx API",
                method: context.method,
                num_retries: num_retries,
                path: context.path)
  Util.log_debug("Request details",
                 body: context.body,
                 query_params: context.query_params)
end
log_response(context, request_start, status, body) click to toggle source
# File lib/telnyx/telnyx_client.rb, line 403
def log_response(context, request_start, status, body)
  Util.log_info("Response from Telnyx API",
                elapsed: Time.now - request_start,
                method: context.method,
                path: context.path,
                request_id: context.request_id,
                status: status)
  Util.log_debug("Response details",
                 body: body,
                 request_id: context.request_id)

  return unless context.request_id
end
log_response_error(context, request_start, e) click to toggle source
# File lib/telnyx/telnyx_client.rb, line 418
def log_response_error(context, request_start, e)
  Util.log_error("Request error",
                 elapsed: Time.now - request_start,
                 error_message: e.message,
                 method: context.method,
                 path: context.path)
end
request_headers(api_key, _method) click to toggle source
# File lib/telnyx/telnyx_client.rb, line 365
def request_headers(api_key, _method)
  user_agent = "Telnyx/v2 RubyBindings/#{Telnyx::VERSION}"
  unless Telnyx.app_info.nil?
    user_agent += " " + format_app_info(Telnyx.app_info)
  end

  headers = {
    "User-Agent" => user_agent,
    "Authorization" => "Bearer #{api_key}",
    "Content-Type" => "application/json",
  }

  user_agent = @system_profiler.user_agent
  begin
    headers.update(
      "X-Telnyx-Client-User-Agent" => JSON.generate(user_agent)
    )
  rescue StandardError => e
    headers.update(
      "X-Telnyx-Client-Raw-User-Agent" => user_agent.inspect,
      :error => "#{e} (#{e.class})"
    )
  end

  headers
end
specific_api_error(resp, error_list, context) click to toggle source
# File lib/telnyx/telnyx_client.rb, line 293
def specific_api_error(resp, error_list, context)
  error_list.each do |error|
    Util.log_error("Telnyx API error",
                   status: resp.http_status,
                   error_code: error[:code],
                   error_detail: error[:detail],
                   error_source: error[:source],
                   error_title: error[:title],
                   request_id: context.request_id)
  end

  # The standard set of arguments that can be used to initialize most of
  # the exceptions.
  opts = {
    http_body: resp.http_body,
    http_headers: resp.http_headers,
    http_status: resp.http_status,
    json_body: resp.data,
  }

  err_class = case resp.http_status
              when 400 then InvalidRequestError
              when 401 then AuthenticationError
              when 403 then PermissionError
              when 404 then ResourceNotFoundError
              when 405 then MethodNotSupportedError
              when 408 then TimeoutError
              when 422 then InvalidParametersError
              when 429 then RateLimitError
              when 500 then APIError
              when 503 then ServiceUnavailableError
              else APIError
              end

  err_class.new(error_list, **opts)
end