class Supercast::Client
Client
executes requests against the Supercast
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/supercast/client.rb, line 17 def self.active_client Thread.current[:supercast_client] || default_client end
default_client()
click to toggle source
# File lib/supercast/client.rb, line 21 def self.default_client Thread.current[:supercast_client_default_client] ||= Client.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 Client
object should be preferred.
# File lib/supercast/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[:supercast_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 conn.proxy = Supercast.proxy if Supercast.proxy if Supercast.verify_ssl_certs conn.ssl.verify = true conn.ssl.cert_store = Supercast.ca_store else conn.ssl.verify = false unless @verify_ssl_warned @verify_ssl_warned = true warn('WARNING: Running without SSL cert verification. ' \ 'You should never do this in production. ' \ 'Execute `Supercast.verify_ssl_certs = true` to enable ' \ 'verification.') end end conn end end
new(conn = nil)
click to toggle source
Initializes a new Client
. Expects a Faraday connection object, and uses a default connection unless one is passed.
# File lib/supercast/client.rb, line 12 def initialize(conn = nil) self.conn = conn || self.class.default_conn @system_profiler = SystemProfiler.new end
should_retry?(error, 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/supercast/client.rb, line 73 def self.should_retry?(error, num_retries) return false if num_retries >= Supercast.max_network_retries # Retry on timeout-related problems (either on open or read). return true if error.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 error.is_a?(Faraday::ConnectionFailed) false end
sleep_time(num_retries)
click to toggle source
# File lib/supercast/client.rb, line 87 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 = [ Supercast.initial_network_retry_delay * (2**(num_retries - 1)), Supercast.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 = [Supercast.initial_network_retry_delay, sleep_seconds].max sleep_seconds end
Public Instance Methods
execute_request(method, path, api_base: nil, api_version: nil, api_key: nil, headers: {}, params: {})
click to toggle source
# File lib/supercast/client.rb, line 124 def execute_request(method, path, api_base: nil, api_version: nil, api_key: nil, headers: {}, params: {}) # rubocop:disable Metrics/AbcSize Metrics/MethodLength api_base ||= Supercast.api_base api_version ||= Supercast.api_version api_key ||= Supercast.api_key params = Util.objects_to_ids(params) check_api_key!(api_key) body = nil query_params = nil case method.to_s.downcase.to_sym when :get, :head, :delete query_params = params else body = params 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)) params_encoder = FaradaySupercastEncoder.new url = api_url(path, api_base, api_version) # 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.account = headers['Supercast-Account'] context.api_key = api_key context.api_version = headers['Supercast-Version'] context.body = body ? params_encoder.encode(body) : nil context.method = method context.path = path context.query_params = (params_encoder.encode(query_params) if query_params) # note that both request body and query params will be passed through # `FaradaySupercastEncoder` http_resp = execute_request_with_rescues(api_base, context) do conn.run_request(method, url, body, headers) do |req| req.options.open_timeout = Supercast.open_timeout req.options.params_encoder = params_encoder req.options.timeout = Supercast.read_timeout req.params = query_params unless query_params.nil? end end begin resp = Response.from_faraday_response(http_resp) rescue JSON::ParserError raise general_api_error(http_resp.status, http_resp.body) end # Allows Client#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 = Client.new charge, resp = client.request { Episode.create }
# File lib/supercast/client.rb, line 111 def request @last_response = nil old_supercast_client = Thread.current[:supercast_client] Thread.current[:supercast_client] = self begin res = yield [res, @last_response] ensure Thread.current[:supercast_client] = old_supercast_client end end
Private Instance Methods
api_url(url = '', api_base = nil, api_version = nil)
click to toggle source
# File lib/supercast/client.rb, line 233 def api_url(url = '', api_base = nil, api_version = nil) "#{api_base || Supercast.api_base}/#{api_version}#{url}" end
check_api_key!(api_key)
click to toggle source
# File lib/supercast/client.rb, line 237 def check_api_key!(api_key) unless api_key raise AuthenticationError, 'No API key provided. ' \ 'Set your API key using "Supercast.api_key = <API-KEY>". ' \ 'You can generate API keys from the Supercast web interface. ' \ 'See https://docs.supercast.tech/docs/access-tokens for details, or email ' \ 'support@supercast.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 ' \ 'Supercast web interface. See https://docs.supercast.tech/docs/access-tokens for details, or ' \ 'email support@supercast.com if you have any questions.)' end
execute_request_with_rescues(api_base, context) { || ... }
click to toggle source
# File lib/supercast/client.rb, line 254 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::ClientError 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
general_api_error(status, body)
click to toggle source
# File lib/supercast/client.rb, line 303 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/supercast/client.rb, line 309 def handle_error_response(http_resp, context) begin resp = Response.from_faraday_hash(http_resp) rescue StandardError raise general_api_error(http_resp[:status], http_resp[:body]) end error = specific_api_error(resp, context) error.response = resp raise(error) end
handle_network_error(error, context, num_retries, api_base = nil)
click to toggle source
# File lib/supercast/client.rb, line 353 def handle_network_error(error, context, num_retries, api_base = nil) Util.log_error('Supercast network error', error_message: error.message, idempotency_key: context.idempotency_key) case error when Faraday::ConnectionFailed message = 'Unexpected error communicating when trying to connect to ' \ 'Supercast. You may be seeing this message because your DNS is not ' \ 'working. To check, try running `host supercast.com` from the ' \ 'command line.' when Faraday::SSLError message = 'Could not establish a secure connection to Supercast, you ' \ 'may need to upgrade your OpenSSL version. To check, try running ' \ '`openssl s_client -connect api.supercast.com:443` from the command ' \ 'line.' when Faraday::TimeoutError api_base ||= Supercast.api_base message = "Could not connect to Supercast (#{api_base}). " \ 'Please check your internet connection and try again. ' \ "If this problem persists, you should check Supercast's service " \ 'status at https://status.supercast.com, or let us know at ' \ 'support@supercast.com.' else message = 'Unexpected error communicating with Supercast. ' \ 'If this problem persists, let us know at support@supercast.com.' end message += " Request was retried #{num_retries} times." if num_retries.positive? raise APIConnectionError, message + "\n\n(Network error: #{error.message})" end
log_request(context, num_retries)
click to toggle source
# File lib/supercast/client.rb, line 420 def log_request(context, num_retries) Util.log_info('Request to Supercast API', account: context.account, api_version: context.api_version, idempotency_key: context.idempotency_key, method: context.method, num_retries: num_retries, path: context.path) Util.log_debug('Request details', body: context.body, idempotency_key: context.idempotency_key, query_params: context.query_params) end
log_response(context, request_start, status, body)
click to toggle source
# File lib/supercast/client.rb, line 434 def log_response(context, request_start, status, body) Util.log_info('Response from Supercast API', account: context.account, api_version: context.api_version, elapsed: Time.now - request_start, idempotency_key: context.idempotency_key, method: context.method, path: context.path, status: status) Util.log_debug('Response details', body: body, idempotency_key: context.idempotency_key) end
log_response_error(context, request_start, error)
click to toggle source
# File lib/supercast/client.rb, line 448 def log_response_error(context, request_start, error) Util.log_error('Request error', elapsed: Time.now - request_start, error_message: error.message, idempotency_key: context.idempotency_key, method: context.method, path: context.path) end
request_headers(api_key, method)
click to toggle source
# File lib/supercast/client.rb, line 392 def request_headers(api_key, method) headers = { 'User-Agent' => "Supercast RubyBindings/#{Supercast::VERSION}", 'Authorization' => "Bearer #{api_key}", 'Content-Type' => 'application/x-www-form-urlencoded' } # It is only safe to retry network failures on post and delete # requests if we add an Idempotency-Key header headers['Idempotency-Key'] ||= SecureRandom.uuid if %i[post delete].include?(method) && Supercast.max_network_retries.positive? headers['Supercast-Version'] = Supercast.api_version if Supercast.api_version user_agent = @system_profiler.user_agent begin headers.update( 'X-Supercast-Client-User-Agent' => JSON.generate(user_agent) ) rescue StandardError => e headers.update( 'X-Supercast-Client-Raw-User-Agent' => user_agent.inspect, :error => "#{e} (#{e.class})" ) end headers end
specific_api_error(resp, context)
click to toggle source
# File lib/supercast/client.rb, line 322 def specific_api_error(resp, context) Util.log_error('Supercast API error', status: resp.http_status, error_code: resp.http_status, error_message: resp.data[:message], idempotency_key: context.idempotency_key) # 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, code: resp.http_status } case resp.http_status when 400, 404, 422 InvalidRequestError.new(resp.data[:message], opts) when 401 AuthenticationError.new(resp.data[:message], opts) when 403 PermissionError.new(resp.data[:message], opts) when 429 RateLimitError.new(resp.data[:message], opts) else APIError.new(resp.data[:message], opts) end end